Vectorisation and Iteration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R (the base, stats and compiler packages that ship with R), so no add-on package version applies; the lintr 3.4.0 default linters were used only to check the scripts. R has no scalar type: a single number is a vector of length one, and almost every arithmetic operator and mathematical function takes a whole vector and returns a whole vector, with the loop over the elements running in compiled code inside the function. That property is called vectorisation, and it is the main reason that idiomatic R code contains fewer loops than the same program in most other languages. This page explains what it means exactly, the base functions that replace the loops people write first (running totals, differences, group sums, buckets, lookups), what goes wrong when a function is not vectorised, and the cases where a for loop is the right tool after all, including how to write one so that it does not become quadratic.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes, so the linter does not hide the lesson. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a script measures time with system.time(), the page prints only a comparison (TRUE when the loop is more than ten times slower, for instance) and never a number of seconds, because the numbers vary by machine, by load and by R build; only the ordering is reliable, and every comparison on the page was confirmed on repeated runs. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. The if, ifelse() and loop syntax itself is in Control Flow; this page is about when to use which, and the lapply() family is covered in The apply Family and Functionals.

What "vectorised" means

The loop runs in compiled code

An Introduction to R states the rule in its section on vector arithmetic: vectors can be used in arithmetic expressions, in which case the operations are performed element by element (An Introduction to R: Vector arithmetic). x * 2 is not "multiply the number x by 2" with a hidden loop around it; it is a single call to a function written in C that walks over the elements. A vectorised function therefore has a contract that is worth stating, because the rest of the page depends on it:

  • it takes one or more vectors and returns a vector whose element i depends only on element i of each argument (after recycling), so f(x)[i] is f(x[i]);

  • the result has the length of the longest argument, or length zero if an argument has length zero;

  • it contains no hidden state between elements and no decision that needs a single TRUE or FALSE for the whole vector.

Functions fall into a few families, and only the first is "vectorised" in the strict sense above. The others are still loop-free for the caller, which is what matters in practice.

Family Behaviour Examples

Element-wise (map)

n values in, n values out, element i from element i

+, -, *, ==, sqrt, log, nchar, toupper, is.na, ifelse, pmax, pmin

Reduction

n values in, one value out

sum, prod, mean, max, min, range, any, all, length

Scan (running)

n values in, n values out, element i depends on elements up to i

cumsum, cumprod, cummax, cummin, diff, rev

Lookup

values in, positions or classes out

match, %in%, findInterval, which, order, cut

Grouped

values and a grouping key in, one row per group out

rowsum, tapply, table, tabulate

The script below shows the first two families, the vector-in vector-out property on dates (one subtraction gives one result per loan), and why a scalar is not special: 5 is a vector of length one, so every function in the table accepts it. is.numeric() is also on show because it is the test most functions apply to their arguments: it is TRUE for integer and double vectors and FALSE for factors and dates, even though both are stored as numbers, which is why arithmetic on them goes through methods rather than the plain C loop (R documentation: numeric).

source("scenario.R")

# Arithmetic, comparison and most maths functions work on whole vectors.
pages <- c(387L, 432L, 214L, 198L, NA)
pages * 2L
#> [1] 774 864 428 396  NA
sqrt(pages)
#> [1] 19.67232 20.78461 14.62874 14.07125       NA
pages > 300L
#> [1]  TRUE  TRUE FALSE FALSE    NA
nchar(c("Loud Roofs", "Paper Moons", "Ten Winters"))
#> [1] 10 11 11
toupper(c("Loud Roofs", "Paper Moons"))
#> [1] "LOUD ROOFS"  "PAPER MOONS"

# The same idea for dates: one subtraction gives one result per loan.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
length(days_out) == nrow(loans)
#> [1] TRUE
head(days_out, 8L)
#> [1] 21 10  1 26 26 17 24 10

# Summaries are the other kind of vectorised function: n values in, 1 out.
sum(pages, na.rm = TRUE)
#> [1] 1231
mean(pages, na.rm = TRUE)
#> [1] 307.75
range(pages, na.rm = TRUE)
#> [1] 198 432

# A scalar is just a vector of length one.
length(5)
#> [1] 1
is.vector(5)
#> [1] TRUE
is.numeric(c(1L, 2L))
#> [1] TRUE
is.numeric(factor("a"))
#> [1] FALSE
is.numeric(as.Date("2026-06-30"))
#> [1] FALSE

The vectorised subtraction end_date - loans$loan_date is where a newcomer would write a loop over the 60 rows. The next sections give the vectorised idiom for the most common loops; the last section of this part measures the difference.

Recycling

When the operands of an operator have different lengths, the shorter one is recycled: repeated, perhaps fractionally, until it matches the longer one. The manual calls it "the recycling rule" and gives the exact consequence for an expression such as 2*x + y + 1: a constant is simply repeated, and the result is as long as the longest vector (An Introduction to R: The recycling rule). Recycling is what makes x + 1 work at all, and it is also the source of the most common silent bug in vectorised code, because when the shorter length divides the longer one R recycles without a word.

When it does not divide, R still recycles but warns, with the message longer object length is not a multiple of shorter object length. The same message comes from arithmetic, comparison and the logical operators. A zero-length operand is a different case: the result has length zero and there is no warning, which is the safe behaviour for code that may meet an empty selection, and the reason to prefer seq_len() to 1:n below. Arrays are the exception to the "recycle silently" rule: R 3.4.0 turned the old silent recycling of a length-one array against a longer vector into a warning that says the behaviour is deprecated, and the comparison operators raise an error instead. In R-devel (the future 4.7.0) the same kind of check is extended to logical subscripts: the development NEWS says that x[logi] now warns if length(x) is not a multiple of length(logi). That is upcoming behaviour and does not exist in 4.6.1, so no example on this page uses it.

# The shorter vector is recycled until it matches the longer one.
1:6 + 10
#> [1] 11 12 13 14 15 16
1:6 + c(10, 20)
#> [1] 11 22 13 24 15 26
1:6 * c(1, -1)
#> [1]  1 -2  3 -4  5 -6

# A length that is not a multiple gives a warning, and still recycles.
r <- withCallingHandlers(
  1:6 + 1:4,
  warning = function(w) {
    cat("warning:", conditionMessage(w), "\n")
    invokeRestart("muffleWarning")
  }
)
#> warning: longer object length is not a multiple of shorter object length
r
#> [1] 2 4 6 8 6 8

# The same message comes from comparison and logic.
tryCatch(1:5 == 1:2, warning = conditionMessage)
#> [1] "longer object length is not a multiple of shorter object length"
tryCatch(c(TRUE, FALSE, TRUE) & c(TRUE, FALSE), warning = conditionMessage)
#> [1] "longer object length is not a multiple of shorter object length"

# A zero-length operand gives a zero-length result, without a warning.
1:3 + numeric(0L)
#> numeric(0)
numeric(0L) > 1
#> logical(0)

# Recycling hides a length mistake when the lengths happen to divide.
loan_ids <- c("L001", "L002", "L003", "L004")
prefix <- c("book", "film")
paste(prefix, loan_ids)
#> [1] "book L001" "film L002" "book L003" "film L004"

# A one-element array is not silently recycled against a longer vector: R 3.4.0
# turned the old silent behaviour into a warning for arithmetic (an error for
# comparison), and the warning says the behaviour is deprecated.
tryCatch(matrix(1, 1L, 1L) + 2:3, warning = conditionMessage)
#> [1] "Recycling array of length 1 in array-vector arithmetic is deprecated.\n  Use c() or as.vector() instead."
tryCatch(matrix(1, 1L, 1L) < 2:3, error = conditionMessage)
#> [1] "dims [product 1] do not match the length of object [2]"

# rep() and rep_len() make the recycling explicit.
rep_len(c("a", "b"), 5L)
#> [1] "a" "b" "a" "b" "a"
rep(c("a", "b"), times = 2L, each = 2L)
#> [1] "a" "a" "b" "b" "a" "a" "b" "b"

The practical rules are short. Recycle a length-one value (a constant, a threshold) deliberately. Never rely on recycling a longer vector unless the length relation is explicit in the code (rep_len() and rep(each = ) say it out loud). And put a stopifnot(length(a) == length(b)) at the top of a function that takes two parallel vectors, as the last script of this section does. The warning is a symptom, not an error, so scripts that run with the default options(warn = 0) print it after the top-level call finishes and are easy to overlook in a log.

Writing a function that is vectorised

A function written only from vectorised building blocks is vectorised without any extra work: days_late() below uses a subtraction, an index assignment and pmax(), so it accepts one loan or the whole log. The contract can be tested, and the test is cheap: element i of the result must equal the result for element i alone. Three things break the contract and none of them raises an error until the function meets a vector. if (cond) and && need a single logical value, and since R 4.2.0 a condition of length greater than one is an error (R 4.3.0 did the same for && and ||); max(), min() and sum() reduce the whole vector to one number, so a function that uses them for a per-element decision silently returns a single value; and a function that works through a loop variable instead of vectors cannot be fed a vector at all. & and | are the vectorised operators, && and || are for scalar conditions.

source("scenario.R")

# A vectorised function is built only from vectorised pieces.
days_late <- function(loan_date, return_date,
                      today = shelf_today, # nolint: object_usage_linter.
                      allowed = loan_days) { # nolint: object_usage_linter.
  end <- return_date
  end[is.na(end)] <- today
  pmax(0L, as.integer(end - loan_date) - allowed)
}
late <- days_late(loans$loan_date, loans$return_date)
length(late) == nrow(loans)
#> [1] TRUE
head(late, 12L)
#>  [1]  0  0  0  5  5  0  3  0 10  0 15  0

# The test of the contract: element i of f(x) is f(x[i]).
i <- 11L
identical(late[[i]], days_late(loans$loan_date[[i]], loans$return_date[[i]]))
#> [1] TRUE
same_one_by_one <- vapply(seq_along(late), \(k) {
  one <- days_late(loans$loan_date[[k]], loans$return_date[[k]])
  identical(late[[k]], one)
}, logical(1L))
all(same_one_by_one)
#> [1] TRUE

# A zero-length input gives a zero-length result.
days_late(loans$loan_date[0L], loans$return_date[0L])
#> integer(0)

# max() collapses the vector: no error, and the wrong answer.
late_wrong <- function(days_out, allowed = 21L) max(0L, days_out - allowed)
late_wrong(c(10L, 30L, 50L))
#> [1] 29
pmax(0L, c(10L, 30L, 50L) - 21L)
#> [1]  0  9 29

# if () and && need a single TRUE or FALSE.
days_out_if <- function(loan_date, return_date, today) {
  end <- if (is.na(return_date)) today else return_date
  as.integer(end - loan_date)
}
tryCatch(
  days_out_if(loans$loan_date[1:3], loans$return_date[1:3], shelf_today),
  error = conditionMessage
)
#> [1] "the condition has length > 1"
tryCatch(c(TRUE, FALSE) && TRUE, error = conditionMessage)
#> [1] "'length = 2' in coercion to 'logical(1)'"
c(TRUE, FALSE) & TRUE
#> [1]  TRUE FALSE

# Recycling is silent when the lengths divide, so check lengths on entry.
days_late_checked <- function(loan_date, return_date) {
  stopifnot(length(loan_date) == length(return_date))
  days_late(loan_date, return_date)
}
tryCatch(
  days_late_checked(loans$loan_date[1:4], loans$return_date[1:2]),
  error = conditionMessage
)
#> [1] "length(loan_date) == length(return_date) is not TRUE"

The wrong answer from late_wrong() is the dangerous failure: max(0L, days_out - allowed) returns 29, one number for three loans, and nothing in the output says that a vector was expected. The cure is pmax(), covered below, or a test like the one in the script.

Why it is faster

A loop in R pays the interpreter’s cost once per iteration: the evaluation of the loop body, the lookup of every function and variable, the boxing of a one-element result, and the creation of a promise for every argument of every call. A vectorised function pays it once per call and then runs a tight loop in C. The gap grows with the length of the vectors and is large enough to dominate any other consideration for data of realistic size, as the script shows by repeating the shelf loans 2000 times (120000 rows) and computing the days late with a preallocated loop and with the vectorised form. The comparison printed is TRUE when the loop is more than ten times slower; the actual factor depends on the machine, and it is usually much larger. Both versions are byte-compiled by the JIT compiler (see the last section), so the difference is not an artefact of interpreting the loop line by line.

source("scenario.R")

# Days late for 120000 loans (the shelf loans repeated), by loop and by vector.
loan_date <- rep(loans$loan_date, 2000L)
return_date <- rep(loans$return_date, 2000L)
n <- length(loan_date)

late_loop <- function(loan_date, return_date, today, allowed) {
  out <- integer(length(loan_date))
  for (i in seq_along(loan_date)) {
    end <- return_date[[i]]
    if (is.na(end)) {
      end <- today
    }
    out[[i]] <- max(0L, as.integer(end - loan_date[[i]]) - allowed)
  }
  out
}

late_vector <- function(loan_date, return_date, today, allowed) {
  end <- return_date
  end[is.na(end)] <- today
  pmax(0L, as.integer(end - loan_date) - allowed)
}

elapsed <- function(expr) system.time(expr)[["elapsed"]]
t_loop <- elapsed(
  a <- late_loop(loan_date, return_date, shelf_today, loan_days)
)
t_vector <- elapsed(
  b <- late_vector(loan_date, return_date, shelf_today, loan_days)
)
identical(a, b)
#> [1] TRUE
n
#> [1] 120000
t_loop > 10 * t_vector
#> [1] TRUE

Profiling, microbenchmark and the other tools for measuring are the subject of Performance, Profiling and Benchmarking; the lesson here is only that a vectorised expression is both shorter and faster, and that it needs the loop’s logic expressed in terms of whole-vector operations, which the next sections supply.

Running totals and differences

cumsum, cumprod, cummax and cummin

cumsum(x) returns the running total, cumprod(x) the running product, and cummax(x) and cummin(x) the running maximum and minimum. All four are documented on one help page (R documentation: cumsum). They are the vectorised answer to "loop over the elements and keep a running value", provided that the update is one of those four operations. They have three behaviours worth knowing. A missing value propagates to the end of the result, because every later total depends on it, so the usual repair is to replace the NA by the neutral element (0 for a sum, 1 for a product) before the call, or to keep the positions with which(). An integer sum that overflows gives NA with a warning whose text names the remedy, integer overflow in 'cumsum'; use 'cumsum(as.numeric(.))'. And the names are kept (a one-dimensional table becomes a named vector), which is what makes a running total over a table() readable.

The shelf example is the running number of loans per month. The months are written as a factor with all 18 levels from 2025-01 to 2026-06, so that a month without loans still has a count of zero (here the last month, 2026-06, has none) instead of being missing from the table, which would silently shift the running total against a calendar. cumsum() of the table then gives the cumulative number of loans at the end of every month, and the last value equals the number of loans with a valid date.

source("scenario.R")

# Running calculations: one value per input element.
x <- c(3, 1, 4, 1, 5, 9, 2, 6)
cumsum(x)
#> [1]  3  4  8  9 14 23 25 31
cumprod(x)
#> [1]    3    3   12   12   60  540 1080 6480
cummax(x)
#> [1] 3 3 4 4 5 9 9 9
cummin(x)
#> [1] 3 1 1 1 1 1 1 1

# A missing value poisons everything after it.
y <- c(2, 5, NA, 4)
cumsum(y)
#> [1]  2  7 NA NA
cummax(y)
#> [1]  2  5 NA NA
cumsum(ifelse(is.na(y), 0, y))
#> [1]  2  7  7 11

# Integers stay integers until they overflow, then become NA with a warning.
big <- c(.Machine$integer.max, 1L)
tryCatch(cumsum(big), warning = conditionMessage)
#> [1] "integer overflow in 'cumsum'; use 'cumsum(as.numeric(.))'"
cumsum(as.numeric(big))
#> [1] 2147483647 2147483648

# Names are kept.
cumsum(c(a = 1, b = 2, c = 3))
#> a b c
#> 1 3 6

# Running total of loans per month on the shelf.
first_of_month <- seq(as.Date("2025-01-01"), by = "month", length.out = 18L)
months <- format(first_of_month, "%Y-%m")
month <- factor(format(loans$loan_date, "%Y-%m"), levels = months)
per_month <- table(month)
per_month
#> month
#> 2025-01 2025-02 2025-03 2025-04 2025-05 2025-06 2025-07 2025-08 2025-09 2025-10
#>       4       2       3       3       2       3       7       1       4       7
#> 2025-11 2025-12 2026-01 2026-02 2026-03 2026-04 2026-05 2026-06
#>       3       3       4       3       3       4       3       0
running <- cumsum(per_month)
running
#> 2025-01 2025-02 2025-03 2025-04 2025-05 2025-06 2025-07 2025-08 2025-09 2025-10
#>       4       6       9      12      14      17      24      25      29      36
#> 2025-11 2025-12 2026-01 2026-02 2026-03 2026-04 2026-05 2026-06
#>      39      42      46      49      52      56      59      59
tail(running, 1L) == sum(!is.na(loans$loan_date))
#> 2026-06
#>    TRUE

diff

diff(x) returns the successive differences x[(1+lag):n] - x[1:(n-lag)], so it is shorter than x by lag elements. Its two arguments are lag, the distance between the two elements subtracted, and differences, the number of times the operation is applied (a second difference is the difference of the differences). The help page also says that a missing value propagates and that a matrix is differenced column by column. For dates, diff() has a Date method, and the result is an object of class difftime, with units in days; as.integer() (or as.numeric(gaps, units = "days")) takes the number out. The inverse is diffinv(), which lives in the stats package (R documentation: diffinv), and diff(cumsum(x)) gives x back without its first element.

The shelf example sorts the loan dates and takes the gaps between consecutive loans. sort() drops the one NA date (the impossible 2025-02-30), so 59 dates give 58 gaps, and which.max() finds where the longest quiet period started.

source("scenario.R")

# diff() subtracts neighbours: the result is shorter by `lag` elements.
x <- c(1, 4, 9, 16, 25)
diff(x)
#> [1] 3 5 7 9
diff(x, lag = 2L)
#> [1]  8 12 16
diff(x, differences = 2L)
#> [1] 2 2 2
diff(x, lag = 2L, differences = 2L)
#> [1] 8
diff(1)
#> numeric(0)

# It is the vectorised form of x[-1] - x[-length(x)].
identical(diff(x), x[-1L] - x[-length(x)])
#> [1] TRUE

# Dates give a difftime object.
loan_dates <- sort(loans$loan_date)
gaps <- diff(loan_dates)
class(gaps)
#> [1] "difftime"
units(gaps)
#> [1] "days"
head(gaps, 8L)
#> Time differences in days
#> [1]  2  5  5 24  9 11 16  3
as.integer(gaps)[1:8]
#> [1]  2  5  5 24  9 11 16  3
max(gaps)
#> Time difference of 28 days
loan_dates[which.max(gaps) + 0:1]
#> [1] "2025-05-07" "2025-06-04"

# The shortest and the longest gap between two loans.
range(gaps)
#> Time differences in days
#> [1]  1 28

# A matrix is differenced column by column.
m <- matrix(c(1, 4, 9, 2, 4, 8), nrow = 3L)
diff(m)
#>      [,1] [,2]
#> [1,]    3    2
#> [2,]    5    4

# diffinv() is the inverse: it rebuilds the series from its differences.
diffinv(diff(x), xi = x[[1L]])
#> [1]  1  4  9 16 25
diff(cumsum(c(1, 4, 9, 16)))
#> [1]  4  9 16

Parallel extremes: pmin and pmax

max() and min() look at all the values of all their arguments and return one number. pmax() and pmin() ("parallel" maximum and minimum) compare the arguments position by position and return a vector. Both pairs are documented on the same page (R documentation: Extremes), and the introduction manual lists them together in its vector-arithmetic section. The details that matter are these. The arguments are recycled to a common length, and the result has length zero if any argument has length zero. na.rm = FALSE is the default, so an NA in either argument gives NA at that position, while na.rm = TRUE ignores NA position by position; but a position where every argument is NA is NA even then, which is the difference from max() and min(), whose result for an empty set is -Inf and Inf with a warning. Attributes such as names and dim are copied from the first argument, the result type is the highest of the inputs (integer, then double, then character), and pmax() and pmin() work on classed objects such as Date that have comparison methods, but the faster pmax.int() and pmin.int() drop all attributes.

pmax(0, x) is the idiom for "clamp from below", pmin(x, cap) for "clamp from above", and the two together, pmin(pmax(x, lower), upper), for a range. They replace the most common use of ifelse() for numbers, with the advantage of keeping the type and the attributes. On the shelf, a loan is late by days_out - 21 days when that number is positive and not late otherwise: pmax(0L, days_out - loan_days) computes it for all 60 loans, and the loan with the impossible date stays NA.

source("scenario.R")

# max() and min() reduce to one value; pmax() and pmin() work by position.
a <- c(1, 5, 3)
b <- c(4, 2, 6)
max(a, b)
#> [1] 6
pmax(a, b)
#> [1] 4 5 6
pmin(a, b)
#> [1] 1 2 3
pmax(a, 2)
#> [1] 2 5 3

# Days late on the shelf: negative values become zero.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
days_late <- pmax(0L, days_out - loan_days)
head(days_late, 12L)
#>  [1]  0  0  0  5  5  0  3  0 10  0 15  0
sum(days_late > 0L, na.rm = TRUE)
#> [1] 32

# Missing values: na.rm = FALSE by default; TRUE drops them by position.
pmax(c(1, NA, 3), c(2, 2, NA))
#> [1]  2 NA NA
pmax(c(1, NA, 3), c(2, 2, NA), na.rm = TRUE)
#> [1] 2 2 3
pmax(c(NA, 1), c(NA, 2), na.rm = TRUE)
#> [1] NA  2

# A zero-length argument gives a zero-length result.
pmax(numeric(0L), 1)
#> numeric(0)
days_late[is.na(loans$loan_date)]
#> [1] NA

# Clamp to a range with pmin(pmax()).
pmin(pmax(c(-3, 0.5, 8), 0), 1)
#> [1] 0.0 0.5 1.0

# Attributes come from the first argument.
pmax(c(a = 1, b = 5), c(3, 2))
#> a b
#> 3 5
pmax(matrix(1:4, 2L), 2L)
#>      [,1] [,2]
#> [1,]    2    3
#> [2,]    2    4

# Mixed types: the result type is the common type.
class(pmax(1L, 2.5))
#> [1] "numeric"
class(pmax(as.Date("2026-06-01"), as.Date("2026-05-01")))
#> [1] "Date"
pmin(c("b", "a"), "ab")
#> [1] "ab" "a"

Group sums: rowsum

rowsum(x, group) adds up the rows of x that share a value of group and returns one row per group. It is generic, with a method for data frames and a default method for vectors and matrices, and the help page calls its hashing implementation much faster for large matrices and lists the tapply(), split() and aggregate() forms as slower versions (R documentation: rowsum). A numeric vector is treated as a one-column matrix, so the result is always a matrix with the group values as row names. The reorder argument controls the row order: TRUE (the default) gives the order of sort(unique(group)) and agrees with tapply(), FALSE gives the order in which the groups first appear. A missing value in group is treated as another group and gives a warning, and a missing value in x makes its group NA unless na.rm = TRUE, in which case a group of only NA sums to zero. For integer input, an overflow gives NA.

The name is a trap: rowsum() is not rowSums(). rowSums(), colSums(), rowMeans() and colMeans() reduce a whole matrix or data frame margin to one number per row or column and have no grouping, while rowsum() sums within groups of rows. The help page points out that for a single group over all the rows colSums() is even faster.

The shelf example sums the days out per member. The member column is dirty (one name written in two ways, and one empty value), so the names are tidied first with tolower() and trimws(), and the empty name is replaced by a placeholder; the second rowsum() call counts loans by summing a column of ones, which gives the counts as a one-column matrix that can be bound to other columns.

source("scenario.R")

# rowsum() adds up the rows of each group in one pass.
x <- c(10, 20, 30, 40, 50)
g <- c("b", "a", "b", "a", "c")
rowsum(x, g)
#>   [,1]
#> a   60
#> b   40
#> c   50
rowsum(x, g, reorder = FALSE)
#>   [,1]
#> b   40
#> a   60
#> c   50

# The same result as tapply(), but as a matrix and without the apply machinery.
tapply(x, g, sum)
#>  a  b  c
#> 60 40 50

# A matrix is summed column by column, within groups.
m <- cbind(pages = c(100, 200, 300), loans = c(1, 2, 3))
rowsum(m, c("x", "y", "x"))
#>   pages loans
#> x   400     4
#> y   200     2

# A missing value makes its group NA; na.rm = TRUE drops it, and a group that
# is all NA then sums to 0.
y <- c(1, NA, NA)
rowsum(y, c("a", "a", "b"))
#>   [,1]
#> a   NA
#> b   NA
rowsum(y, c("a", "a", "b"), na.rm = TRUE)
#>   [,1]
#> a    1
#> b    0

# rowSums(), colSums() and rowMeans() reduce a whole matrix margin.
rowSums(m)
#> [1] 101 202 303
colSums(m)
#> pages loans
#>   600     6
rowMeans(m)
#> [1]  50.5 101.0 151.5

# Days out per member, with the member names tidied first.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
member <- tolower(trimws(loans$member))
member[member == ""] <- "(unknown)"
total_days <- rowsum(days_out, member, na.rm = TRUE)
total_days
#>               [,1]
#> (unknown)       18
#> ana ruiz       329
#> ben okafor     116
#> chloe martin   246
#> dev patel      202
#> eva lindqvist  162
#> farid karimi    20
#> grace liu      149
#> hugo silva     228
rowsum(rep(1L, nrow(loans)), member)
#>               [,1]
#> (unknown)        1
#> ana ruiz        14
#> ben okafor       5
#> chloe martin     9
#> dev patel        6
#> eva lindqvist    6
#> farid karimi     2
#> grace liu        7
#> hugo silva      10
sum(total_days) == sum(days_out, na.rm = TRUE)
#> [1] TRUE
rownames(total_days)[which.max(total_days)]
#> [1] "ana ruiz"

Buckets and lookups

findInterval and cut

findInterval(x, vec) returns, for each element of x, the number of breakpoints in the sorted vector vec that are less than or equal to it, so 0 means "below the first breakpoint" and length(vec) means "at or above the last". The intervals are closed on the left and open on the right, as in vec[i] <= x < vec[i + 1]. Four arguments adjust the edges: left.open = TRUE flips the closure (closed on the right), rightmost.closed = TRUE includes the last breakpoint in the last interval, all.inside = TRUE forces the result into 1:(N - 1), and checkSorted and checkNA can be turned off for speed when the caller guarantees the input (R documentation: findInterval). A missing x gives NA; unsorted vec is an error ('vec' must be sorted non-decreasingly and not contain NAs). The search takes a time proportional to n * log(N), so it is also the right tool for a large number of values and a few hundred breakpoints, where a loop of comparisons would be quadratic.

cut() does the same bucketing and returns a factor with labels, and it closes the intervals on the right by default (right = TRUE), the opposite of findInterval(), so the same breakpoints put the value 150 in different buckets unless right = FALSE is given (R documentation: cut). The practical rule is to use cut() when the result is a categorical variable for a table or a model, and findInterval() when the result is an index, for instance into a vector of labels or rates, as in labels[findInterval(x, breaks)], which is a vectorised lookup and the replacement for a chain of if/else if.

source("scenario.R")

# findInterval() returns the number of breakpoints at or below each value.
breaks <- c(0, 150, 300, 450)
pages <- c(88L, 150L, 299L, 300L, 432L, 500L, NA)
findInterval(pages, breaks)
#> [1]  1  2  2  3  3  4 NA

# Name the interval with a lookup: one vectorised index, no loop.
labels <- c("short", "medium", "long", "very long")
labels[findInterval(pages, breaks)]
#> [1] "short"     "medium"    "medium"    "long"      "long"      "very long"
#> [7] NA

# cut() does the same job and returns a factor.
cut(pages, breaks = c(breaks, Inf), labels = labels, right = FALSE)
#> [1] short     medium    medium    long      long      very long <NA>
#> Levels: short medium long very long

# By default cut() closes the intervals on the right, so 150 is "short".
cut(pages, breaks = c(breaks, Inf), labels = labels)
#> [1] short     short     medium    medium    long      very long <NA>
#> Levels: short medium long very long

# Arguments of findInterval().
findInterval(c(0, 150, 450, 451), breaks, left.open = TRUE)
#> [1] 0 1 3 4
findInterval(c(0, 450, 451), breaks, rightmost.closed = TRUE)
#> [1] 1 3 4
findInterval(c(-5, 0, 450, 500), breaks, all.inside = TRUE)
#> [1] 1 1 3 3

# The breakpoints must be sorted; NA values in x stay NA (see above).
tryCatch(findInterval(5, c(3, 1, 2)), error = conditionMessage)
#> [1] "'vec' must be sorted non-decreasingly and not contain NAs"

# The shelf: bucket books$pages and count the books in each bucket.
bucket <- findInterval(books$pages, breaks)
table(factor(labels[bucket], levels = labels), useNA = "ifany")
#>
#>     short    medium      long very long      <NA>
#>         2         6         6         0         2

The last lines bucket books$pages into the four categories: two books are short, six medium, six long, none very long, and two have no page count at all. NA pages stay NA and are counted separately by useNA = "ifany", so they are not lost in the table.

match and %in%

match(x, table) returns, for each element of x, the position of its first match in table, or nomatch (default NA) when there is none. x %in% table is the logical version, defined in the help page as match(x, table, nomatch = 0L) > 0L, so it never returns NA, which is why it is safe in an if condition. %notin%, new in R 4.6.0, is its negation (R documentation: match). match() hashes table internally, so the cost per element does not grow with the size of the table: this is the vectorised replacement for the nested loop "for every loan, search the books until the ISBN matches". A few definitions are worth keeping in mind. Only the first match counts when table has duplicates. NA matches NA (unlike ==, which gives NA). x and table are coerced to a common type first, and factors are converted to character, so match() on a factor compares labels, not codes. incomparables lists values that must never match.

The shelf example is the classic lookup. The loans carry the ISBN as 13 bare digits and the books as the 16 hyphenated or bare strings of the catalogue, so a direct match() would find almost nothing. The keys are put into the same form first with normalise_isbn() from the scenario; then match(loan_isbn, book_isbn) returns the row of books for every loan in one call, books$title[row] fetches the titles by position, and one loan (L005, whose ISBN is one digit short) has no match. The table() of the titles then counts loans per book; the titles are used as they are in the catalogue (only trimws() is applied), so the lowercase title the quiet streets keeps its case. The loop at the end is what match() replaces, and identical() confirms the two give the same answer.

source("scenario.R")

# match() returns the position of each first argument element in the table.
match(c("b", "z", "a"), c("a", "b", "c"))
#> [1]  2 NA  1
match(c("b", "z", "a"), c("a", "b", "c"), nomatch = 0L)
#> [1] 2 0 1
c("b", "z", "a") %in% c("a", "b", "c")
#> [1]  TRUE FALSE  TRUE

# Only the first match counts.
match("a", c("x", "a", "a"))
#> [1] 2

# NA matches NA, unlike ==.
match(NA, c(1, NA, 3))
#> [1] 2
NA %in% c(1, NA, 3)
#> [1] TRUE
NA == NA # nolint: equals_na_linter.
#> [1] NA

# The loans store bare ISBN digits and the books store hyphenated ones,
# so the keys have to be put in the same form first.
loan_isbn <- loans$isbn
book_isbn <- normalise_isbn(books$isbn)
row <- match(loan_isbn, book_isbn)
head(row, 10L)
#>  [1]  2  1  4  7 NA  6  7  3  3  1
head(books$title[row], 5L)
#> [1] "Pride and Prejudice"   "The Dispossessed"      "Loud Roofs"
#> [4] "Garden of Small Fires" NA

# Loans whose ISBN is not on the shelf at all.
loans$loan_id[is.na(row)]
#> [1] "L005"
loans$isbn[is.na(row)]
#> [1] "978030640615"

# Count loans per title with one lookup and one table().
titles <- trimws(books$title)[row]
loans_per_title <- sort(table(titles), decreasing = TRUE)
head(loans_per_title, 4L)
#> titles
#> the quiet streets       Ten Winters  The Dispossessed        Loud Roofs
#>                 8                 7                 7                 6

# %in% is the membership test; any() and all() summarise it.
sum(loan_isbn %in% book_isbn)
#> [1] 59
any(!loan_isbn %in% book_isbn)
#> [1] TRUE
c("0000", loan_isbn[[2L]]) %notin% book_isbn
#> [1]  TRUE FALSE

# The loop it replaces.
row_loop <- integer(length(loan_isbn))
for (i in seq_along(loan_isbn)) {
  hit <- which(book_isbn == loan_isbn[[i]])
  row_loop[[i]] <- if (length(hit)) hit[[1L]] else NA_integer_
}
identical(row, row_loop)
#> [1] TRUE

merge() and dplyr::left_join() are the data-frame versions of the same lookup, but match() is the primitive that most of them are built on, and for adding one column to a data frame by key df$new <- other$value[match(df$key, other$key)] is shorter and faster than a merge that may reorder rows. See Data Frames for merge().

ifelse

ifelse(test, yes, no) is the vectorised form of if/else: it returns a value of the shape of test with elements taken from yes where test is true and from no where it is false (R documentation: ifelse). It is the tool people reach for first, and it has the surprises that make it the wrong default; they are all in the help page, and Control Flow works through each of them (type that depends on the values, attributes taken from test, yes and no evaluated for the whole vector, a length-one test giving a length-one result). The release NEWS for R 4.0.0 to 4.6.1 contains no entry about ifelse(): the behaviour shown here is the long-standing documented one, not a recent change. The script below adds the views that matter for vectorisation. Both branches are computed for every element, so a branch that raises a warning for the elements it does not select (here sqrt() of a negative number) still warns; the result type depends on the values selected, so a function that returns ifelse() can change type with its input; a Date passed as yes comes back as a number; a length-one test truncates yes; and if with a vector condition is an error rather than the vectorised form. When the choice is between a value and a bound, pmax(), pmin() or replace() keep the type and the attributes and say what they do; for several outcomes, findInterval() and cut() above replace nested ifelse() calls.

# ifelse() is vectorised, but it computes both branches for every element.
x <- c(4, -1, 9, NA)
tryCatch(ifelse(x >= 0, sqrt(x), NA), warning = conditionMessage)
#> [1] "NaNs produced"
suppressWarnings(ifelse(x >= 0, sqrt(x), NA))
#> [1]  2 NA  3 NA

# The values of the unused branch are thrown away, but the warning is raised.
# Subsetting computes only the elements that are needed.
res <- rep(NA_real_, length(x))
ok <- !is.na(x) & x >= 0
res[ok] <- sqrt(x[ok])
res
#> [1]  2 NA  3 NA

# The type of the result follows the values picked, not the arguments.
class(ifelse(c(TRUE, TRUE), 1L, "none"))
#> [1] "integer"
class(ifelse(c(TRUE, FALSE), 1L, "none"))
#> [1] "character"

# Attributes of yes and no are dropped: a Date becomes a number.
d <- as.Date(c("2026-06-01", "2026-06-15"))
ifelse(d > as.Date("2026-06-10"), d, NA)
#> [1]    NA 20619
class(ifelse(d > as.Date("2026-06-10"), d, NA))
#> [1] "numeric"

# A length-one test needs if, not ifelse(): ifelse() truncates yes.
ifelse(TRUE, c("a", "b", "c"), "z")
#> [1] "a"

# if with a vector condition is an error since R 4.2.0.
tryCatch(if (x > 0) "positive", error = conditionMessage)
#> [1] "the condition has length > 1"

# A vectorised alternative that keeps the type: pmax() or replace().
pmax(c(2, -3, 5), 0)
#> [1] 2 0 5
replace(c(2, -3, 5), c(2, -3, 5) < 0, 0)
#> [1] 2 0 5

which, rev, order and tabulate

Four small functions replace most of the remaining loops over positions.

  • which(x) turns a logical vector into the positions where it is TRUE; NA counts as not true, names are kept, and arr.ind = TRUE returns row and column indices for a matrix. which.max() and which.min() return the position of the first maximum or minimum (R documentation: which). which() is the vectorised form of "loop and remember the index".

  • rev(x) reverses a vector (R documentation: rev).

  • order(...) returns the permutation that sorts one or more keys, with decreasing, ties kept in their original order, NA last by default, and later arguments breaking ties of earlier ones. x[order(x)] is sort(x), but order() also sorts other vectors and whole data frames by one column (R documentation: order). sort() drops NA unless na.last is given.

  • tabulate(bin, nbins) counts how many times each integer from 1 to nbins occurs. It is the engine of table(), much faster for integer codes, returns a plain integer vector without names, and silently ignores values outside the range and NA (R documentation: tabulate). Combined with findInterval() it counts the members of every bucket in one pass, including empty buckets, which table() omits.

source("scenario.R")

# which() turns a logical vector into positions; NA counts as FALSE.
x <- c(5, 3, NA, 8, 3)
which(x > 4)
#> [1] 1 4
which(x == 3)
#> [1] 2 5
which(c(a = TRUE, b = FALSE, c = TRUE))
#> a c
#> 1 3
which(matrix(c(1, 5, 3, 7), 2L) > 2, arr.ind = TRUE)
#>      row col
#> [1,]   2   1
#> [2,]   1   2
#> [3,]   2   2
which.max(x)
#> [1] 4
which.min(x)
#> [1] 2

# rev() reverses; order() returns the permutation that sorts.
rev(c("x", "y", "z"))
#> [1] "z" "y" "x"
order(c(30, 10, 20))
#> [1] 2 3 1
order(c(30, 10, 20), decreasing = TRUE)
#> [1] 1 3 2

# Ties are kept in their original order; the second key breaks them.
order(c(2, 1, 2, 1), c(4, 3, 2, 1))
#> [1] 4 2 3 1

# The three longest loans on the shelf.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
longest <- order(days_out, decreasing = TRUE)[1:3]
data.frame(loan_id = loans$loan_id[longest], days = days_out[longest])
#>   loan_id days
#> 1    L054   84
#> 2    L055   81
#> 3    L056   71

# NA values go last in order(), and are dropped by sort().
order(c(2, NA, 1))
#> [1] 3 1 2
sort(c(2, NA, 1))
#> [1] 1 2
sort(c(2, NA, 1), na.last = TRUE)
#> [1]  1  2 NA

# tabulate() counts positive integers: faster than table() and always dense.
tabulate(c(2, 3, 3, 5))
#> [1] 0 1 2 0 1
tabulate(c(2, 3, 3, 5), nbins = 7L)
#> [1] 0 1 2 0 1 0 0
bucket <- findInterval(books$pages, c(0, 150, 300, 450))
tabulate(bucket, nbins = 4L)
#> [1] 2 6 6 0
table(bucket, useNA = "ifany")
#> bucket
#>    1    2    3 <NA>
#>    2    6    6    2

outer and functions that are not vectorised

outer(X, Y, FUN) builds the table of FUN(x, y) for every pair. It does so by extending X and Y with rep() to the product of their lengths and then calling FUN once with both long vectors, so FUN must be vectorised: the help page says it "must be a vectorized function (or the name of one) expecting at least two arguments and returning a value with the same length as the first (and the second)" (R documentation: outer). This is the situation of R FAQ 7.17, "Why does outer() behave strangely with my function?": the answer is that, unlike the apply family, outer() does not call the function pair by pair (R FAQ 7.17: Why does outer() behave strangely with my function?).

The two errors you will see are in the script. A function with a scalar if fails with the condition has length > 1, because it receives 6 values where it expected 1. A function that reduces its arguments, such as max(days * rate, 0), returns one number, and outer() then fails when it tries to give that result the dimensions of the table: dims [product 6] do not match the length of object [1] (the 6 is the number of pairs, the 1 the length of what the function returned). There are three repairs. The first, and usually the best, is to write the function with vectorised pieces (pmax() instead of if). The second is Vectorize(), which the FAQ recommends and which wraps a scalar function so that it is called once per pair. The third is the wrapper from the FAQ itself, which loops over the pairs; the script writes it with mapply() instead of the FAQ’s sapply() over seq_along(), so that it returns a vector of the right length even when the function returns a single value.

# A late fee that is written for one number at a time.
late_fee <- function(days, rate) {
  if (days > 0) days * rate else 0
}
late_fee(5, 0.1)
#> [1] 0.5

# outer() builds every pair of values and calls the function once, with
# two long vectors.
days <- c(0, 3, 10)
rate <- c(0.1, 0.25)
tryCatch(outer(days, rate, late_fee), error = conditionMessage)
#> [1] "the condition has length > 1"

# The other failure: the function returns the wrong length.
biggest <- function(days, rate) max(days * rate, 0)
tryCatch(outer(days, rate, biggest), error = conditionMessage)
#> [1] "dims [product 6] do not match the length of object [1]"

# What FUN actually receives.
outer(days, rate, function(d, r) {
  cat("length of d:", length(d), " length of r:", length(r), "\n")
  d * r
})
#> length of d: 6  length of r: 6
#>      [,1] [,2]
#> [1,]  0.0 0.00
#> [2,]  0.3 0.75
#> [3,]  1.0 2.50

# Repair 1: write the function with vectorised pieces.
late_fee_vec <- function(days, rate) pmax(days, 0) * rate
outer(days, rate, late_fee_vec)
#>      [,1] [,2]
#> [1,]  0.0 0.00
#> [2,]  0.3 0.75
#> [3,]  1.0 2.50

# Repair 2: Vectorize() wraps the scalar function.
outer(days, rate, Vectorize(late_fee))
#>      [,1] [,2]
#> [1,]  0.0 0.00
#> [2,]  0.3 0.75
#> [3,]  1.0 2.50

# Repair 3: the wrapper from R FAQ 7.17, written with mapply().
wrapper <- function(x, y, my_fun, ...) {
  mapply(function(a, b) my_fun(a, b, ...), x, y)
}
foo <- function(x, y, happy) {
  stopifnot(length(x) == 1L, length(y) == 1L)
  (x + y) * happy
}
outer(1:4, 1:2, FUN = wrapper, my_fun = foo, happy = 10)
#>      [,1] [,2]
#> [1,]   20   30
#> [2,]   30   40
#> [3,]   40   50
#> [4,]   50   60

# The default function is multiplication; %o% is its operator form. Names
# become dimnames, and a character FUN is looked up.
outer(1:3, 1:2)
#>      [,1] [,2]
#> [1,]    1    2
#> [2,]    2    4
#> [3,]    3    6
outer(c(a = 1, b = 2), c(x = 10, y = 20), "+")
#>    x  y
#> a 11 21
#> b 12 22

The cat() call inside the anonymous function in the script shows what FUN receives: one call with two vectors of length 6, not six calls with scalars. The same property explains why the argument ... of outer() is for extra arguments that are passed unchanged to every call, and why outer() is also the vectorised way to build a lookup grid for which(), a multiplication table, or the matrix of all pairwise differences between two vectors.

Vectorize

Vectorize(FUN) returns a function with the same arguments that calls FUN once per element, using mapply() under the hood (R documentation: Vectorize). vectorize.args names the arguments that are looped over, so the others (a threshold, a rate) are passed whole to every call; the defaults of arguments that are not passed are not vectorised. SIMPLIFY and USE.NAMES behave as in sapply(): the result is simplified to a vector or matrix when possible and named after the first argument when it is a character vector. Primitive functions cannot be wrapped because they have no formals, and so cannot functions that already have an argument called FUN, SIMPLIFY, USE.NAMES or vectorize.args. Vectorize() is a convenience and not an optimisation: the body of the wrapper in the script is a call to mapply(), so the loop is still there, in R code, and the speed is that of the loop it hides. Use it to feed a scalar function to outer() or to a vectorised caller, and rewrite hot functions with vectorised pieces. The functional side of mapply() and Map() is in The apply Family and Functionals.

# Vectorize() turns a scalar function into one that accepts vectors.
fee <- function(days, rate = 0.1, grace = 21) {
  if (days > grace) (days - grace) * rate else 0
}
fee_all <- Vectorize(fee)
fee_all(c(10, 30, 50))
#> [1] 0.0 0.9 2.9

# Only some arguments are vectorised when you say so.
fee_all2 <- Vectorize(fee, vectorize.args = "days")
fee_all2(c(10, 30, 50), rate = 0.5)
#> [1]  0.0  4.5 14.5

# The result is simplified like sapply(); SIMPLIFY = FALSE keeps a list.
Vectorize(function(n) seq_len(n), SIMPLIFY = FALSE)(1:3)
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 1 2
#>
#> [[3]]
#> [1] 1 2 3
#>
Vectorize(function(n) seq_len(n))(c(2, 2))
#>      [,1] [,2]
#> [1,]    1    1
#> [2,]    2    2

# A character first argument becomes the names of the result (USE.NAMES).
Vectorize(function(s) nchar(s))(c("Loud Roofs", "Paper Moons"))
#>  Loud Roofs Paper Moons
#>          10          11
Vectorize(function(s) nchar(s), USE.NAMES = FALSE)(
  c("Loud Roofs", "Paper Moons")
)
#> [1] 10 11

# It is a loop in disguise (mapply), so it is not faster than the loop.
body(fee_all)
#> {
#>     args <- lapply(as.list(match.call())[-1L], eval, parent.frame())
#>     names <- names(args) %||% character(length(args))
#>     dovec <- names %in% vectorize.args
#>     do.call("mapply", c(FUN = FUN, args[dovec], MoreArgs = list(args[!dovec]),
#>         SIMPLIFY = SIMPLIFY, USE.NAMES = USE.NAMES))
#> }

When a for loop is the right tool

Vectorisation is not a rule against loops. A for loop is the right tool when one of these holds.

  • Each step depends on the previous result in a way that no cumulative function expresses: a balance that cannot go negative, a state machine, an iteration that converges, a simulation in which the next draw depends on the last state.

  • The loop is run for its side effects: printing, writing a file, drawing a plot, sending a request, updating a database. The value of a loop is NULL, so the result of the work is the side effect.

  • The work per iteration is large (fitting a model, reading a file) so the interpreter overhead is negligible next to it, and a loop is the clearest way to say it.

  • The data are a list of unequal pieces and the computation is naturally recursive.

If the loop only computes one value per element, vapply() is the safer expression of the same loop, because it declares the type and length of every result (see The apply Family and Functionals). The syntax of for, while, repeat, break and next is in Control Flow; what follows is how to write them well.

Sequential dependence

The script below is a recurrence that cumsum() cannot express. Returns arrive each month (here the number of loans of that month stands for them) and at most three can be re-shelved per month; the backlog at the end of a month is max(0, previous + arrived - 3), so each value depends on the previous one and the max() stops the backlog from going negative. The plain cumsum(arrivals - 3) becomes negative and gives the wrong answer. Reduce() with accumulate = TRUE is the functional form of the same recurrence: it carries the value from one step to the next and returns all the intermediate values, with the initial value first, which is why the check drops it. The last loop is a side-effect loop: it prints one line for each loan out more than 60 days.

source("scenario.R")

# Returns arrive each month (here: as many as loans were made), and the
# librarian re-shelves at most 3 a month. The backlog at the end of a month
# depends on the backlog before it, and can never be negative.
first_of_month <- seq(as.Date("2025-01-01"), by = "month", length.out = 18L)
months <- format(first_of_month, "%Y-%m")
loan_month <- factor(format(loans$loan_date, "%Y-%m"), levels = months)
arrivals <- as.vector(table(loan_month))
arrivals
#>  [1] 4 2 3 3 2 3 7 1 4 7 3 3 4 3 3 4 3 0

backlog <- numeric(length(arrivals))
previous <- 0
for (i in seq_along(arrivals)) {
  previous <- max(0, previous + arrivals[[i]] - 3)
  backlog[[i]] <- previous
}
backlog
#>  [1] 1 0 0 0 0 0 4 2 3 7 7 7 8 8 8 9 9 6

# cumsum() cannot express the max(0, ...): it would go negative.
cumsum(arrivals - 3)[1:6]
#> [1]  1  0  0  0 -1 -1

# The same recurrence with Reduce(), keeping every intermediate value. The
# starting value 0 is returned first, so it is dropped.
step <- function(previous, arrived) max(0, previous + arrived - 3)
reduced <- Reduce(step, arrivals, accumulate = TRUE, 0)
reduced[1:4]
#> [1] 0 1 0 0
identical(reduced[-1L], backlog)
#> [1] TRUE

# Side effects are another reason for a loop: report each overdue loan.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_out <- as.integer(end_date - loans$loan_date)
overdue <- which(days_out > 60L)
for (i in overdue) {
  cat(sprintf("%s: %d days out\n", loans$loan_id[[i]], days_out[[i]]))
}
#> L054: 84 days out
#> L055: 81 days out
#> L056: 71 days out
#> L057: 65 days out

seq_along and seq_len instead of 1:n

The loop variable should come from seq_along(x) (positions of the elements of x) or seq_len(n) (the first n integers), never from 1:length(x) or 1:nrow(df). When the object is empty, 1:0 is c(1, 0): the loop runs twice, indexes position 1 and position 0, and with an assignment in the body can silently write to the wrong element. seq_along(x) and seq_len(0) are integer(0) and the body never runs. seq_len() accepts a non-negative number: a negative one is the error argument must be coercible to non-negative integer, and a vector argument keeps only the first element with the warning first element used of 'length.out' argument (R documentation: seq). The introduction manual itself uses 1:length(yc) in its worked example of for, which is the origin of the habit; Control Flow has the same trap with a data frame. The default lintr linter seq_linter flags 1:length(x), and the script marks those lines with # nolint: seq_linter. so that the trap can be shown.

# 1:length(x) and 1:nrow(x) count down when the object is empty.
x <- character(0L)
1:length(x) # nolint: seq_linter.
#> [1] 1 0
seq_along(x)
#> integer(0)
seq_len(length(x)) # nolint: seq_linter.
#> integer(0)

# A loop that runs when it should not.
visited <- 0L
for (i in 1:length(x)) { # nolint: seq_linter.
  visited <- visited + 1L
}
visited
#> [1] 2

visited <- 0L
for (i in seq_along(x)) {
  visited <- visited + 1L
}
visited
#> [1] 0

# The same trap for a data frame with no rows.
no_rows <- data.frame(id = integer(0L))
1:nrow(no_rows) # nolint: seq_linter.
#> [1] 1 0
seq_len(nrow(no_rows))
#> integer(0)

# seq_along() follows the elements, seq_len() takes a number.
seq_along(c("a", "b", "c"))
#> [1] 1 2 3
seq_len(3L)
#> [1] 1 2 3
seq_len(0L)
#> integer(0)
tryCatch(seq_len(-1L), error = conditionMessage)
#> [1] "argument must be coercible to non-negative integer"
tryCatch(seq_len(c(2, 3)), warning = conditionMessage)
#> [1] "first element used of 'length.out' argument"

Preallocation and the growing-vector trap

A loop has to store its results somewhere, and how it does so decides its cost. The two forms that look natural, out <- c(out, value) and out[[length(out) + 1]] <- value, grow the result one element at a time. The c() form allocates a new vector of length n + 1 and copies the n existing elements on every iteration, so the total work is proportional to the square of the final length: doubling the number of iterations multiplies the time by about four. The assignment form is less bad because R 3.4.0 made assignment beyond the end of a vector over-allocate by a small fraction (the NEWS entry marks the new vector as growable and says that pre-allocating is still preferred, and that the implementation is subject to change; see R NEWS: Changes in R 3.4.0), but it still has to find the end, it hides off-by-one bugs, and it is not something to build a design on. The remedy is to preallocate: numeric(n), integer(n), character(n), logical(n) or vector("list", n) allocate the result once, and the loop fills it by position (R documentation: vector). When the final length is not known in advance, preallocate an upper bound and trim with x[seq_len(filled)], or collect the pieces in a list and combine them once at the end with do.call(rbind, parts) or unlist(). The same applies to lists: lst[[length(lst) + 1]] <- item in a loop and rbind(df, row) in a loop are the quadratic patterns for lists and data frames.

The script times the three forms for 30000 elements and prints the comparisons. elapsed() takes the elapsed entry of system.time(). The first comparison is TRUE when the c() form is more than five times slower than the preallocated one; the second is TRUE when the assignment form is faster than the c() form; the third doubles the length and is TRUE when the c() form takes more than two and a half times as long, which is what a quadratic algorithm does and a linear one does not.

# Elapsed seconds for an expression: the absolute numbers differ by machine,
# so only comparisons are printed below.
elapsed <- function(expr) system.time(expr)[["elapsed"]]

n <- 30000L

grow_c <- function(n) {
  out <- c()
  for (i in seq_len(n)) {
    out <- c(out, i)
  }
  out
}

grow_assign <- function(n) {
  out <- c()
  for (i in seq_len(n)) {
    out[[length(out) + 1L]] <- i
  }
  out
}

preallocated <- function(n) {
  out <- integer(n)
  for (i in seq_len(n)) {
    out[[i]] <- i
  }
  out
}

all(grow_c(10L) == preallocated(10L), grow_assign(10L) == preallocated(10L))
#> [1] TRUE
t_c <- elapsed(grow_c(n))
t_assign <- elapsed(grow_assign(n))
t_pre <- elapsed(preallocated(n))

# Printed as comparisons, not as seconds.
t_c > 5 * t_pre
#> [1] TRUE
t_assign < t_c
#> [1] TRUE

# Doubling the length of a quadratic algorithm roughly quadruples its time.
t_c2 <- elapsed(grow_c(2L * n))
t_c2 / t_c > 2.5
#> [1] TRUE

while, repeat, break and next

while (cond) repeats while a condition holds and is the right form when the number of iterations is not known in advance; repeat loops until a break; next skips to the next iteration (R documentation: Control). The manual describes the three in the section on repetitive execution (An Introduction to R: Repetitive execution). Two cautions. A while loop whose condition never becomes false never ends, so the body must change something the condition tests, and an upper bound on the number of iterations is a cheap guard. And a search for the first element that satisfies a condition does not need any of them: match(TRUE, cond) and which(cond)[1] are the vectorised forms, as the end of the script shows. The full syntax, with if and switch(), is in Control Flow.

# while: how many months until a backlog of 20 returns is cleared at 3 a month?
backlog <- 20L
months_needed <- 0L
while (backlog > 0L) {
  backlog <- max(0L, backlog - 3L)
  months_needed <- months_needed + 1L
}
months_needed
#> [1] 7

# repeat with break, and next to skip an element.
n <- 0L
repeat {
  n <- n + 1L
  if (n %% 2L == 0L) {
    next
  }
  if (n > 7L) {
    break
  }
  cat("odd:", n, "\n")
}
#> odd: 1
#> odd: 3
#> odd: 5
#> odd: 7

# The vectorised version of "find the first match" is match() or which().
days <- c(12L, 30L, 65L, 80L)
match(TRUE, days > 60L)
#> [1] 3
which(days > 60L)[1L]
#> [1] 3

replicate and Reduce

replicate(n, expr) evaluates expr n times and simplifies the results as sapply() does, so it is the form for simulations where the repetitions are independent: the result is a vector (or a matrix, one column per repetition, when each result has length greater than one), and simplify = FALSE keeps a list. It is documented with the lapply() family (R documentation: lapply). Each repetition evaluates the expression afresh, so random draws differ, which is why the scripts call set.seed() before it (the seed makes the page reproducible; a real simulation chooses its own).

Reduce(f, x) applies a binary function cumulatively, as in f(f(f(x[1], x[2]), x[3]), x[4]). With accumulate = TRUE it returns every intermediate value, which is the cumulative recurrence of the previous section for any f, not only the four built-in ones; init gives the starting value, which is also the result for an empty input, and right = TRUE folds from the right. Without init, Reduce() of an empty vector is NULL (R documentation: funprog). A typical use is to combine a list of data frames pairwise, as the end of the script does with merge(). Folding with Reduce() and the plus operator is slower than sum(x): use the specialised function when there is one.

source("scenario.R")

# replicate() repeats an expression: it is sapply() over seq_len(n).
pages <- books$pages[!is.na(books$pages)]
set.seed(282)
replicate(3L, mean(sample(pages, 5L)))
#> [1] 206.6 179.0 293.2

# Each repetition is independent, so a simulation is a vector of results.
set.seed(282)
means <- replicate(1000L, mean(sample(pages, 5L)))
round(c(mean(means), sd(means)), 1)
#> [1] 269.3  39.1
round(mean(pages), 1)
#> [1] 268.2

# simplify = FALSE keeps a list; results of length > 1 are stacked as columns.
set.seed(282)
dim(replicate(4L, range(sample(1:10, 3L))))
#> [1] 2 4
class(replicate(2L, 1:3, simplify = FALSE))
#> [1] "list"

# Reduce() applies a binary function cumulatively.
Reduce("+", 1:5)
#> [1] 15
Reduce("+", 1:5, accumulate = TRUE)
#> [1]  1  3  6 10 15
Reduce("+", 1:5, accumulate = TRUE, right = TRUE)
#> [1] 15 14 12  9  5
Reduce(function(a, b) paste0(a, b), c("a", "b", "c"), accumulate = TRUE)
#> [1] "a"   "ab"  "abc"

# With an initial value, and over an empty vector.
Reduce("+", integer(0L))
#> NULL
Reduce("+", integer(0L), 0L)
#> [1] 0

# A recurrence that cumsum() cannot do: compound growth with a yearly fee.
growth <- function(balance, rate) balance * (1 + rate) - 1
Reduce(growth, c(0.05, 0.04, 0.06), 100, accumulate = TRUE)
#> [1] 100.0000 104.0000 107.1600 112.5896

# Merging a list of data frames: several joins in one call.
parts <- list(
  data.frame(isbn = books$isbn[1:3], title = books$title[1:3]),
  data.frame(isbn = books$isbn[1:3], year = books$year[1:3]),
  data.frame(isbn = books$isbn[1:3], pages = books$pages[1:3])
)
Reduce(function(a, b) merge(a, b, by = "isbn"), parts)
#>                isbn                title year pages
#> 1 978-0-14-143951-8  Pride and Prejudice 1813   432
#> 2 978-0-306-40615-7     The Dispossessed 1974   387
#> 3     9780001000100   the quiet streets  2019   214

Loops over data frames

A data frame is a list of columns, so a loop over its rows is almost always the wrong shape. The cost is in the access: df[i, "col"] and df[i, "col"] <- value go through the data-frame method of the subsetting functions, with checks and copies, and are far slower than the same operation on a plain vector. Three habits keep the loop cheap. Take the columns out into vectors before the loop and loop over positions with seq_len(nrow(df)), never 1:nrow(df), which counts down on an empty data frame. Assign into a vector and put the vector back into the data frame once at the end. And if the loop is really "do something for each group", split(df, f) cuts the data frame into a list of data frames, which can be processed with vapply() or a loop over names() (R documentation: split).

apply(df, 1, f) is the other trap of older tutorials: apply() first converts the data frame to a matrix with as.matrix(), and a data frame with a character column becomes a character matrix, so numbers and dates arrive in f as strings (here the Date column is a "character"). Use vapply() over seq_len(nrow(df)), over a column, or Map() over several columns instead. One more pitfall of the grouped loop in the script: a list element whose name is the empty string cannot be fetched by name, because x[[""]] never matches, so the empty member name is replaced by a placeholder before split().

source("scenario.R")

# Loop over the rows by position, and read the columns once.
loan_date <- loans$loan_date
return_date <- loans$return_date
days <- integer(nrow(loans))
for (i in seq_len(nrow(loans))) {
  days[[i]] <- as.integer(
    (if (is.na(return_date[[i]])) shelf_today else return_date[[i]]) -
      loan_date[[i]]
  )
}
head(days)
#> [1] 21 10  1 26 26 17

# apply() on a data frame converts it to a character matrix first.
first_two <- loans[1:2, c("loan_id", "loan_date")]
apply(first_two, 1L, function(r) class(r[["loan_date"]]))
#>           1           2
#> "character" "character"
as.matrix(first_two)
#>   loan_id loan_date
#> 1 "L001"  "2025-01-14"
#> 2 "L002"  "2025-01-16"

# split() cuts a data frame by a factor, so each group is handled in one go.
member <- tolower(trimws(loans$member))
member[member == ""] <- "(unknown)"
by_member <- split(loans, member)
length(by_member)
#> [1] 9
vapply(by_member, nrow, integer(1L))
#>     (unknown)      ana ruiz    ben okafor  chloe martin     dev patel
#>             1            14             5             9             6
#> eva lindqvist  farid karimi     grace liu    hugo silva
#>             6             2             7            10
names(by_member)[1:3]
#> [1] "(unknown)"  "ana ruiz"   "ben okafor"

# A group with an empty name cannot be fetched by name: [[""]] never matches.
by_name <- list(1, 2)
names(by_name) <- c("", "b")
by_name[[""]]
#> NULL

# Walking over groups with a for loop and filling a list.
first_loan <- vector("list", length(by_member))
names(first_loan) <- names(by_member)
for (nm in names(by_member)) {
  first_loan[[nm]] <- min(by_member[[nm]]$loan_date, na.rm = TRUE)
}
first_loan[["ana ruiz"]]
#> [1] "2025-03-27"

# Row-wise assignment into a data frame is much slower than into a column.
big <- loans[rep(seq_len(nrow(loans)), 20L), c("loan_id", "isbn")]
big$flag <- FALSE
elapsed <- function(expr) system.time(expr)[["elapsed"]]
t_cell <- elapsed(for (i in seq_len(nrow(big))) big[i, "flag"] <- TRUE)
flag <- logical(nrow(big))
t_vec <- elapsed({
  for (i in seq_len(nrow(big))) flag[[i]] <- TRUE
  big$flag <- flag
})
t_cell > 5 * t_vec
#> [1] TRUE

lapply versus for

A persistent belief says that lapply() and vapply() are faster than for because "R loops are slow and the apply functions are vectorised". They are not vectorised in the sense of this page: lapply(X, FUN) is a loop written in C that calls the R function FUN once per element, as the body of lapply shows (.Internal(lapply(X, FUN))), and a preallocated for loop that fills a vector calls the same function the same number of times. Measured on 200000 elements, the for loop, lapply() and vapply() take times of the same order, and the script prints the comparison as TRUE when the slowest is less than three times the fastest. The vectorised form, which calls the function once on the whole vector, is where the large gain is: TRUE when it is more than ten times faster than the best of the three. The reasons to prefer the apply family are therefore not speed: it states the intent (one result per element), it needs no preallocation, it cannot leave the loop variable behind, and vapply() checks the type and length of every result. The reasons to prefer a for loop are the ones in the previous part.

elapsed <- function(expr) system.time(expr)[["elapsed"]]
x <- seq_len(200000L)
f <- function(v) v * 2 + 1

t_for <- elapsed({
  out <- numeric(length(x))
  for (i in seq_along(x)) {
    out[[i]] <- f(x[[i]])
  }
})
t_lapply <- elapsed(res <- lapply(x, f))
t_vapply <- elapsed(res2 <- vapply(x, f, numeric(1L)))
t_vector <- elapsed(res3 <- f(x))
identical(out, res3)
#> [1] TRUE
identical(res2, res3)
#> [1] TRUE
identical(unlist(res), res3)
#> [1] TRUE

# The apply family is a loop in a nicer shape: the times are of the same order.
max(t_for, t_lapply, t_vapply) / min(t_for, t_lapply, t_vapply) < 3
#> [1] TRUE

# Vectorising is where the large gain is.
min(t_for, t_lapply, t_vapply) > 10 * t_vector
#> [1] TRUE

# lapply() is implemented in C but calls the R function once per element.
body(lapply)
#> {
#>     FUN <- match.fun(FUN)
#>     if (!is.vector(X) || is.object(X))
#>         X <- as.list(X)
#>     .Internal(lapply(X, FUN))
#> }

Byte-compilation

R compiles functions to byte code, and the loop on its own is faster than the interpreter’s reading of the syntax tree. Since R 3.4.0 a just-in-time (JIT) compiler is on by default at level 3: closures are compiled on their first or second use and top-level loops are compiled before they run (R NEWS: Changes in R 3.4.0). Since R 3.5.0 packages are byte-compiled when installed. compiler::enableJIT(level) sets the level (0 turns it off, 1 and 2 compile fewer things, a negative argument returns the current level) and returns the previous one; the environment variable R_ENABLE_JIT selects it at start-up. compiler::cmpfun(f) compiles a closure explicitly and is only useful when the JIT is off. The JIT is part of why a plain for loop on a preallocated vector is not as slow as older advice suggests, and why the examples on this page, all of which are compiled, still show the gap between a loop and the vectorised form: the byte-code interpreter removes much of the cost of reading the syntax tree, but not the cost of one R-level operation per element. A compiled function prints a <bytecode: ...> line with a memory address, which is why the page does not print compiled closures.

library(compiler)

# The JIT level in force: 3 is the default since R 3.4.0.
enableJIT(-1L)
#> [1] 3

elapsed <- function(expr) system.time(expr)[["elapsed"]]
slow_sum <- function(n) {
  total <- 0
  for (i in seq_len(n)) {
    total <- total + i * 2
  }
  total
}

# Turning the JIT off returns the previous level. The same loop then runs in
# the AST interpreter; t_interpreted / t_jit2 is how many times slower that
# is, a ratio that depends on the machine and so is not printed here.
old <- enableJIT(0L)
old
#> [1] 3
t_interpreted <- elapsed(slow_sum(2000000L))
invisible(enableJIT(old))
t_jit2 <- elapsed(slow_sum(2000000L))

# cmpfun() compiles a function explicitly, with the same result.
fast_sum <- cmpfun(slow_sum)
identical(fast_sum(1000L), slow_sum(1000L))
#> [1] TRUE
is.function(fast_sum)
#> [1] TRUE

# The vectorised version still beats both.
vec_sum <- function(n) sum(seq_len(n) * 2)
vec_sum(1000L) == slow_sum(1000L)
#> [1] TRUE
elapsed(vec_sum(2000000L)) < t_jit2
#> [1] TRUE

R 4.4.0 added declare(), which its NEWS calls a new experimental primitive "intended to eventually allow information about R code to be communicated to the interpreter, compiler, and code analysis tools", with a declaration syntax that is still being developed. On R 4.6.1 it ignores its arguments and returns NULL invisibly, so it neither speeds up a loop nor changes what the byte-code compiler does; it is mentioned here only so that the name is recognised in newer code (R documentation: declare).

Pitfalls collected

Symptom Cause and remedy Where

longer object length is not a multiple of shorter object length

Two operands whose lengths do not divide. Check the lengths, or recycle on purpose with rep_len().

R-intro: the recycling rule

A function returns one number for a vector argument, with no error

It reduces with max(), min() or sum() where it should compare element by element. Use pmax(), pmin() or ifelse().

This page

the condition has length > 1

if, while or a scalar-only function met a vector. Vectorise, or call it once per element.

R 4.2.0 NEWS

outer() fails with dims [product N] do not match the length of object [1] or with the condition has length > 1

FUN is not vectorised. Rewrite it with vectorised pieces or wrap it with Vectorize().

R FAQ 7.17

A loop runs twice on an empty input

1:length(x) or 1:nrow(df) counts down. Use seq_along() and seq_len().

This page

A loop takes far too long for 100000 iterations

The result is grown with c() or rbind() in the loop. Preallocate, or collect in a list and combine once.

This page

cumsum() or cummax() returns NA from some position onwards

A missing value propagates. Replace it by the neutral value or handle it first.

?cumsum

ifelse() returns numbers where dates or factors were expected

Attributes come from test, not from yes and no. Use replace(), an indexed assignment or dplyr::if_else().

?ifelse

apply(df, 1, f) sees every value as a string

apply() converts the data frame to a character matrix. Use vapply() over rows or columns.

?apply

A lookup returns NA for keys that look equal

The keys differ in form (hyphens, case, blanks). Normalise both sides, then match().

This page

Version notes

Version Change Source

R 3.4.0

The JIT byte-code compiler is enabled by default at level 3. Assigning beyond the end of a vector over-allocates by a small fraction (pre-allocating is still preferred). Arithmetic between a length-one array and a longer non-array warns instead of recycling silently.

NEWS 3.4.0

R 3.5.0

All packages are byte-compiled on installation by default.

NEWS 3.5.0

R 4.2.0

if() and while() with a condition of length greater than one is an error (it was a warning); && and || with an argument of length greater than one warn.

NEWS 4.2.0

R 4.3.0

&& and || with an argument of length greater than one is always an error, in the form 'length = 2' in coercion to 'logical(1)'.

NEWS 4.3.0

R 4.6.0

%notin% is added to base R as the negation of %in%.

NEWS 4.6.0

R 4.0.0 to 4.6.1

No NEWS entry changes the behaviour of ifelse(), pmax(), rowsum(), findInterval() or diff() as shown here.

NEWS 4.x

R-devel (4.7.0, under development)

x[logi] warns when length(x) is not a multiple of length(logi). It does not exist in R 4.6.1, so no example uses it.

R-devel NEWS

The 3.x entries are in the NEWS of R 3.x and the R-devel entry in the development NEWS; it may change before 4.7.0 is released.

Where older tutorials differ

Older tutorial Current practice Why Version

for (i in 1:length(x)) and for (i in 1:nrow(df))

seq_along(x) and seq_len(nrow(df))

the empty input makes 1:0 count down; the seq_linter of lintr flags it

always

x <- c(x, value) or x[length(x) + 1] <- value in a loop

preallocate with numeric(n) or vector("list", n) and assign by position

the c() form copies the vector on every step and is quadratic; assignment over-allocates only since R 3.4.0

R 3.4.0

ifelse() for every conditional on a vector

pmax(), pmin(), replace(), subsetting assignment, findInterval(), cut()

ifelse() computes both branches, takes its type from the data and drops attributes

always

apply(df, 1, f) on a data frame

vapply() over seq_len(nrow(df)), over a column, or Map() over columns; split() for groups

apply() turns the data frame into a character matrix

always

"Avoid loops, use sapply() because it is faster"

Use vapply() for clarity and safety, and vectorise for speed

the apply family is a loop in C calling an R function per element; the gain is in vectorising, not in the apply family

always

"R loops are very slow"

Loops are acceptable when the work per iteration is large or the iterations depend on each other; the JIT compiler is on by default

top-level loops and closures are compiled automatically since R 3.4.0, which removes much of the interpreter overhead of a simple loop

R 3.4.0

Writing a scalar function and calling it from outer()

a vectorised function, or Vectorize()

outer() calls the function once with long vectors (R FAQ 7.17)

always

Nested for loops to look up a key in another table

match() with normalised keys

match() hashes the table; a nested loop is quadratic

always

sapply(x, f) where f may return different types

vapply(x, f, numeric(1))

a declared type fails loudly instead of returning a list or a matrix by surprise

R 3.x onwards

For the full list of changes by release see What’s New and Migrating Older Code.

Where to go next