Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

R (4.6)  Module 3 – Factors, matrices, lists and data frames

Lists and nested data

Build R lists that hold anything, read their shape with str(), choose between [, [[ and $, add and delete elements, and flatten nested records into tables.

  • Beginner
  • 10 minutes
  • Examples run with R 4.6.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create lists that hold any objects, and read their shape with str()
  • Choose between [ ], [[ ]] and $, and avoid silent partial matching
  • Add, replace and delete list elements, including elements that hold NULL
  • Convert nested records into named vectors and into data frames

Before you start

What you’ll learn · Before you start

What you will learn

  • Create lists that hold any objects, and read their shape with str()
  • Choose between [ ], [[ ]] and $, and avoid silent partial matching
  • Add, replace and delete list elements, including elements that hold NULL
  • Convert nested records into named vectors and into data frames

Before you start

On this page

A clinic’s system exports each patient as one record: an id, an age, three readings and a list of visits, each visit with its own reason and fee. No vector can hold that, because a vector has one type and one level. A list can: it holds any objects, other lists included, and a model fitted in R comes back as one too.

On runs of R 4.6.1 you will build and read nested lists, choose between [ ], [[ ]] and $ without the partial matching that makes l$al return l$alpha, add and delete elements, and flatten a list of records into a data frame with one row per record.

A list holds objects of any type, and str() shows its shape

list(id = "P-104", age = 52L, ...) makes a list: R's container for objects of any type and length, each element with an optional name. [ ] returns a smaller list, while [[ ]] and $ return one element itself.: an ordered set of elements, each of which can be any object, here a string, an integer, a named numeric vector and another list. An Introduction to R puts it plainly: the components need not be of the same mode or type. Step through the record’s four elements:

Interactive · step by step The shape of one nested record Step through the four elements of the record. The left column shows each element's name; the right column shows what the element holds.
The shape of one nested record, step 5 of 5. Showing id, age, vitals, visits, chr "P-104", int 52, num [1:3], visit 1, visit 2.idagevitalsvisitschr "P-104"int 52num [1:3]named sbp, dbp, pulsevisit 1a list: reason, feevisit 2a list: reason, fee

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Four named elements. list() made a list of four elements, named id, age, vitals and visits. length(rec) is 4, whatever each element holds.
  2. Single values. id and age are atomic vectors of length 1, a character string and an integer. A list does not care that they have different types.
  3. A named vector. vitals is one element that holds a whole numeric vector of three named readings, so one list element can carry many values.
  4. A list inside the list. visits holds a list of two visits, and each visit is a list of its own with a reason and a fee. That is what makes the record nested.
  5. What str() prints. str(rec) prints this tree as text, one line per element, with one more "..$" for every level of nesting, so you can read the shape before you index into it.

The run builds the record and prints its shape:

record.R, typed at the R console Python · record_session.py
from session import run, version

version()
# R reads record.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f record.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f record.R
> # One patient's record from a clinic system.
> rec <- list(
+   id = "P-104",
+   age = 52L,
+   vitals = c(sbp = 138, dbp = 86, pulse = 74),
+   visits = list(
+     list(reason = "check-up", fee = 300),
+     list(reason = "fever", fee = 450)
+   )
+ )
> length(rec)
[1] 4
> names(rec)
[1] "id"     "age"    "vitals" "visits"
> str(rec)
List of 4
 $ id    : chr "P-104"
 $ age   : int 52
 $ vitals: Named num [1:3] 138 86 74
  ..- attr(*, "names")= chr [1:3] "sbp" "dbp" "pulse"
 $ visits:List of 2
  ..$ :List of 2
  .. ..$ reason: chr "check-up"
  .. ..$ fee   : num 300
  ..$ :List of 2
  .. ..$ reason: chr "fever"
  .. ..$ fee   : num 450

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 record_session.py

length(rec) is 4, the number of top-level elements, whatever each one holds, and names(rec) lists their names. The record is a nested list: A list with other lists among its elements, such as a record whose visits element holds one list per visit. str() shows each level with one more ..$ of indentation., because visits is a list of two lists. Printing a nested list shows every value, level by level, which gets long quickly; str(), whose help page says it is especially well suited to compactly display nested lists, prints one line per element instead. Each line gives the type and the first values, and each extra ..$ is one more level of nesting. Run str() on any object you did not build yourself before you index into it.

The same tool reads what R hands back. A fitted model is a list:

model_list.R, typed at the R console Python · model_list_session.py
from session import run, version

version()
# R reads model_list.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f model_list.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f model_list.R
> # A fitted model is a list too.
> fit <- lm(dist ~ speed, data = cars)
> is.list(fit)
[1] TRUE
> length(fit)
[1] 12
> str(fit, max.level = 1, give.attr = FALSE)
List of 12
 $ coefficients : Named num [1:2] -17.58 3.93
 $ residuals    : Named num [1:50] 3.85 11.85 -5.95 12.05 2.12 ...
 $ effects      : Named num [1:50] -303.914 145.552 -8.115 9.885 0.194 ...
 $ rank         : int 2
 $ fitted.values: Named num [1:50] -1.85 -1.85 9.95 9.95 13.88 ...
 $ assign       : int [1:2] 0 1
 $ qr           :List of 5
 $ df.residual  : int 48
 $ xlevels      : Named list()
 $ call         : language lm(formula = dist ~ speed, data = cars)
 $ terms        :Classes 'terms', 'formula'  language dist ~ speed
 $ model        :'data.frame':	50 obs. of  2 variables:
> fit$coefficients
(Intercept)       speed 
 -17.579095    3.932409 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 model_list_session.py

lm() returned a list of 12 elements, among them the coefficients, the residuals and the QR decomposition, and fit$coefficients takes one of them out. str(fit, max.level = 1) stops after the top level, which keeps the output to one screen. Fitted models come back in the modelling module of this track, read the same way.

Checkpoint

Check: Lists and str()

  1. Question 1 of 2 x <- list(1:10, letters, list(1, 2)). What does length(x) return?

    Type a number.

    A hint

    length() of a list counts its top-level elements.

    Show the answer: Question 1 of 2

    Answer: 3 elements

    x has three elements, a vector of 10 numbers, a vector of 26 letters and a list of two, so its length is 3, however long each element is.

  2. Question 2 of 2 In the str() output of the record, $ visits:List of 2 is followed by ..$ :List of 2. What does that second line say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first element of visits has no name and is itself a list of 2

    str() prints one line per element and adds ..$ for every level, so the line is the first visit, an unnamed list of two elements.

Single brackets keep a list, double brackets take the element out

rec["vitals"] and rec[["vitals"]] look alike and return different things. Single brackets select elements and keep them in a list, so rec["vitals"] is a list of length 1. Double brackets and $ take one element out of the list, so rec[["vitals"]] is the numeric vector itself. Step through what each form reaches:

Interactive · step by step What [ ], [[ ]] and $ take out of the record Step through the index calls of the example. The parts an index reaches are in focus, and the note says what R returns.
What [ ], [[ ]] and $ take out of the record, step 6 of 6. Showing id, age, vitals, visits, chr "P-104", int 52, num [1:3], visit 1, visit 2.idagevitalsvisitschr "P-104"int 52num [1:3]named sbp, dbp, pulsevisit 1check-up, 300visit 2fever, 450NULL for a name, error past the end

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. rec["vitals"]. Single brackets select elements and keep them in a list, so the result is a list of length 1 whose one element is vitals, name and all.
  2. rec[["vitals"]]. Double brackets take one element out of the list. The result is the numeric vector itself, with its three named readings.
  3. rec$vitals["sbp"]. $ takes an element out by name, like [[ ]], and the result is an ordinary named vector, so ["sbp"] indexes it as any vector.
  4. Three steps in. rec[["visits"]][[2]][["reason"]] goes one level at a time: the visits list, its second visit, then that visit's reason.
  5. rec[[c(4, 2, 1)]]. A vector inside [[ ]] walks the same path by position, element 4, then 2, then 1, and reaches the same value.
  6. Names and positions not there. rec[["weight"]] returns NULL, because the list has no element of that name, while rec[[7]] stops with "subscript out of bounds".

The run of the same calls, then arithmetic on two of the results:

access.R, typed at the R console Python · access_session.py
from session import run, version

version()
# R reads access.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f access.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f access.R
> rec <- list(
+   id = "P-104",
+   age = 52L,
+   vitals = c(sbp = 138, dbp = 86, pulse = 74),
+   visits = list(
+     list(reason = "check-up", fee = 300),
+     list(reason = "fever", fee = 450)
+   )
+ )
> rec["vitals"]
$vitals
  sbp   dbp pulse 
  138    86    74 

> class(rec["vitals"])
[1] "list"
> rec[["vitals"]]
  sbp   dbp pulse 
  138    86    74 
> class(rec[["vitals"]])
[1] "numeric"
> rec$vitals["sbp"]
sbp 
138 
> rec[["visits"]][[2]][["reason"]]
[1] "fever"
> rec[[c(4, 2, 1)]]
[1] "fever"
> # A name that is not there, and a position that is not there.
> rec[["weight"]]
NULL
> try(rec[[7]])
Error in rec[[7]] : subscript out of bounds
> # Arithmetic on what single brackets return.
> try(rec["vitals"] * 2)
Error in rec["vitals"] * 2 : non-numeric argument to binary operator
> rec[["vitals"]] * 2
  sbp   dbp pulse 
  276   172   148 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 access_session.py

An Introduction to R calls the difference very important: [[ ]] selects a single element, while [ ] is the general subscripting operator, which returns a sub-list. So class(rec["vitals"]) is "list", and class(rec[["vitals"]]) is "numeric". Use [ ] to keep several elements as a list, as in rec[c("id", "age")], and [[ ]] or $ to work with the value of one.

Nested values take one step per level: rec[["visits"]][[2]][["reason"]] is the reason of the second visit. The help page of Extract adds that [[ ]] applies recursively when it is given a vector, so rec[[c(4, 2, 1)]] walks the same path by position. A missing element behaves differently by kind of index: rec[["weight"]] returns NULL, because the list has no element of that name, while rec[[7]] stops with “subscript out of bounds”.

Doing arithmetic on a single-bracket result

rec["vitals"] * 2 stops with “non-numeric argument to binary operator”, as the end of the run shows, because single brackets returned a list. Write rec[["vitals"]] * 2 or rec$vitals * 2 to work with the numbers inside.

Checkpoint

Check: Single and double brackets

  1. Question 1 of 2 What do the last two lines return?

    Read the code, then choose one answer.

    l <- list(a = 1:3, b = "x")
    class(l["a"])
    class(l[["a"]])
    Show the answer: Question 1 of 2

    Answer: "list", then "integer"

    [ ] selects elements and returns a list; [[ ]] and $ return one element itself.

  2. Question 2 of 2 cfg <- list(db = list(host = "localhost", port = 5432)). Which line returns 5432?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: cfg[["db"]][["port"]]

    Go one level at a time with [[ ]], or give the path in one go with cfg[[c("db", "port")]].

The dollar sign matches partial names; double brackets do not

l <- list(alpha = 1, beta = 2) has no element called al, yet l$al returns 1. $ accepts the start of a name when only one name starts that way, a rule called partial matching: Accepting the start of a name as the whole name when exactly one name starts that way. $ does it on lists and data frames, so l$al can return l$alpha; [[ ]] does not unless exact = FALSE., and the help page of Extract states it exactly: x$name is equivalent to x[["name", exact = FALSE]]. The run tries both kinds of brackets, a data frame and a name that matches two elements:

partial.R, typed at the R console Python · partial_session.py
from session import run, version

version()
# R reads partial.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f partial.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f partial.R
> l <- list(alpha = 1, beta = 2)
> l$al
[1] 1
> l[["al"]]
NULL
> l[["al", exact = FALSE]]
[1] 1
> d <- data.frame(alpha = 1:2)
> d$al
[1] 1 2
> # Two names start with "al", so nothing matches.
> two <- list(alpha = 1, alto = 2)
> two$al
NULL
> # Ask R to warn about every partial match.
> options(warnPartialMatchDollar = TRUE)
> l$al
[1] 1
Warning message:
In l$al : partial match of 'al' to 'alpha'
> d$al
[1] 1 2
Warning message:
In d$al : partial match of 'al' to 'alpha'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 partial_session.py

Expression Result Why
l$al 1, silently $ matches the start of the name, alpha
l[["al"]] NULL [[ ]] matches whole names by default, exact = TRUE
l[["al", exact = FALSE]] 1 partial matching, asked for
d$al on a data frame the column alpha $ matches columns of data frames too
two$al, with alpha and alto NULL two names start with al, so neither is chosen

In R 4.6.1 nothing warns by default. options(warnPartialMatchDollar = TRUE), which its help page describes as a warning whenever $ uses partial matching, makes the last two lines of the run say “partial match of ‘al’ to ‘alpha’”. Turn it on while you develop a script, and write [[ ]] inside functions, where a name may come from a variable: a typo then gives NULL instead of the wrong element.

Key idea

$ completes a name that is only partly typed, without a word; [[ ]] matches whole names only. Use $ at the console and [[ ]] in code that must not guess.

Checkpoint

Check: Partial matching with $

  1. Question 1 of 2 What does the last line return?

    Read the code, then choose one answer.

    l <- list(value = 10)
    l$val
    Show the answer: Question 1 of 2

    Answer: 10

    x$name is equivalent to x[["name", exact = FALSE]], so a unique start of a name is enough.

  2. Question 2 of 2 What does the last line return?

    Read the code, then choose one answer.

    l <- list(value = 10, valid = TRUE)
    l$val
    Show the answer: Question 2 of 2

    Answer: NULL

    A partial name must match exactly one element. With value and valid, l$val is NULL, as two$al was in the run.

Assigning NULL deletes an element; list(NULL) stores one

The same brackets work on the left of <-, and one value behaves differently there: NULL: R's object for nothing, of length 0. Assigning NULL to a list element deletes the element, while list(NULL) stores an element that holds NULL., R’s object for nothing. Assigning NULL to an element deletes the element, so the list gets shorter; storing NULL as a value takes a list that holds it. Step through four changes to a small list:

Interactive · step by step Deleting an element, and storing NULL in one Step through the assignments of the example. Each box on the left is an element of s with what it holds; the box on the right gives the length of s.
Deleting an element, and storing NULL in one, step 5 of 5. Showing element a, element c (holds NULL), element d, length(s) (3).element anum 1element cholds NULLelement dnum 5length(s)3

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Three elements. s starts as a list of three elements, a, b and c, of three different types.
  2. s$b <- NULL. Assigning NULL to an element removes it from the list. b is gone, and the list is one shorter.
  3. s["c"] <- list(NULL). Assigning a list that holds NULL, with single brackets, keeps the element and stores NULL in it. c is still there, and the length stays 2.
  4. s$d <- 5. Assigning to a name the list does not have adds a new element at the end.
  5. Where NULL can live. list(1, NULL, 3) has length 3, because a NULL inside list() is an element. Only assignment of a bare NULL deletes.

The run of those changes, and a merge of settings:

modify.R, typed at the R console Python · modify_session.py
from session import run, version

version()
# R reads modify.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f modify.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f modify.R
> s <- list(a = 1, b = "x", c = TRUE)
> s$b <- NULL
> str(s)
List of 2
 $ a: num 1
 $ c: logi TRUE
> s["c"] <- list(NULL)
> str(s)
List of 2
 $ a: num 1
 $ c: NULL
> s$d <- 5
> names(s)
[1] "a" "c" "d"
> length(list(1, NULL, 3))
[1] 3
> # Settings: defaults, then what a user changed.
> defaults <- list(digits = 2, plot = list(width = 6, height = 4), title = "Report")
> user <- list(plot = list(width = 8))
> str(modifyList(defaults, user))
List of 3
 $ digits: num 2
 $ plot  :List of 2
  ..$ width : num 8
  ..$ height: num 4
 $ title : chr "Report"
> str(c(defaults, user))
List of 4
 $ digits: num 2
 $ plot  :List of 2
  ..$ width : num 6
  ..$ height: num 4
 $ title : chr "Report"
 $ plot  :List of 1
  ..$ width: num 8
> c(defaults, user)$plot$width
[1] 6

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 modify_session.py

s$b <- NULL removed b. s["c"] <- list(NULL) kept c and made it hold NULL: single brackets on the left take a list of values, and the one value here is NULL. Assigning to a name that is not there, s$d <- 5, adds an element at the end. And list(1, NULL, 3) has length 3, because inside list() a NULL is an element like any other.

Settings are where this matters most. modifyList(defaults, user) merges a list of user choices into the defaults, level by level: its help page says it changes the matching elements at each level and adds new ones, so the user’s width = 8 replaced the default 6 while height = 4 stayed. c(defaults, user) only appends, and the result has two elements called plot; $ returns the first, so c(defaults, user)$plot$width is still the default 6, and the user’s choice is silently ignored.

Merge settings with modifyList()

modifyList(defaults, user) keeps every default the user did not change, even inside nested lists. A NULL in user deletes that setting unless you pass keep.null = TRUE, so decide which of the two your program needs.

Checkpoint

Check: Adding and deleting elements

  1. Question 1 of 2 s <- list(a = 1, b = 2, c = 3), then s$b <- NULL. What does length(s) return?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 2 elements

    Assigning NULL to an element deletes it, so s keeps a and c. s["b"] <- list(NULL) would have kept 3 elements.

  2. Question 2 of 2 defaults <- list(size = 10, colour = "blue") and user <- list(size = 12). Which line gives settings whose $size is 12 and whose $colour is "blue"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: modifyList(defaults, user)

    modifyList() merges by name, level by level. c() only appends, so the old value is the one $ finds.

unlist() flattens one record; a row builder flattens many

unlist() turns a nested list into one atomic vector and builds names from the path: unlist(list(a = 1, b = list(c = 2, d = 3))) gives a, b.c and b.d. The help page of unlist says the elements are coerced to a common type, which is why a record that mixes text and numbers flattens to text: "P-104", "138" and "86". That is the coercion: Converting values from one type to another. c() does it by itself when it combines values of different types; as.numeric(), as.character() and the other as.*() functions do it on request. More in “Atomic vectors, types and coercion” of c(), and it is fine for one record you want to look at, not for numbers you want to compute with.

For many records, the aim is a data frame with one row per record and one column per field. The shortcut, as.data.frame() of each record stacked with rbind(), fails as soon as the records disagree. Step through the route that works:

Interactive · stage by stage From a list of records to one data frame Step through the flattening of the example. The column is the route that works; the branch on the right is the shortcut that stops with an error.
From a list of records to one data frame, step 4 of 4. Showing recs, lapply(recs (3 rows of id, age, sbp), do.call(rbind, rows) (3 rows, 3 columns). In focus: do.call(rbind, rows).as.data.frame eachrecs3 records, 2 incompletelapply(recs3 rows of id, age, sbpdo.call(rbind, rows)3 rows, 3 columns

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The records. Three visit records arrive as a list of lists. P-102 has no sbp, and P-103 lists its fields in another order.
  2. The shortcut. as.data.frame() of each record makes data frames of 3, 2 and 3 columns, and rbind() stops with "numbers of columns of arguments do not match".
  3. One row per record. row_of() asks every record for the same three fields, id, age and sbp, in that order, and puts NA where a field is missing.
  4. Stack the rows. do.call(rbind, rows) passes the three one-row data frames to rbind() as separate arguments, which stacks them into 3 rows with NA where P-102 had no sbp.

The run tries the shortcut, then builds the rows:

flatten.R, typed at the R console Python · flatten_session.py
from session import run, version

version()
# R reads flatten.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f flatten.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f flatten.R
> unlist(list(a = 1, b = list(c = 2, d = 3)))
  a b.c b.d 
  1   2   3 
> unlist(list(id = "P-104", vitals = c(sbp = 138, dbp = 86)))
        id vitals.sbp vitals.dbp 
   "P-104"      "138"       "86" 
> # Three visit records; two of them miss a field.
> recs <- list(
+   list(id = "P-101", age = 34, sbp = 122),
+   list(id = "P-102", age = 61),
+   list(id = "P-103", sbp = 141, age = 47)
+ )
> try(do.call(rbind, lapply(recs, as.data.frame)))
Error in rbind(deparse.level, ...) : 
  numbers of columns of arguments do not match
> fields <- c("id", "age", "sbp")
> row_of <- function(r) {
+   vals <- lapply(fields, function(f) if (is.null(r[[f]])) NA else r[[f]])
+   names(vals) <- fields
+   as.data.frame(vals)
+ }
> visits <- do.call(rbind, lapply(recs, row_of))
> visits
     id age sbp
1 P-101  34 122
2 P-102  61  NA
3 P-103  47 141
> str(visits)
'data.frame':	3 obs. of  3 variables:
 $ id : chr  "P-101" "P-102" "P-103"
 $ age: num  34 61 47
 $ sbp: num  122 NA 141

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 flatten_session.py

row_of() asks every record for the same fields, in the same order, and uses NA where r[[f]] is NULL, so every row has the same three columns and a missing reading becomes a missing value instead of a missing column. lapply() calls it once per record, and do.call(rbind, rows), which constructs a call from a function and a list of arguments, as its help page puts it, hands the three one-row data frames to rbind() as separate arguments. str() confirms the types: id is text, and age and sbp are numbers, with one NA.

JSON Viewer & Tree Editor Open a JSON file as a collapsible tree and see how deeply its records nest before you turn them into R lists.

Checkpoint

Check: Flattening records

  1. Question 1 of 2 What does the last line return?

    Read the code, then choose one answer.

    unlist(list(a = 1, b = list(x = 2, y = "z")))
    Show the answer: Question 1 of 2

    Answer: A character vector named a, b.x and b.y: "1" "2" "z"

    unlist() joins names with a dot and uses the highest type of the hierarchy, here character.

  2. Question 2 of 2 recs is a list of records, and some records lack fields. Which approach gives one row per record, with NA for every missing field?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Build each row from a fixed list of fields, with NA for a NULL field, then do.call(rbind, rows)

    Decide the columns first, then ask every record for each of them. That is what row_of() does in the lesson.

Practise: predict, then check

Eight lines poke at one small nested list. Note what you expect from each of them, and only then look.

Before you look

For x <- list(a = 1:3, b = list(c = "z")), predict length(x), class(x["a"]), x$b$c and x$a[2]; then length(x) after x$b <- NULL; and unlist(list(p = 1, q = list(r = "2"))). Then type the lines into your own R.

practice.R, typed at the R console Python · practice_session.py
from session import run, version

version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> x <- list(a = 1:3, b = list(c = "z"))
> length(x)
[1] 2
> class(x["a"])
[1] "list"
> x$b$c
[1] "z"
> x$a[2]
[1] 2
> x$b <- NULL
> length(x)
[1] 1
> unlist(list(p = 1, q = list(r = "2")))
  p q.r 
"1" "2" 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

The list has two top-level elements, however long they are; single brackets returned a list; $ went down two levels to "z"; x$a[2] indexed the vector inside; assigning NULL deleted b; and unlist() coerced 1 to text to sit beside "2", with the names p and q.r.

Now the questions, beginning with the idea you have answered least well, each answer explained at once:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Which of these can be an element of a list? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 11

    Answer:

    • Another list
    • A function
    • A numeric vector of 1,000 values

    An Introduction to R says the components of a list need not be of the same mode or type; vectors, functions, data frames and other lists can all be elements.

  2. Question 2 of 11 fit <- lm(dist ~ speed, data = cars). Which line returns the residuals as a numeric vector of 50 values?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: fit[["residuals"]]

    str(fit, max.level = 1) lists the 12 elements of the model; [[ ]] or $ takes any one of them out.

  3. Question 3 of 11 What does the last line return?

    Read the code, then choose one answer.

    l2 <- list(a = list(b = 7))
    l2[[c("a", "b")]]
    Show the answer: Question 3 of 11

    Answer: 7

    l2[[c("a", "b")]] is the same as l2[["a"]][["b"]], which is 7.

  4. Question 4 of 11 The record has no element called weight. What does rec[["weight"]] do, and what would x[["weight"]] do on a named atomic vector x?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: The list returns NULL; the vector stops with "subscript out of bounds"

    On a list, check for NULL with is.null() after [[ ]] when a field may be missing, as row_of() does.

  5. Question 5 of 11 l <- list(alpha = 1). What does l[["al"]] return?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: NULL

    Double brackets need the whole name, which is why they are safer than $ in code.

  6. Question 6 of 11 get_port <- function(cfg) cfg$port returns "x" for cfg <- list(portal = "x"), which has no port at all. What is the fix?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: Write cfg[["port"]], which returns NULL when port is missing

    $ matched portal because it starts with port. Inside functions use [[ ]], and check the result with is.null().

  7. Question 7 of 11 What does length(list(1, NULL, 3)) return?

    Type a number.

    Show the answer: Question 7 of 11

    Answer: 3 elements

    Inside list(), NULL is an element like any other. Only assigning a bare NULL to an element deletes it.

  8. Question 8 of 11 What does the last line print?

    Read the code, then choose one answer.

    str(modifyList(list(a = 1, b = 2), list(b = NULL)))
    Show the answer: Question 8 of 11

    Answer: List of 1, with a: num 1

    modifyList() deletes the elements that the second list sets to NULL, unless keep.null = TRUE.

  9. Question 9 of 11 What type does unlist(list(1, "a", TRUE)) return?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Character

    unlist() coerces like c(): logical < integer < double < character. Mixed records flatten to text.

  10. Question 10 of 11 rows is a list of three one-row data frames. Which call is do.call(rbind, rows) equivalent to?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: rbind(rows[[1]], rows[[2]], rows[[3]])

    do.call() constructs a call from a function and a list of arguments, so rbind() receives the three rows at once.

  11. Question 11 of 11 Each visit record has a field dx that holds a list of one or more diagnosis codes. You need to count visits by diagnosis. How do you shape the data?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: A visits table with one row per visit, plus a diagnoses table with one row per visit and code, keyed by the visit id

    A field with several values per record belongs in a table of its own, one row per value, linked by an id.

Interview questions

Warm-up: why can l$al return a value when the list has no element called al, and how do you avoid it?

$ uses partial matching: when the name you type is the start of exactly one element’s name, it returns that element, so with l <- list(alpha = 1), l$al gives 1. The help page of Extract says x$name is equivalent to x[["name", exact = FALSE]]. Data frames behave the same way, so d$al can return the column alpha. If two names start that way, $ returns NULL. Nothing warns by default, so a typo or a shortened name can read the wrong element without a sign. To avoid it, use [[ ]] in code, which matches whole names only and returns NULL for a name that is not there, and turn on options(warnPartialMatchDollar = TRUE) while developing, which makes every partial match print a warning.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A pipeline merges a user's settings into its defaults with c(defaults, user), both nested lists read from files. Changing plot$width in the user's file has no effect. A teammate switches to modifyList(), and now a user who writes title: null crashes a later step. Explain both problems and how you would make the merge safe.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    An API returns 2,000 visit records as a list of lists. Some records lack fields, some carry extra ones, and one field holds a list of diagnosis codes. do.call(rbind, lapply(recs, as.data.frame)) stops with "numbers of columns of arguments do not match". How do you build a visits table with NA for missing fields, and keep the diagnoses?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A list holds objects of any type and length, other lists included, and str() shows a nested list’s shape one line per element.
  • [ ] returns a smaller list, while [[ ]] and $ return one element itself; [[ ]] with a vector walks a nested path, and a missing name gives NULL.
  • $ matches partial names silently, on lists and data frames; [[ ]] matches whole names only, and options(warnPartialMatchDollar = TRUE) warns about every partial match.
  • Assigning NULL deletes an element, [ ] <- list(NULL) stores NULL in one, and modifyList() merges nested settings, where c() would only append duplicates.
  • unlist() flattens one record into a named vector of one type; to flatten many, build one row per record with the same fields and stack the rows with do.call(rbind, rows).

Terms in this lesson

list
R's container for objects of any type and length, each element with an optional name. [ ] returns a smaller list, while [[ ]] and $ return one element itself.
nested list
A list with other lists among its elements, such as a record whose visits element holds one list per visit. str() shows each level with one more ..$ of indentation.
NULL
R's object for nothing, of length 0. Assigning NULL to a list element deletes the element, while list(NULL) stores an element that holds NULL.
partial matching
Accepting the start of a name as the whole name when exactly one name starts that way. $ does it on lists and data frames, so l$al can return l$alpha; [[ ]] does not unless exact = FALSE.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.