Vectors, lists, matrices and data frames

Lesson 3 — R Programming

Lesson 3 of 12 Beginner ~150 min

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 = FALSE and %*% vs *
  • Explain why a data frame is a list of vectors, and what follows from that
  • Distinguish data.frame from tibble and 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:

  1. 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.
  2. 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 30

The second line starts at element 23, hence [23].

ImportantThere are no scalars in R

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 it

This 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 4

Sequences.

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 3

Repetition.

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] TRUE

Note 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 double

For 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 → character
WarningThis is why one bad value ruins a whole column

Reading 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 logical

The 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 proportion

TRUE 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] FALSE

Named 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] 40

Names 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 names

Using 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"            NA

Inspecting 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.6

which() 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 20

2. 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 40

3. 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 30

This 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 71

Note 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
                  #> 20
WarningThree subsetting surprises

You cannot mix positive and negative indices:

x[c(1, -2)]
#> Error in x[c(1, -2)] : can't mix positive and negative subscripts

Index 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 NA

This 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 400

Replacing 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.7

Adding 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 100

Removing 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
ImportantGrowing a vector in a loop is slow
# 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) * 2

On 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] NA

Most 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.973454

Detecting 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] TRUE
WarningNever test for NA with ==
x == NA
#> [1] NA NA NA NA

Comparison 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 8

That 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 24

The 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 length
ImportantThe silent case is the dangerous one

Lengths 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 2

Element-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.8

Comparison 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
Warning& 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"
)
Noteifelse() 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"    <- correct

dplyr::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
Tipsort() 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 31

str() 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]]
#> NULL
Warninglist() 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 elements

c() 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 3

unlist() 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 now

Use 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] 45

The 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] 90

When 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
Warning$ 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 explicitly

Partial 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] 80

purrr 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 error

pluck() 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] 5
ImportantAssigning NULL 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: NULL

This 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] 1

Every 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] 30

Knowing 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"
Tipsapply() 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 LIST

sapply() 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 preserved

That 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 3

You 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 6

That 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 vector
WarningColumn-major filling surprises people

R 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    6

If 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    6

rbind() 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 rows

Rows 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 list

That 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 flattens

By name, when dimnames exist:

shift["Normal", "High"]       #> [1] 9
shift["Low", ]                #> Low Normal High
                              #>  45     12    3
Importantdrop = 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 matrix

Use 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   16

Matrix multiplication uses %*%:

m %*% m
#>      [,1] [,2]
#> [1,]    7   15
#> [2,]   10   22
Warning* 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    6

apply() 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 proportions

The 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.6

Linear 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 decomposition

A 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.03177295
Tipsolve(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 slice

Three-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 — correct

The 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 codes
Warningas.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 STRINGS

The 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 4

length(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 column

colSums(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.

NotestringsAsFactors 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 F

Those 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 frame

Rows:

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 age

Both:

df[df$sex == "F", c("usubjid", "age")]
#>   usubjid age
#> 1     001  45
#> 3     003  38

df[2, "age"]             #> [1] 52
ImportantBase [ 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 data

df$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  45

This 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 values

Adding 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 value

Combining 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)
Warningcbind() 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-wise

apply(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        1

Every 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:

  1. Is everything the same type? No → list or data frame.
  2. Is it rectangular? Yes → data frame (or matrix if also one type).
  3. 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 safer

Exercise 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 > thresholds
Show 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     wrong

The 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 FALSE
Generalising: whenever a lookup value varies by group, use a join. Positional recycling is only safe for length-1 values.

Exercise 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.00903
The point of the exercise: the coefficient table is a matrix sitting inside a list, and rownames() 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 NULL to a list element deletes it
  • A matrix is a vector with dim; use drop = FALSE and %*% 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 an NA condition creates phantom rows; filter() does not
  • seq_along() over 1:length(), always

Next: Importing SAS, CSV and Excel files.

Back to top