Vectors, lists, matrices and data frames
Lesson 3 — R Programming
Learning objectives
- Explain what a vector is and why R has no scalars
- Create, name, inspect, subset and modify vectors confidently
- Predict type coercion and handle missing values correctly
- Explain recycling and recognise when it is silently hurting you
- Use lists for heterogeneous and nested data, and master
[vs[[ - Work with matrices, including
drop = FALSEand%*%vs* - Explain why a data frame is a list of vectors, and what follows from that
- Distinguish
data.framefromtibbleand subset both without surprises
Vectors
A vector is the simplest and most important data structure in R. It contains multiple values of the same data type.
Two ideas are doing all the work in that sentence:
- Multiple values in one object. A vector holds any number of elements — zero, one, or two million — and R operates on all of them at once.
- All the same type. Every element must be a logical, or every element a number, or every element a character. A vector cannot mix them.
ages <- c(45, 52, 38, 61, 29) # all numbers
sexes <- c("F", "M", "F", "M", "F") # all characters
saffl <- c(TRUE, TRUE, FALSE, TRUE, TRUE) # all logicals
ages
#> [1] 45 52 38 61 29
length(ages)
#> [1] 5
typeof(ages)
#> [1] "double"That [1] at the start of the output is R telling you the index of the first element on that line. With a longer vector it becomes obvious:
1:30
#> [1] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
#> [23] 23 24 25 26 27 28 29 30The second line starts at element 23, hence [23].
A single number is a vector of length one. There is no separate “scalar” type.
x <- 42
length(x) #> [1] 1
is.vector(x) #> [1] TRUE
x[1] #> [1] 42 — you can subscript itThis is the single most important structural fact about R, and it is why x * 2 works whether x holds one value or a million. Coming from Python or SAS, where a scalar and a collection are different things, this takes a little adjustment — and then makes a great deal of R make sense.
Creating vectors
c() — combine. The most common constructor. The c stands for “combine”, and it flattens whatever you give it:
c(1, 2, 3) #> [1] 1 2 3
c("ALT", "AST", "BILI") #> [1] "ALT" "AST" "BILI"
c(TRUE, FALSE, NA) #> [1] TRUE FALSE NA
# c() flattens — it does not nest
c(c(1, 2), c(3, 4)) #> [1] 1 2 3 4
c(1, c(2, c(3, 4))) #> [1] 1 2 3 4Sequences.
1:5 #> [1] 1 2 3 4 5
5:1 #> [1] 5 4 3 2 1 — counts down
-2:2 #> [1] -2 -1 0 1 2
seq(0, 1, by = 0.25) #> [1] 0.00 0.25 0.50 0.75 1.00
seq(0, 100, length.out = 5) #> [1] 0 25 50 75 100
seq(2, 20, by = 2) #> [1] 2 4 6 8 10 12 14 16 18 20
seq_len(5) #> [1] 1 2 3 4 5
seq_along(c("a", "b", "c")) #> [1] 1 2 3Repetition.
rep(0, times = 5) #> [1] 0 0 0 0 0
rep(c(1, 2), times = 3) #> [1] 1 2 1 2 1 2
rep(c(1, 2), each = 3) #> [1] 1 1 1 2 2 2
rep(c(1, 2), times = c(3, 1)) #> [1] 1 1 1 2
rep(c("Placebo", "Drug A"), each = 2)
#> [1] "Placebo" "Placebo" "Drug A" "Drug A"The difference between times and each catches people out: times repeats the whole vector, each repeats every element in place.
Empty vectors of a known type and length. Used to pre-allocate before a loop:
numeric(5) #> [1] 0 0 0 0 0
character(3) #> [1] "" "" ""
logical(2) #> [1] FALSE FALSE
integer(3) #> [1] 0 0 0
vector("numeric", 5) # the general form
vector("character", 3)A worked example. Building a small visit schedule:
weeks <- c(0, 2, 4, 8, 12, 16, 24)
visit <- c("Baseline", paste("Week", weeks[-1]))
scheduled <- rep(TRUE, length(weeks))
weeks
#> [1] 0 2 4 8 12 16 24
visit
#> [1] "Baseline" "Week 2" "Week 4" "Week 8" "Week 12" "Week 16" "Week 24"
length(weeks) == length(visit)
#> [1] TRUENote weeks[-1] — drop the first element — and that paste() is itself vectorised, producing one string per week without a loop.
Vector types
There are six atomic types. Four of them you will use constantly.
| Type | Example | typeof() |
Test |
|---|---|---|---|
| logical | TRUE, FALSE, NA |
"logical" |
is.logical() |
| integer | 1L, 42L |
"integer" |
is.integer() |
| double | 1, 3.14, 1e6 |
"double" |
is.double() |
| character | "ALT", 'F' |
"character" |
is.character() |
| complex | 1+2i |
"complex" |
is.complex() |
| raw | as.raw(1) |
"raw" |
is.raw() |
typeof(c(TRUE, FALSE)) #> [1] "logical"
typeof(c(1L, 2L)) #> [1] "integer"
typeof(c(1, 2)) #> [1] "double" — note: NOT integer
typeof(c("a", "b")) #> [1] "character"Three functions that answer slightly different questions:
x <- c(1.5, 2.5, 3.5)
typeof(x) #> [1] "double" — how R stores it
class(x) #> [1] "numeric" — how R dispatches methods on it
mode(x) #> [1] "numeric" — an older, coarser classification
is.numeric(x) #> [1] TRUE — TRUE for both integer and doubleFor everyday checking, is.numeric() and is.character() are what you want. Reach for typeof() when you are debugging something surprising.
One type only: coercion
Because a vector can hold only one type, mixing types forces R to convert them all to the most flexible one present. It does this silently.
The hierarchy, from least to most flexible:
logical → integer → double → character
c(TRUE, 1L) #> [1] 1 1 logical → integer
typeof(c(TRUE, 1L)) #> [1] "integer"
c(1L, 2.5) #> [1] 1.0 2.5 integer → double
typeof(c(1L, 2.5)) #> [1] "double"
c(1, "a") #> [1] "1" "a" double → character
typeof(c(1, "a")) #> [1] "character"
c(TRUE, "yes") #> [1] "TRUE" "yes" logical → characterReading a CSV where a single row of an otherwise numeric column contains "N/A", "Not done" or "." turns the entire column into character.
lab_values <- c("23.4", "31.2", "N/A", "28.7")
typeof(lab_values)
#> [1] "character"
mean(lab_values)
#> Error in mean.default(lab_values) : argument is not numeric or logicalThe failure surfaces at mean(), far from the actual cause. Convert explicitly, and check what failed:
values <- suppressWarnings(as.numeric(lab_values))
values
#> [1] 23.4 31.2 NA 28.7
# Which ones could not be converted?
lab_values[is.na(values) & !is.na(lab_values)]
#> [1] "N/A"That last line is worth keeping. It tells you which values were lost, rather than leaving you to discover the count changed later.
The logical-to-number coercion is genuinely useful and you will use it often:
ages <- c(45, 52, 38, 61, 29, 71)
ages >= 65
#> [1] FALSE FALSE FALSE FALSE FALSE TRUE
sum(ages >= 65) #> [1] 1 — how many
mean(ages >= 65) #> [1] 0.1666667 — what proportionTRUE becomes 1 and FALSE becomes 0, so sum() counts and mean() gives a proportion. This is the idiomatic way to count matching records in R.
Explicit conversion:
as.numeric("3.14") #> [1] 3.14
as.integer("42") #> [1] 42
as.integer(3.99) #> [1] 3 — truncates, does not round
as.character(3.14) #> [1] "3.14"
as.logical("TRUE") #> [1] TRUE
as.logical("T") #> [1] TRUE
as.logical("yes") #> [1] NA — careful
as.logical(0) #> [1] FALSENamed vectors
Every element can carry a name. This turns a vector into a small lookup table.
lab_ranges <- c(ALT = 40, AST = 35, BILI = 20.5, ALP = 120)
lab_ranges
#> ALT AST BILI ALP
#> 40.0 35.0 20.5 120.0
names(lab_ranges)
#> [1] "ALT" "AST" "BILI" "ALP"
lab_ranges["ALT"]
#> ALT
#> 40
lab_ranges[["ALT"]] # drops the name, returns a bare number
#> [1] 40Names can be added or changed after creation:
x <- c(10, 20, 30)
names(x) <- c("low", "mid", "high")
x
#> low mid high
#> 10 20 30
# Or in one step
x <- setNames(c(10, 20, 30), c("low", "mid", "high"))
names(x) <- NULL # remove the namesUsing a named vector as a lookup. This is one of the most useful and most underused patterns in R:
arm_labels <- c(PBO = "Placebo", A50 = "Drug A 50mg", A100 = "Drug A 100mg")
codes <- c("A50", "PBO", "A100", "PBO", "A50")
arm_labels[codes]
#> A50 PBO A100 PBO A50
#> "Drug A 50mg" "Placebo" "Drug A 100mg" "Placebo" "Drug A 50mg"
# Usually you want the values without the names
unname(arm_labels[codes])
#> [1] "Drug A 50mg" "Placebo" "Drug A 100mg" "Placebo" "Drug A 50mg"One expression replaces an entire if/else chain or a join. Watch for unmatched codes, which give NA with an <NA> name:
arm_labels[c("A50", "UNKNOWN")]
#> A50 <NA>
#> "Drug A 50mg" NAInspecting a vector
x <- c(23.4, 31.2, 28.7, 45.1, 19.8, 31.2, NA, 52.6)
length(x) #> [1] 8
head(x, 3) #> [1] 23.4 31.2 28.7
tail(x, 2) #> [1] NA 52.6
str(x) #> num [1:8] 23.4 31.2 28.7 45.1 19.8 ...
summary(x)
#> Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
#> 19.80 27.38 31.20 33.14 41.62 52.60 1
sort(x) #> [1] 19.8 23.4 28.7 31.2 31.2 45.1 52.6 — NA removed
sort(x, decreasing = TRUE)
rev(x) # reverse the current order
unique(x) #> [1] 23.4 31.2 28.7 45.1 19.8 NA 52.6
duplicated(x) #> [1] F F F F F T F F
table(c("F","M","F","F"))
#>
#> F M
#> 3 1
which(x > 30) #> [1] 2 4 6 8 — the POSITIONS, not the values
which.max(x) #> [1] 8
range(x, na.rm = TRUE) #> [1] 19.8 52.6which() returning positions rather than values is worth internalising — it is what you use when you need the index, for example to look up the same position in a parallel vector.
Subsetting
Four ways, all useful. This is the same [ operator every time; only what you put inside it changes.
x <- c(a = 10, b = 20, c = 30, d = 40)1. Positive integers — keep those positions.
x[1] #> a
#> 10
x[c(1, 3)] #> a c
#> 10 30
x[2:4] #> b c d
#> 20 30 40
x[c(1, 1, 2)] #> repetition is allowed
#> a a b
#> 10 10 202. Negative integers — drop those positions.
x[-1] #> b c d
#> 20 30 40
x[-c(1, 4)] #> b c
#> 20 30
x[-(1:2)] #> c d
#> 30 403. A logical vector — keep where TRUE.
x > 15 #> a b c d
#> FALSE TRUE TRUE TRUE
x[x > 15] #> b c d
#> 20 30 40
x[c(TRUE, FALSE, TRUE, FALSE)]
#> a c
#> 10 30This is the most important form. Any expression producing logicals can go inside the brackets:
ages <- c(45, 52, 38, 61, 29, 71)
sexes <- c("F", "M", "F", "M", "F", "M")
ages[sexes == "F"] #> [1] 45 38 29
ages[ages >= 40 & ages < 65] #> [1] 45 52 61
ages[sexes == "M" | ages > 60] #> [1] 52 61 71Note that the logical vector is built from sexes but used to subset ages — that works because the two vectors are the same length and in the same order.
4. Names.
x[c("a", "d")] #> a d
#> 10 40
x["b"] #> b
#> 20You cannot mix positive and negative indices:
x[c(1, -2)]
#> Error in x[c(1, -2)] : can't mix positive and negative subscriptsIndex zero returns nothing, silently:
x[0]
#> named numeric(0)That looks harmless until it appears inside a function where an index was computed and came out as 0 — you get an empty result rather than an error.
Out-of-range access gives NA, not an error:
x[10]
#> <NA>
#> NA
length(x[10])
#> [1] 1 — a length-one vector containing NAThis is the one that causes silent bugs. A loop that runs one iteration too far quietly produces NA rather than stopping, and the NA then propagates through everything downstream.
Modifying vectors
Assignment uses the same subsetting forms, on the left of <-:
x <- c(a = 10, b = 20, c = 30, d = 40)
x[1] <- 99 # by position
x["b"] <- 25 # by name
x[x > 50] <- 0 # by condition — replaces every matching element
x[c(3, 4)] <- c(300, 400) # several at once
x
#> a b c d
#> 0 25 300 400Replacing by condition is extremely common in data cleaning:
values <- c(23.4, -1, 31.2, -999, 28.7)
# -1 and -999 are missing-value codes in this source system
values[values < 0] <- NA
values
#> [1] 23.4 NA 31.2 NA 28.7Adding elements.
x <- c(1, 2, 3)
x <- c(x, 4) #> [1] 1 2 3 4
x <- c(0, x) #> [1] 0 1 2 3 4
x <- append(x, 99, after = 2)
#> [1] 0 1 99 2 3 4
x[10] <- 100 # assigning past the end pads with NA
x
#> [1] 0 1 99 2 3 4 NA NA NA 100Removing elements — there is no delete; you subset to what you want to keep:
x <- c(a = 1, b = 2, c = 3)
x <- x[-2] # by position
x <- x[names(x) != "c"] # by name
x <- x[!is.na(x)] # drop missing# SLOW — reallocates and copies the whole vector on every iteration
result <- c()
for (i in 1:100000) {
result <- c(result, i * 2)
}
# FAST — allocate once, fill in place
result <- numeric(100000)
for (i in 1:100000) {
result[i] <- i * 2
}
# FASTEST — no loop at all
result <- (1:100000) * 2On 100,000 elements the first form takes several seconds and the third takes a fraction of a millisecond. The reason is copy-on-modify: c(result, i) creates a new vector every time.
Missing values in vectors
NA is a legitimate element of a vector, and it is contagious.
x <- c(23.4, 31.2, NA, 28.7)
x + 1 #> [1] 24.4 32.2 NA 29.7
mean(x) #> [1] NA
sum(x) #> [1] NAMost summary functions take na.rm to exclude them:
mean(x, na.rm = TRUE) #> [1] 27.76667
sum(x, na.rm = TRUE) #> [1] 83.3
max(x, na.rm = TRUE) #> [1] 31.2
sd(x, na.rm = TRUE) #> [1] 3.973454Detecting and handling:
is.na(x) #> [1] FALSE FALSE TRUE FALSE
sum(is.na(x)) #> [1] 1 — how many are missing
which(is.na(x)) #> [1] 3 — where
x[!is.na(x)] #> [1] 23.4 31.2 28.7 — drop them
na.omit(x) # same, but carries an attribute recording what was removed
anyNA(x) #> [1] TRUENA with ==
x == NA
#> [1] NA NA NA NAComparison with an unknown value is itself unknown, so == can never return TRUE for NA. Always use is.na().
NA is also typed, which matters inside functions that check return types:
typeof(NA) #> [1] "logical"
typeof(NA_integer_) #> [1] "integer"
typeof(NA_real_) #> [1] "double"
typeof(NA_character_) #> [1] "character"Recycling
When two vectors of different length are combined element-wise, R repeats the shorter one to match the longer.
c(1, 2, 3, 4) * 2
#> [1] 2 4 6 8That is recycling: the length-one vector 2 is repeated four times. It is why x * 2 works at all, and it is completely safe when one side has length one.
It is much less safe otherwise:
c(1, 2, 3, 4) + c(10, 20)
#> [1] 11 22 13 24The shorter vector was recycled to c(10, 20, 10, 20). No warning was issued, because 4 is an exact multiple of 2.
R warns only when the lengths are not multiples:
c(1, 2, 3) + c(10, 20)
#> [1] 11 22 13
#> Warning message:
#> In c(1, 2, 3) + c(10, 20) :
#> longer object length is not a multiple of shorter object lengthLengths 4 and 2, or 100 and 10, recycle silently and produce plausible-looking wrong numbers. This is a genuine source of incorrect analysis results.
Two defences:
# 1. Assert the lengths when it matters
stopifnot(length(values) == length(thresholds))
# 2. Use dplyr, whose rules are stricter
library(dplyr)
tibble(x = 1:4) |> mutate(y = x + c(10, 20))
#> Error in `mutate()`:
#> ! `y` must be size 4 or 1, not 2.dplyr permits only length-1 or length-n, which turns the silent case into an immediate error. That is one of the better reasons to prefer mutate() over base assignment for anything analytical.
Vectorised operations
Because a vector holds many values, R applies operations to all of them at once. Writing a loop where a vectorised operation exists is the most common habit that new R users bring from other languages.
ages <- c(45, 52, 38, 61, 29)
ages + 10 #> [1] 55 62 48 71 39
ages / 2 #> [1] 22.5 26.0 19.0 30.5 14.5
ages^2 #> [1] 2025 2704 1444 3721 841
round(ages / 7) #> [1] 6 7 5 9 4
sqrt(c(4, 9, 16)) #> [1] 2 3 4
log(c(1, 10, 100), base = 10) #> [1] 0 1 2Element-wise between two vectors of the same length:
weight <- c(70, 85, 62, 91)
height <- c(175, 180, 160, 172)
bmi <- weight / (height / 100)^2
round(bmi, 1)
#> [1] 22.9 26.2 24.2 30.8Comparison and logical operators are vectorised too:
ages > 40 #> [1] TRUE TRUE FALSE TRUE FALSE
ages == 38 #> [1] FALSE FALSE TRUE FALSE FALSE
ages %in% c(38, 61)#> [1] FALSE FALSE TRUE TRUE FALSE
(ages > 40) & (ages < 60) #> [1] TRUE TRUE FALSE FALSE FALSE
!(ages > 40) #> [1] FALSE FALSE TRUE FALSE TRUE& and | versus && and ||
c(TRUE, FALSE) & c(TRUE, TRUE) #> [1] TRUE FALSE — element-wise
c(TRUE, FALSE) && c(TRUE, TRUE) #> Error in ... : 'length = 2' in coercion to 'logical(1)'Single & and | are vectorised — use them for subsetting and in filter(). Double && and || work on a single value and are for if statements. Since R 4.3 using && on a longer vector is an error rather than a silent truncation, which was a welcome change.
Vectorised thinking
The practical upshot: reach for a vectorised expression before a loop.
ages <- c(45, 52, 38, 61, 29)
# Not this
group <- character(length(ages))
for (i in seq_along(ages)) {
group[i] <- if (ages[i] >= 65) "elderly" else "adult"
}
# This
group <- ifelse(ages >= 65, "elderly", "adult")
# Or, for more than two branches
group <- dplyr::case_when(
ages >= 65 ~ "elderly",
ages >= 18 ~ "adult",
.default = "minor"
)ifelse() drops attributes
ifelse() returns a value whose type is taken from the first non-missing result, and it drops the class attribute. On dates this bites:
d <- as.Date(c("2026-01-01", "2026-06-01"))
ifelse(d > as.Date("2026-03-01"), d, NA)
#> [1] NA 20240 <- numbers, not dates
dplyr::if_else(d > as.Date("2026-03-01"), d, as.Date(NA))
#> [1] NA "2026-06-01" <- correctdplyr::if_else() is type-strict and preserves class. Prefer it.
Useful vector functions
| Function | Returns |
|---|---|
length(x) |
Number of elements |
sum(x), mean(x), median(x) |
Summary statistics |
min(x), max(x), range(x) |
Extremes |
sd(x), var(x) |
Spread |
cumsum(x), cumprod(x) |
Running totals |
sort(x), order(x) |
Sorted values / the sorting indices |
rev(x) |
Reversed |
unique(x), duplicated(x) |
Distinct values / duplicate flags |
table(x) |
Frequency counts |
which(x > 5) |
Positions where the condition is TRUE |
which.max(x), which.min(x) |
Position of the extreme value |
any(x), all(x) |
Is any / are all TRUE |
head(x, n), tail(x, n) |
First / last n |
rev(sort(x))[1:3] |
Top 3 |
x %in% y |
Membership test |
setdiff(x, y), intersect(x, y), union(x, y) |
Set operations |
paste(x, y), paste0(x, y) |
Element-wise string joining |
nchar(x) |
Characters per element |
is.na(x), anyNA(x) |
Missingness |
sort() versus order()
x <- c(30, 10, 20)
sort(x) #> [1] 10 20 30 — the sorted VALUES
order(x) #> [1] 2 3 1 — the INDICES that would sort it
x[order(x)] #> [1] 10 20 30 — equivalent to sort(x)order() is what you need to sort one vector by another — for example, sorting subject IDs by age:
ids <- c("001", "002", "003")
ages <- c(52, 38, 61)
ids[order(ages)]
#> [1] "002" "001" "003"Lists
A list is like a vector, with one crucial difference: elements can be of different types, and different lengths. A list element can itself be a vector, another list, a data frame, a function or a fitted model.
If a vector is a row of identical boxes, a list is a filing cabinet where every drawer can hold something different.
person <- list(
id = "001",
age = 45,
female = TRUE,
visits = c("2026-01-05", "2026-02-03", "2026-03-04"),
labs = data.frame(test = c("ALT", "AST"), value = c(23, 31))
)
length(person)
#> [1] 5
str(person)
#> List of 5
#> $ id : chr "001"
#> $ age : num 45
#> $ female: logi TRUE
#> $ visits: chr [1:3] "2026-01-05" "2026-02-03" "2026-03-04"
#> $ labs :'data.frame': 2 obs. of 2 variables:
#> ..$ test : chr [1:2] "ALT" "AST"
#> ..$ value: num [1:2] 23 31str() is the single most useful function for looking at a list. print() on a nested list floods the console; str() gives you the shape.
Note length(person) is 5 — the number of drawers, not the total number of values inside them.
Creating lists
list(1, "a", TRUE) # unnamed
list(x = 1, y = "a", z = TRUE) # named
list() # empty
vector("list", 3) # empty, pre-allocated
#> [[1]]
#> NULL
#> [[2]]
#> NULL
#> [[3]]
#> NULLlist() nests, c() flattens
list(list(1, 2), list(3, 4))
#> a list of 2 lists
c(list(1, 2), list(3, 4))
#> a list of 4 elementsc() on lists concatenates them into one flat list. list() wraps them. This is the same distinction as c(c(1,2), c(3,4)) giving a length-4 vector — but with lists the difference is much easier to get wrong, and str() is how you check which one you got.
Converting between vectors and lists:
as.list(c(1, 2, 3))
#> [[1]]
#> [1] 1
#> [[2]]
#> [1] 2
#> [[3]]
#> [1] 3
unlist(list(a = 1, b = 2, c = 3))
#> a b c
#> 1 2 3unlist() flattens a list into a vector — which means it also coerces everything to a common type, exactly as c() does:
unlist(list(1, "a", TRUE))
#> [1] "1" "a" "TRUE" — all character nowUse unlist() when you know the elements share a type. When they do not, you wanted a data frame.
[ versus [[
This distinction confuses everyone once and then never again.
person["age"] # single bracket: a LIST of length 1
#> $age
#> [1] 45
class(person["age"])
#> [1] "list"
person[["age"]] # double bracket: the ELEMENT itself
#> [1] 45
class(person[["age"]])
#> [1] "numeric"
person$age # $ is shorthand for [[ ]] with a name
#> [1] 45The standard metaphor: if the list is a train, [ returns carriages and [[ returns the contents of a carriage. person["age"] is a one-carriage train; person[["age"]] is the number 45.
The practical consequence:
person["age"] * 2
#> Error in person["age"] * 2 : non-numeric argument to binary operator
person[["age"]] * 2
#> [1] 90When to use which:
| You want | Use |
|---|---|
| One element’s value | [[ ]] or $ |
| A sub-list of several elements | [ ] |
| To loop over elements | [[ ]] |
| To subset and keep it a list | [ ] |
person[c("id", "age")] # a list of 2
#> $id
#> [1] "001"
#> $age
#> [1] 45
person[1:2] # a list of 2, by position
person[-5] # all but the fifth$ does partial matching — [[ does not
person$ag
#> [1] 45 — matched "age"!
person[["ag"]]
#> NULL — no partial matching
person[["ag", exact = FALSE]]
#> [1] 45 — opt in explicitlyPartial matching is convenient at the console and dangerous in a script. If a column called agegroup is added later, person$ag becomes ambiguous and returns NULL — silently changing behaviour without any code change.
Use $ interactively, [[ ]] in code you keep.
Nested lists
Lists inside lists are how R represents anything tree-shaped — JSON, API responses, model objects, configuration.
study <- list(
id = "ABC-101",
sites = list(
list(id = "001", country = "USA", n_subjects = 42),
list(id = "002", country = "DEU", n_subjects = 38)
),
settings = list(
alpha = 0.05,
populations = c("SAF", "ITT", "PP")
)
)
study$sites[[1]]$country
#> [1] "USA"
study[["settings"]][["alpha"]]
#> [1] 0.05
# How many sites?
length(study$sites)
#> [1] 2
# All site IDs
sapply(study$sites, function(s) s$id)
#> [1] "001" "002"
# Total subjects
sum(sapply(study$sites, function(s) s$n_subjects))
#> [1] 80purrr makes deep extraction much more readable:
library(purrr)
map_chr(study$sites, "id") #> [1] "001" "002"
map_dbl(study$sites, "n_subjects") #> [1] 42 38
pluck(study, "sites", 1, "country") #> [1] "USA"
pluck(study, "sites", 9, "country") #> NULL — safe, no errorpluck() returning NULL rather than erroring on a missing path is the main reason to prefer it for anything parsing an API response.
Modifying lists
person$email <- "a@example.com" # add
person[["age"]] <- 46 # change
person$visits <- c(person$visits, "2026-04-01") # extend an element
person$email <- NULL # DELETE — assigning NULL removes it
length(person)
#> [1] 5NULL deletes; to store a NULL, wrap it
lst <- list(a = 1, b = 2)
lst$b <- NULL # b is now GONE
names(lst)
#> [1] "a"
lst["b"] <- list(NULL) # b exists and holds NULL
str(lst)
#> List of 2
#> $ a: num 1
#> $ b: NULLThis is a genuine trap when clearing a field programmatically — you intend to blank it and you actually remove it, changing the list’s length and breaking any code that indexes by position.
Lists as return values
A function that needs to return several things returns a list.
summarise_lab <- function(x) {
list(
n = sum(!is.na(x)),
nmiss = sum(is.na(x)),
mean = mean(x, na.rm = TRUE),
sd = sd(x, na.rm = TRUE),
range = range(x, na.rm = TRUE)
)
}
res <- summarise_lab(c(23, 31, 28, NA, 35))
res$mean #> [1] 29.25
res$range #> [1] 23 35
res$nmiss #> [1] 1Every model object in R is a list underneath:
fit <- lm(mpg ~ wt, data = mtcars)
class(fit)
#> [1] "lm"
typeof(fit)
#> [1] "list" — it is a list with a class attribute
names(fit)
#> [1] "coefficients" "residuals" "effects" "rank"
#> [5] "fitted.values" "assign" "qr" "df.residual"
#> [9] "xlevels" "call" "terms" "model"
fit$coefficients
#> (Intercept) wt
#> 37.285126 -5.344472
fit[["df.residual"]]
#> [1] 30Knowing this means you can extract anything from any model object, including ones no tidying package supports. str(fit, max.level = 1) shows you what is in there.
Iterating over lists
labs <- list(ALT = c(23, 31, 28), AST = c(19, 25, 22), BILI = c(8, 12, 9))
# Base R
sapply(labs, mean)
#> ALT AST BILI
#> 27.333333 22.000000 9.666667
lapply(labs, mean) # returns a LIST rather than a vector
# purrr — type-stable, which sapply is not
library(purrr)
map_dbl(labs, mean) #> ALT AST BILI (a named numeric vector)
map(labs, range) # returns a list
map_int(labs, length) #> ALT AST BILI: 3 3 3
# Two lists in parallel
map2_dbl(labs, list(40, 35, 20), \(x, limit) mean(x > limit))
#> ALT AST BILI
#> 0 0 0
# Iterate over names and values together
imap_chr(labs, \(x, nm) sprintf("%s: mean %.1f", nm, mean(x)))
#> ALT AST BILI
#> "ALT: mean 27.3" "AST: mean 22.0" "BILI: mean 9.7"sapply() is not type-stable
sapply(list(1:3, 4:6), range) # a 2x2 matrix
sapply(list(1:3), range) # a 2x1 matrix
sapply(list(), range) # an empty LISTsapply() simplifies its result if it can, so the return type depends on the data. That is fine at the console and a bug generator inside a function.
Use vapply() in base R, which requires you to declare the return type:
vapply(labs, mean, numeric(1))or map_dbl() / map_chr() from purrr, which do the same thing more readably. See Functions and tidy evaluation.
Lists versus vectors
| Vector | List | |
|---|---|---|
| Element types | All the same | Any mix |
| Element lengths | Each is one value | Each can be any length |
length() |
Number of values | Number of elements |
| Extract one element | x[i] |
x[[i]] |
| Arithmetic | Works directly | Must extract first |
| Memory | Compact | Larger overhead |
| Use for | Measurements, IDs, flags | Records, config, model output, JSON |
c(1, 2, "a") # a character VECTOR — coerced
list(1, 2, "a") # a LIST of 3 — types preservedThat is the decision in one line: if coercion would lose information, you need a list.
Matrices
A matrix is a vector with a dim attribute — the same one-type constraint as a vector, arranged into rows and columns.
m <- matrix(1:6, nrow = 2)
m
#> [,1] [,2] [,3]
#> [1,] 1 3 5
#> [2,] 2 4 6
typeof(m) #> [1] "integer" — still an integer vector underneath
class(m) #> [1] "matrix" "array"
attributes(m)
#> $dim
#> [1] 2 3You can see the underlying vector by removing the dimension:
c(m) #> [1] 1 2 3 4 5 6
dim(m) <- NULL
m #> [1] 1 2 3 4 5 6That is the whole implementation. A matrix is not a separate data structure — it is a vector that knows its shape.
Creating matrices
matrix(1:6, nrow = 2) # fills by COLUMN (the default)
#> [,1] [,2] [,3]
#> [1,] 1 3 5
#> [2,] 2 4 6
matrix(1:6, nrow = 2, byrow = TRUE) # fills by row
#> [,1] [,2] [,3]
#> [1,] 1 2 3
#> [2,] 4 5 6
matrix(0, nrow = 3, ncol = 3) # filled with a single value
diag(3) # 3x3 identity
diag(c(1, 2, 3)) # diagonal from a vectorR fills matrices down the columns by default, not across the rows. Coming from a language that fills row-wise, this silently transposes your data.
matrix(c(1, 2, 3, 4, 5, 6), nrow = 2)
#> [,1] [,2] [,3]
#> [1,] 1 3 5 <- 1, 3, 5 — not 1, 2, 3
#> [2,] 2 4 6If you are writing the values out literally in the shape you want to see, add byrow = TRUE.
Building from vectors:
rbind(c(1, 2, 3), c(4, 5, 6)) # bind as rows
#> [,1] [,2] [,3]
#> [1,] 1 2 3
#> [2,] 4 5 6
cbind(c(1, 2), c(3, 4), c(5, 6)) # bind as columns
#> [,1] [,2] [,3]
#> [1,] 1 3 5
#> [2,] 2 4 6rbind() and cbind() are how most matrices actually get built in practice.
Dimensions and names
m <- matrix(1:6, nrow = 2)
dim(m) #> [1] 2 3
nrow(m) #> [1] 2
ncol(m) #> [1] 3
length(m) #> [1] 6 — total ELEMENTS, not rowsRows and columns can be named, which makes matrices far more readable:
shift <- matrix(
c(45, 12, 3,
8, 30, 9,
1, 5, 22),
nrow = 3, byrow = TRUE,
dimnames = list(
Baseline = c("Low", "Normal", "High"),
Postdose = c("Low", "Normal", "High")
)
)
shift
#> Postdose
#> Baseline Low Normal High
#> Low 45 12 3
#> Normal 8 30 9
#> High 1 5 22
rownames(shift) #> [1] "Low" "Normal" "High"
colnames(shift) #> [1] "Low" "Normal" "High"
dimnames(shift) # both, as a listThat is a shift table — a standard clinical output — and it is a natural matrix because every cell is the same type of thing (a count).
Subsetting
The syntax is m[rows, columns]. Leaving one blank means “all of them”.
m <- matrix(1:12, nrow = 3)
m
#> [,1] [,2] [,3] [,4]
#> [1,] 1 4 7 10
#> [2,] 2 5 8 11
#> [3,] 3 6 9 12
m[2, 3] #> [1] 8 one element
m[1, ] #> [1] 1 4 7 10 row 1 — note it becomes a VECTOR
m[, 2] #> [1] 4 5 6 column 2
m[1:2, 3:4] # a sub-matrix
#> [,1] [,2]
#> [1,] 7 10
#> [2,] 8 11
m[-1, ] # drop row 1
m[m > 6] #> [1] 7 8 9 10 11 12 — logical subsetting flattensBy name, when dimnames exist:
shift["Normal", "High"] #> [1] 9
shift["Low", ] #> Low Normal High
#> 45 12 3drop = FALSE — the classic matrix bug
Selecting a single row or column returns a vector, not a matrix:
m[1, ]
#> [1] 1 4 7 10
dim(m[1, ])
#> NULL — no longer a matrix!Inside a function that expects a matrix, this breaks the next operation — and only when the selection happens to return one row, which may be rare in testing and common in production.
m[1, , drop = FALSE]
#> [,1] [,2] [,3] [,4]
#> [1,] 1 4 7 10
dim(m[1, , drop = FALSE])
#> [1] 1 4 — still a matrixUse drop = FALSE in any function that subsets a matrix and passes the result on. The same argument applies to base data frames.
Arithmetic
Element-wise operations work exactly as they do on vectors:
m <- matrix(1:4, nrow = 2)
m + 10
m * 2
m^2
m + m # element-wise addition
m * m # element-wise MULTIPLICATION, not matrix multiplication
#> [,1] [,2]
#> [1,] 1 9
#> [2,] 4 16Matrix multiplication uses %*%:
m %*% m
#> [,1] [,2]
#> [1,] 7 15
#> [2,] 10 22* and %*% are different operations
* multiplies element by element. %*% performs matrix multiplication, with the conformability rules that implies. Confusing them produces either an error or — worse — a plausible wrong answer when the matrices happen to be square.
Python users: NumPy has exactly the same distinction, * versus @.
Row and column operations
m <- matrix(1:6, nrow = 2)
rowSums(m) #> [1] 9 12
colSums(m) #> [1] 3 7 11
rowMeans(m) #> [1] 3 4
colMeans(m) #> [1] 1.5 3.5 5.5
t(m) # transpose
#> [,1] [,2]
#> [1,] 1 2
#> [2,] 3 4
#> [3,] 5 6apply() runs an arbitrary function across a margin:
apply(m, 1, max) # 1 = rows
#> [1] 5 6
apply(m, 2, max) # 2 = columns
#> [1] 2 4 6
apply(m, 2, function(col) col / sum(col)) # column proportionsThe margin argument is the one to remember: 1 is rows, 2 is columns.
Applied to the shift table, this gives row percentages:
round(100 * prop.table(shift, margin = 1), 1)
#> Postdose
#> Baseline Low Normal High
#> Low 75.0 20.0 5.0
#> Normal 17.0 63.8 19.1
#> High 3.6 17.9 78.6Linear algebra
Matrices are the right structure whenever you are doing actual mathematics:
A <- matrix(c(2, 1, 1, 3), nrow = 2)
b <- c(5, 6)
solve(A) # inverse
solve(A, b) # solve Ax = b — preferred over solve(A) %*% b
#> [1] 1.8 1.4
det(A) #> [1] 5
diag(A) #> [1] 2 3
crossprod(A) # t(A) %*% A, computed efficiently
eigen(A) # eigenvalues and eigenvectors
chol(A) # Cholesky decompositionA worked example — fitting a linear model by hand, which is what lm() does underneath:
X <- model.matrix(~ wt + hp, data = mtcars) # design matrix
y <- mtcars$mpg
beta <- solve(t(X) %*% X) %*% t(X) %*% y
beta
#> [,1]
#> (Intercept) 37.22727012
#> wt -3.87783074
#> hp -0.03177295
coef(lm(mpg ~ wt + hp, data = mtcars)) # the same numbers
#> (Intercept) wt hp
#> 37.22727012 -3.87783074 -0.03177295solve(A, b) rather than solve(A) %*% b
Computing an explicit inverse and then multiplying is both slower and numerically less stable than solving the system directly. On an ill-conditioned matrix the difference is not academic — it shows up in the answer.
Arrays
An array is a matrix with more than two dimensions. Same idea, more indices.
arr <- array(1:24, dim = c(2, 3, 4))
dim(arr) #> [1] 2 3 4
arr[1, 2, 3] #> [1] 15
arr[, , 1] # the first 2x3 sliceThree-dimensional arrays appear in clinical work for things like subject × parameter × visit structures, though a long data frame is usually easier to work with.
When to use a matrix
| Use a matrix | Use a data frame |
|---|---|
| Every value is the same type | Mixed types |
| Doing linear algebra | Storing records |
| Correlation or covariance | Anything with an ID column |
| A contingency or shift table | Anything going to dplyr |
| Feeding a modelling routine | Anything going to ggplot2 |
| Image or signal data | Almost all clinical data |
cor(mtcars[, c("mpg", "wt", "hp")]) # returns a matrix — correct
table(adsl$TRT01A, adsl$SEX) # returns a matrix — correctThe rule of thumb: if you would ever want a column of subject IDs alongside the numbers, you want a data frame.
Converting between them:
as.data.frame(m)
as.matrix(df) # coerces EVERYTHING to one type — usually character
data.matrix(df) # coerces to numeric, factors become their codesas.matrix() on a mixed data frame coerces to character
df <- data.frame(id = c("001", "002"), age = c(45, 52))
as.matrix(df)
#> id age
#> [1,] "001" "45" — the ages are now STRINGSThe one-type constraint applies the moment it becomes a matrix. Select the numeric columns first:
as.matrix(df[, sapply(df, is.numeric)])Data frames and tibbles
A data frame is a list of equal-length vectors, presented as a table. That is the entire definition, and it explains everything else about how data frames behave.
- Each column is a vector, so it holds one type
- Different columns can hold different types
- Every column must have the same length
That combination — a list of vectors, all the same length — is what makes it a rectangle.
df <- data.frame(
usubjid = c("001", "002", "003", "004"),
age = c(45, 52, 38, 61),
sex = c("F", "M", "F", "M"),
saffl = c(TRUE, TRUE, FALSE, TRUE)
)
df
#> usubjid age sex saffl
#> 1 001 45 F TRUE
#> 2 002 52 M TRUE
#> 3 003 38 F FALSE
#> 4 004 61 M TRUE
typeof(df) #> [1] "list" — it IS a list
class(df) #> [1] "data.frame" — with a class attribute
length(df) #> [1] 4 — the number of COLUMNS
nrow(df) #> [1] 4
ncol(df) #> [1] 4
dim(df) #> [1] 4 4length(df) returning the column count rather than the row count is the clearest evidence that a data frame is a list. Each element of the list is a column.
Inspecting a data frame
The first four commands you run on any new dataset:
str(df)
#> 'data.frame': 4 obs. of 4 variables:
#> $ usubjid: chr "001" "002" "003" "004"
#> $ age : num 45 52 38 61
#> $ sex : chr "F" "M" "F" "M"
#> $ saffl : logi TRUE TRUE FALSE TRUE
head(df, 3) # first 3 rows
tail(df, 2) # last 2
summary(df) # per-column summaries
names(df) #> [1] "usubjid" "age" "sex" "saffl"Also useful:
dplyr::glimpse(df) # like str() but wider and easier to read
View(df) # opens the RStudio data viewer
colnames(df); rownames(df)
sapply(df, class) # the class of every column
colSums(is.na(df)) # missing count per columncolSums(is.na(df)) is worth committing to memory — it is the fastest way to see where the gaps are in a new dataset.
Creating data frames
# From vectors
data.frame(id = 1:3, score = c(9.1, 8.7, 9.4))
# Row by row — the layout matches what you see
tibble::tribble(
~usubjid, ~visit, ~aval,
"001", "Week 4", 23.4,
"001", "Week 8", 25.1,
"002", "Week 4", 31.2
)
# From a list of equal-length vectors
as.data.frame(list(a = 1:3, b = c("x", "y", "z")))
# From a matrix
as.data.frame(matrix(1:6, nrow = 2))
# Empty, with types declared
data.frame(id = character(0), age = numeric(0))tribble() is the best way to write small test data: the code laid out on screen looks like the table it produces, so a reviewer can check the values by eye. It is used throughout the testing lesson for exactly that reason.
stringsAsFactors is finally gone
Before R 4.0, data.frame() silently converted every character column to a factor. This caused years of confusing bugs — most memorably as.numeric(factor) returning level codes rather than values.
Since R 4.0 the default is stringsAsFactors = FALSE and characters stay characters. If you are reading old code or old blog posts, that is why they are full of defensive stringsAsFactors = FALSE arguments.
Tibbles
A tibble is a data frame with three behaviours changed. It is still a data frame — class() shows data.frame in its inheritance — so anything that accepts a data frame accepts a tibble.
library(tibble)
tb <- tibble(
usubjid = c("001", "002", "003"),
age = c(45, 52, 38),
sex = c("F", "M", "F")
)
class(tb)
#> [1] "tbl_df" "tbl" "data.frame"1. Printing. A tibble prints ten rows with column types; a data frame prints everything.
tb
#> # A tibble: 3 × 3
#> usubjid age sex
#> <chr> <dbl> <chr>
#> 1 001 45 F
#> 2 002 52 M
#> 3 003 38 FThose type abbreviations — <chr>, <dbl>, <int>, <lgl>, <date>, <fct> — are genuinely useful. Printing a 200,000-row data.frame by accident is an experience tibbles were partly invented to prevent.
2. [ always returns a tibble. This is the important one.
df[, "age"] #> [1] 45 52 38 — a VECTOR
df[, c("age", "sex")] #> a data frame
tb[, "age"]
#> # A tibble: 3 × 1 — always a tibble
tb[["age"]] #> [1] 45 52 38 — use [[ ]] for the vector
dplyr::pull(tb, age) #> [1] 45 52 38 — or pull()With a base data frame the return type depends on how many columns you selected. In a function that takes the column names as an argument, that is a landmine: it works in testing with two columns and breaks in production with one.
3. No partial matching.
df$ag #> [1] 45 52 38 — silently matched "age"
tb$ag #> NULL with a warning| Behaviour | data.frame |
tibble |
|---|---|---|
| Printing 1M rows | Floods the console | 10 rows plus types |
df[, "one_col"] |
Vector | Tibble |
df$partial_nam |
Partial match | Warning, NULL |
| Strings | Character (R ≥ 4.0) | Character |
| Invalid column names | Mangled | Preserved, backtick-quoted |
| Recycling on creation | Permissive | Length 1 or n only |
| Row names | Supported | Dropped |
Converting freely:
as_tibble(df)
as.data.frame(tb)Subsetting data frames
Because a data frame is a list of vectors, both list-style and matrix-style subsetting work.
Columns:
df$age #> [1] 45 52 38 61 — vector
df[["age"]] #> [1] 45 52 38 61 — vector
df["age"] # a one-column DATA FRAME
df[, "age"] # vector (data.frame) / tibble (tibble)
df[c("usubjid", "age")] # two columns, still a data frameRows:
df[1, ] # first row
df[1:2, ] # first two
df[-1, ] # all but the first
df[df$age > 40, ] # by condition
df[order(df$age), ] # sorted by ageBoth:
df[df$sex == "F", c("usubjid", "age")]
#> usubjid age
#> 1 001 45
#> 3 003 38
df[2, "age"] #> [1] 52[ with NA in the condition creates phantom rows
df <- data.frame(id = 1:3, age = c(45, NA, 38))
df[df$age > 40, ]
#> id age
#> 1 1 45
#> NA NA NA <- a row that does not exist in the datadf$age > 40 evaluates to TRUE, NA, FALSE. Base [ treats the NA index as “a row I know nothing about” and materialises a row of NAs.
Three fixes:
df[which(df$age > 40), ] # which() drops NA
subset(df, age > 40) # subset() handles it
dplyr::filter(df, age > 40) # filter() keeps only definite TRUE
#> id age
#> 1 1 45This alone is a strong argument for using filter() in anything analytical. The phantom row propagates silently into counts, means and joins.
The dplyr equivalents, which are clearer and NA-safe:
library(dplyr)
df |> filter(age > 40) # rows
df |> select(usubjid, age) # columns
df |> select(-saffl) # drop a column
df |> select(starts_with("us")) # helpers
df |> slice(1:3) # rows by position
df |> arrange(age) # sort
df |> arrange(desc(age))
df |> pull(age) # one column as a vector
df |> distinct(sex) # unique valuesAdding and modifying columns
# Base R
df$age_months <- df$age * 12
df$agegr <- ifelse(df$age >= 65, ">=65", "<65")
df[["bmi"]] <- df$weight / (df$height / 100)^2
df$saffl <- NULL # delete a column# dplyr — can reference columns created in the same call
df <- df |>
mutate(
height_m = height / 100,
bmi = weight / height_m^2, # uses height_m from the line above
bmi_cat = case_when(
bmi < 18.5 ~ "Underweight",
bmi < 25 ~ "Normal",
bmi < 30 ~ "Overweight",
.default = "Obese"
)
)
df |> mutate(bmi = weight / (height/100)^2, .after = height) # control position
df |> mutate(bmi = weight / (height/100)^2, .keep = "used") # keep only inputs
df |> rename(subject = usubjid)
df |> relocate(age, .before = usubjid)Modifying specific cells:
df[2, "age"] <- 53 # one cell
df$age[df$usubjid == "003"] <- 39 # by condition
df[df$age < 0, "age"] <- NA # clean an impossible valueCombining data frames
# Stack rows — columns matched by NAME
rbind(df1, df2) # base: requires identical columns
dplyr::bind_rows(df1, df2) # fills missing columns with NA
dplyr::bind_rows(list_of_dfs, .id = "source")
# Side by side — requires the same number of rows, matched by POSITION
cbind(df1, df2)
dplyr::bind_cols(df1, df2)cbind() matches by position, and does not check anything
cbind(subjects, lab_results)This lines up row 1 with row 1, row 2 with row 2, and so on. If the two frames are in different orders — which they usually are — every value is attached to the wrong subject, and nothing warns you.
Use a join on a key instead, always:
left_join(subjects, lab_results, by = "usubjid")See Data manipulation with dplyr for the full treatment, including relationship = to catch unexpected row multiplication.
Iterating over a data frame
Because a data frame is a list of columns, list functions iterate over columns, not rows:
sapply(df, class)
#> usubjid age sex saffl
#> "character" "numeric" "character" "logical"
colSums(is.na(df)) # missing per column
sapply(df, function(x) length(unique(x)))Row-wise work is almost always a sign that a vectorised or grouped operation would be better:
# Rarely what you want
for (i in seq_len(nrow(df))) { ... }
apply(df, 1, function(row) ...) # coerces every row to one type!
# Usually what you want
df |> mutate(new = a + b)
df |> summarise(mean(x), .by = group)
df |> rowwise() |> mutate(m = mean(c(a, b, c))) # genuine row-wiseapply(df, 1, ...) deserves a specific warning: because a matrix can hold only one type, it converts every row to a character vector first. Numbers become strings and arithmetic silently fails.
A realistic example
Bringing the whole lesson together:
library(dplyr)
adsl <- tibble::tribble(
~usubjid, ~arm, ~age, ~sex, ~weight, ~height, ~saffl,
"001", "Placebo", 45, "F", 62.5, 165, "Y",
"002", "Drug A", 52, "M", 88.1, 180, "Y",
"003", "Placebo", 38, "F", 55.0, 158, "N",
"004", "Drug A", 61, "M", 91.3, 175, "Y",
"005", "Drug A", 29, "F", 68.9, 170, "Y"
)
result <- adsl |>
filter(saffl == "Y") |> # a vector condition
mutate(
bmi = weight / (height / 100)^2, # vectorised arithmetic
agegr = if_else(age >= 50, ">=50", "<50") # vectorised branching
) |>
summarise(
n = n(),
mean_age = mean(age),
mean_bmi = mean(bmi),
n_female = sum(sex == "F"), # logical → integer
.by = arm
) |>
arrange(arm)
result
#> # A tibble: 2 × 5
#> arm n mean_age mean_bmi n_female
#> <chr> <int> <dbl> <dbl> <int>
#> 1 Drug A 3 47.3 26.6 1
#> 2 Placebo 1 45 22.9 1Every structure from this lesson is present: the tibble is a list of vectors, each column is a one-type vector, sum(sex == "F") relies on logical-to-integer coercion, and the arithmetic is vectorised throughout. No loops, no indices.
Choosing a structure
| Data | Structure | Why |
|---|---|---|
| One type, one dimension | Atomic vector | Compact, fully vectorised |
| One type, two dimensions, doing maths | Matrix | %*%, solve(), apply() |
| Counts cross-classified by two factors | Matrix | table() returns one |
| Mixed types, rectangular | Tibble | The default for data |
| Mixed types, ragged or nested | List | No length or type constraint |
| Model results, API responses, JSON | List, then tidyr::unnest() |
Tree-shaped |
| Several values returned from a function | List | Named, any types |
| Very large, need speed | data.table or arrow |
Out-of-memory, fast joins |
The three questions that decide it:
- Is everything the same type? No → list or data frame.
- Is it rectangular? Yes → data frame (or matrix if also one type).
- Am I doing linear algebra? Yes → matrix.
Almost all clinical data is a tibble. Reach for a matrix when the maths demands it, and a list when the shape is genuinely irregular.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
1:length(x) |
Loops backwards when empty | seq_along(x) |
x == NA |
Always NA, never TRUE |
is.na(x) |
| Out-of-range index | Silent NA, no error |
Check length() first |
Index 0 |
Empty result, no error | Guard computed indices |
| Growing a vector in a loop | Quadratic time | Pre-allocate or vectorise |
One "N/A" in a numeric column |
Whole column becomes character | Convert explicitly; report failures |
| Unnoticed recycling | Wrong values, no warning | Assert lengths; use mutate() |
&& on a vector |
Error since R 4.3 | & for element-wise |
[ vs [[ on lists |
“non-numeric argument” errors | [[ for the element |
$ partial matching |
Wrong column silently | [[ in scripts |
Assigning NULL to a list element |
Deletes it rather than blanking it | x["a"] <- list(NULL) |
sapply() in a function |
Return type varies with the data | vapply() or map_*() |
| Single row/column from a matrix | Drops to a vector | drop = FALSE |
* instead of %*% |
Element-wise, not matrix product | %*% for linear algebra |
as.matrix() on mixed columns |
Everything becomes character | Select numeric columns first |
df[, "x"] in a function |
Type depends on selection | Use tibbles, or drop = FALSE |
Base [ with NA in condition |
Phantom NA rows |
dplyr::filter() |
cbind() to combine datasets |
Matches by position, misaligns rows | left_join() on a key |
apply(df, 1, ...) |
Coerces every row to character | rowwise() or vectorise |
ifelse() on dates |
Loses the Date class | dplyr::if_else() |
Exercise 3.1 — Subsetting drills
Given x <- c(first = 5, second = 10, third = 15, fourth = 20), write expressions for: (a) the second and fourth elements, (b) everything except the first, (c) elements greater than 8, (d) the elements named "first" and "third", (e) the elements in reverse order.
Show solution
x <- c(first = 5, second = 10, third = 15, fourth = 20)
x[c(2, 4)] # a
x[-1] # b
x[x > 8] # c
x[c("first", "third")] # d
rev(x) # e — or x[length(x):1], but rev() is saferExercise 3.2 — Find the recycling bug
This code is meant to flag subjects whose systolic BP exceeds a threshold that differs by treatment arm. It produces wrong answers. Find and fix the bug.
vitals <- data.frame(
usubjid = sprintf("%03d", 1:6),
arm = c("A", "A", "A", "B", "B", "B"),
sbp = c(120, 145, 138, 152, 118, 149)
)
thresholds <- c(140, 150) # A, B
vitals$high <- vitals$sbp > thresholdsShow solution
thresholds has length 2 and sbp has length 6, so it recycles as 140, 150, 140, 150, 140, 150 — which does not line up with the arm at all, and R issues no warning because 6 is a multiple of 2.
vitals$high
#> [1] FALSE FALSE FALSE TRUE FALSE FALSE wrongThe fix is to join the threshold to the row, not rely on position:
library(dplyr)
thresholds <- tibble::tibble(
arm = c("A", "B"),
threshold = c(140, 150)
)
vitals <- vitals |>
left_join(thresholds, by = "arm") |>
mutate(high = sbp > threshold)
vitals
#> usubjid arm sbp threshold high
#> 1 001 A 120 140 FALSE
#> 2 002 A 145 140 TRUE
#> 3 003 A 138 140 FALSE
#> 4 004 B 152 150 TRUE
#> 5 005 B 118 150 FALSE
#> 6 006 B 149 150 FALSEExercise 3.3 — Model results into a tibble
Fit lm(mpg ~ wt + hp, data = mtcars). Without using broom, extract the coefficient names, estimates and standard errors from the list structure and assemble them into a tibble with columns term, estimate, std_error.
Show solution
fit <- lm(mpg ~ wt + hp, data = mtcars)
# summary() returns a list whose $coefficients is a matrix
cm <- summary(fit)$coefficients
class(cm)
#> [1] "matrix" "array"
colnames(cm)
#> [1] "Estimate" "Std. Error" "t value" "Pr(>|t|)"
result <- tibble::tibble(
term = rownames(cm),
estimate = cm[, "Estimate"],
std_error = cm[, "Std. Error"]
)
result
#> # A tibble: 3 x 3
#> term estimate std_error
#> <chr> <dbl> <dbl>
#> 1 (Intercept) 37.2 1.60
#> 2 wt -3.88 0.633
#> 3 hp -0.0318 0.00903rownames() carries the term names. broom::tidy(fit) does exactly this and handles many model classes — use it in real work, but knowing where the numbers live means you can extract from any model, including ones broom does not support.
Recap
- A vector holds many values of one type; there are no scalars in R
- Mixing types coerces silently: logical → integer → double → character
- Named vectors make excellent lookup tables
- Subset by position, negation, logical or name; out-of-range gives
NA, not an error - Recycling is silent when lengths are multiples — the dangerous case
- A list holds anything, any length;
[returns a sub-list,[[returns the element - Assigning
NULLto a list element deletes it - A matrix is a vector with
dim; usedrop = FALSEand%*%deliberately - A data frame is a list of equal-length vectors — hence
length(df)is the column count - Tibbles fix base
[inconsistency, partial matching and printing - Base
[with anNAcondition creates phantom rows;filter()does not seq_along()over1:length(), always