Control Flow

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. This page explains how R chooses between alternatives and how it repeats work: if and else (which are expressions that return a value), the vectorised ifelse(), switch(), and the loops for, while and repeat with break and next. It also covers the habits that go with them: seq_along() and seq_len() instead of 1:length(x), and preallocating the result of a loop. Every example uses only base R, so no add-on package version needs to be stated; dplyr 1.2.1 is named in prose only, where its if_else() and case_when() are the usual tidyverse alternatives to ifelse().

Every example below is a complete script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines starting with #> are what it printed. The scripts also pass the default lintr linters; where an idiom being shown is exactly what a default linter dislikes (1:length(x), NA_character_ == "a"), the line carries a # nolint: comment so that the linter does not hide the lesson. Errors and warnings that the page wants to show are caught with tryCatch() and printed with conditionMessage(), which gives the message without the leading Error in if (...) : that the console adds, and so that the script still ends normally. The names books, loans, shelf_today, loan_days and the function is_valid_isbn13() come from the scenario.R file of the running library-shelf scenario described on the section index; the scripts that use them start with source("scenario.R"), which reads books.csv and loans.csv with read.csv(). The first page, Getting Started, shows how to install R and run a script.

Control flow is made of expressions

R is an expression language. The introduction manual says that its only command type is a function or expression that returns a result, that even an assignment is an expression, and that commands grouped in braces { ... } form a group whose value is the result of the last expression evaluated in it (An Introduction to R: Grouping, loops and conditional execution). The language definition adds that computation consists of evaluating statements one after another, that a semicolon always ends a statement while a newline may end one, and that a group of statements in braces is a block (The R Language Definition: Control structures).

Two consequences shape the rest of this page.

  • if, for, while, repeat, break, next and function are reserved words, yet they behave like functions: if returns the value of the branch it took, a loop returns NULL, and calling if as a function, as the last lines of the first script do, works. The help page for Control says that these are the basic control-flow constructs of the language and that they work much like the control statements of any Algol-like language (R documentation: Control).

  • The decisions R makes in a program are mostly vectorised rather than written as loops. A condition that is a single TRUE or FALSE selects what to run (if); a condition with one answer per element selects values (ifelse(), logical subsetting); an explicit loop is the tool for work that really is sequential.

flowchart TD Q{"What do you need?"} Q -->|"run one of two or more blocks, chosen by one TRUE or FALSE"| IF["if / else if / else"] Q -->|"pick a value for every element of a vector"| IFELSE["ifelse(), logical subsetting, cut()"] Q -->|"one value with many named or numbered cases"| SW["switch()"] Q -->|"repeat for each element or a known count"| FOR["for over seq_along(x) or seq_len(n)"] Q -->|"repeat while a condition holds"| WH["while"] Q -->|"repeat with the exit test in the middle"| RE["repeat with break"] Q -->|"apply a function to every element and collect the results"| AP["vapply(), lapply(), Map()"]

The rest of this page follows the diagram: the conditional constructs first, then the loops. The apply family in the last branch is covered in The apply Family and Functionals and the vectorised branch in Vectorisation and Iteration.

if and else

The language definition gives the formal syntax of the conditional statement as if (statement1) statement2 else statement3: the condition is evaluated and, if it is TRUE, the first statement is evaluated, otherwise the second (The R Language Definition: if). The else part is optional, if statements can be nested through else if without any limit, and the statement returns, as its value, the value of the branch that ran. The introduction manual states the requirement on the condition: it must evaluate to a single logical value (An Introduction to R: Conditional execution). The section "The condition: what if accepts" below shows what that means in R 4.6.

if and else as an expression

Because if is an expression it can sit on the right-hand side of an assignment, as an argument, or as the last expression of a function, which is how most R functions return one of several values. When no branch runs (the condition is FALSE and there is no else) the value is NULL, and it is returned invisibly, so typing such an if at the console prints nothing. A block in braces is an expression too, and its value is that of its last expression.

# if/else is an expression: it has a value, so it can be assigned or returned.
pages <- 312L
size <- if (pages > 300L) "long" else "short"
size
#> [1] "long"
sizes <- c(short = "under 100 pages", long = "300 pages or more")
sizes[[size]]
#> [1] "300 pages or more"

# The value of the branch that ran, or an invisible NULL when no branch ran.
res <- if (pages > 1000L) "huge"
is.null(res)
#> [1] TRUE
if (pages > 1000L) "huge"
withVisible(if (pages > 1000L) "huge")
#> $value
#> NULL
#>
#> $visible
#> [1] FALSE
#>

# A chain of else if: the conditions are tested in order, first TRUE wins.
classify_length <- function(pages) {
  if (is.na(pages)) {
    "unknown"
  } else if (pages < 100L) {
    "short"
  } else if (pages < 300L) {
    "medium"
  } else {
    "long"
  }
}
vapply(c(80L, 250L, 312L, NA), classify_length, character(1L))
#> [1] "short"   "medium"  "long"    "unknown"

# The value of a block is the value of its last expression.
label <- if (pages > 300L) {
  n_words <- pages * 250L
  paste("about", n_words, "words")
} else {
  "fewer than 75000 words"
}
label
#> [1] "about 78000 words"

# if is a function: the call form shows the three arguments.
`if`(pages > 300L, "long", "short")
#> [1] "long"

A few remarks on the script. In the else if chain only the first condition that is TRUE selects a branch, so the order of the conditions matters, and the NA test goes first because every later comparison with NA would be NA. The function-call form at the end of the script evaluates the same way as the keyword form, which is why code that rewrites expressions (see Language Objects and Quoting) sees if as a call with three arguments. The help page recommends, as a form of defensive programming, to always use braces around the branches (R documentation: Control). Without braces the branch is a single expression, which is fine for short one-line forms such as if (cond) "a" else "b".

The else rule at the top level

The one layout rule that surprises people from other languages is that else must be on the same line as the end of the if branch unless the whole if is inside braces. The language definition spells it out: when the if statement is not in a block, the else, if present, must appear on the same line as the end of the first branch, because otherwise the newline completes the if and yields a syntactically complete statement that is evaluated. The suggested solution is a compound statement with the else on the same line as the closing brace (The R Language Definition: if). The parser-level explanation is in the The else rule section of Syntax and Lexical Structure; the script below shows the real parse errors.

# The same text, parsed at the top level and inside braces.
show_parse <- function(...) {
  src <- paste(c(...), collapse = "\n")
  msg <- tryCatch(
    {
      parse(text = src, keep.source = FALSE)
      "parses"
    },
    error = conditionMessage
  )
  cat(msg, "\n")
}

# At the top level the first line is already a complete statement.
show_parse("if (TRUE) 1", "else 2")
#> <text>:2:1: unexpected 'else'
#> 1: if (TRUE) 1
#> 2: else
#>    ^
show_parse("if (TRUE) 1 else 2")
#> parses
show_parse("if (TRUE) {", "  1", "} else {", "  2", "}")
#> parses
show_parse("if (TRUE) {", "  1", "}", "else {", "  2", "}")
#> <text>:4:1: unexpected 'else'
#> 3: }
#> 4: else
#>    ^

# Inside braces the newline before else is discarded.
show_parse("{", "  if (TRUE) 1", "  else 2", "}")
#> parses

# A function body is a braced block, so the same layout is accepted there.
f <- function(x) {
  if (x > 0)
    "positive"
  else
    "not positive"
}
f(1)
#> [1] "positive"
f(-1)
#> [1] "not positive"

# At the top level too, a brace that is still open keeps the expression going.
y <- {
  if (FALSE) "a"
  else "b"
}
y
#> [1] "b"

The message is unexpected 'else', with the offending line and a caret that points at the start of else. The same text is accepted in three places: on one line, with } else { on the closing-brace line, and anywhere inside { ... }, including the body of a function, because inside braces the parser is in the middle of a compound expression and the newline cannot end the statement. That is why indented if/else layouts that fail when pasted at the console work in a function. It is also why the rule only bites at the top level of a script or the console, which is exactly where beginners write their first if. The robust style is the braced form with } else { on one line.

A script file adds a second wrinkle. Rscript reads the file and evaluates one expression after another, so the lines before the broken if run and print before the error. source() parses the whole file first, so nothing in it runs and the error carries the file name and line number.

# The same layout in a script file: Rscript evaluates the file expression by
# expression, source() parses the whole file first.
script <- tempfile(fileext = ".R")
writeLines(c(
  'cat("before\\n")',
  "if (TRUE) 1",
  "else 2",
  'cat("after\\n")'
), script)

rscript <- file.path(R.home("bin"), "Rscript")
out <- suppressWarnings(system2(rscript, script, stdout = TRUE, stderr = TRUE))
cat(sub(script, "script.R", out, fixed = TRUE), sep = "\n")
#> before
#> [1] 1
#> Error: unexpected 'else' in "else"
#> Execution halted

res <- tryCatch(source(script), error = conditionMessage)
cat(sub(script, "script.R", res, fixed = TRUE), sep = "\n")
#> script.R:3:1: unexpected 'else'
#> 2: if (TRUE) 1
#> 3: else
#>    ^

# With the else on the closing-brace line the file runs from start to end.
writeLines(c(
  'cat("before\\n")',
  "if (TRUE) {",
  "  cat('then\\n')",
  "} else {",
  "  cat('else\\n')",
  "}",
  'cat("after\\n")'
), script)
cat(system2(rscript, script, stdout = TRUE, stderr = TRUE), sep = "\n")
#> before
#> then
#> after
unlink(script)

The help page for Control gives the same advice: do not put a newline between } and else when typing at the keyboard or sourcing, and consider always using braces (R documentation: Control).

The condition: what if accepts

The help page for Control describes the condition as a length-one logical vector that is not NA, and says that other types are coerced to logical if possible, ignoring any class, and that conditions of length greater than one are an error (R documentation: Control). The script shows each way a condition can go wrong, with the real message. The messages belong to if and while alike, so each case is shown for both where it matters.

# What a condition may be, and what happens when it is not.
try_if <- function(expr) {
  tryCatch(
    {
      expr
      invisible("no error")
    },
    error = function(e) cat("error:", conditionMessage(e), "\n"),
    warning = function(w) cat("warning:", conditionMessage(w), "\n")
  )
}

# A condition of length greater than one is an error since R 4.2.0.
x <- c(TRUE, FALSE)
try_if(if (x) "yes")
#> error: the condition has length > 1
try_if(while (x) break)
#> error: the condition has length > 1

# NA is neither TRUE nor FALSE.
try_if(if (NA) "yes")
#> error: missing value where TRUE/FALSE needed
try_if(if (NA_integer_ > 1L) "yes")
#> error: missing value where TRUE/FALSE needed
try_if(if (NA_character_ == "a") "yes") # nolint: equals_na_linter.
#> error: missing value where TRUE/FALSE needed

# Length zero.
try_if(if (logical(0L)) "yes")
#> error: argument is of length zero
try_if(if (NULL) "yes")
#> error: argument is of length zero
try_if(if (character(0L) == "a") "yes")
#> error: argument is of length zero

# A string that is not a logical value.
try_if(if ("yes") "ok")
#> error: argument is not interpretable as logical
if ("TRUE") "string TRUE is accepted"
#> [1] "string TRUE is accepted"
if ("T") "string T is accepted"
#> [1] "string T is accepted"
if ("true") "lower-case true is accepted"
#> [1] "lower-case true is accepted"

# Numbers are coerced: zero is FALSE and every other non-NA number is TRUE.
if (0) "a" else "b"
#> [1] "b"
if (2.5) "a" else "b"
#> [1] "a"
if (-1L) "a" else "b"
#> [1] "a"
try_if(if (NaN) "a")
#> error: argument is not interpretable as logical

# Lists and functions are not coerced.
try_if(if (list(TRUE)) "a")
#> error: argument is not interpretable as logical
try_if(if (sum) "a")
#> error: argument is not interpretable as logical

The table summarises the script.

Condition Result Message or rule

TRUE or FALSE

the branch runs

the normal case

a number

0 is FALSE, any other non-missing number is TRUE

if (2.5) takes the if branch

a string such as "TRUE", "T" or "true"

coerced to the logical value

a string that is not a logical spelling is an error

c(TRUE, FALSE) (length greater than one)

error since R 4.2.0

the condition has length > 1

NA of any type, including the result of a comparison with NA

error

missing value where TRUE/FALSE needed

logical(0), NULL, or a comparison involving a length-zero operand

error

argument is of length zero

"yes", NaN, a list or a function

error

argument is not interpretable as logical

The length-zero case deserves attention because it comes from code that looks safe. A comparison such as character(0) == "a" has length zero, so if fails with argument is of length zero rather than treating the result as FALSE. In real code the usual source is a lookup that finds nothing (x$missing_field == "a", or which(...) returning integer(0)). The NA case is the same problem from missing data: the shelf books table has a missing year and two missing pages, and if (books$pages[[6]] > 300) stops with missing value where TRUE/FALSE needed.

The language definition has an older description in which only the first element of a longer condition is used and an error is raised for a type that is not logical or numeric. Neither statement matches R 4.6.1: a longer condition is an error, and a string spelled like a logical value is accepted. The help page quoted above is the current rule.

Conditions of length greater than one

Until R 4.1.x, if (c(TRUE, FALSE)) gave only a warning and used the first element. The NEWS for R 4.2.0 records the change: calling if() or while() with a condition of length greater than one now gives an error rather than a warning, and as a consequence the environment variable _R_CHECK_LENGTH_1_CONDITION_ no longer has any effect (R NEWS: Changes in R 4.2.0). The R Internals manual lists that variable among the environment variables of the checking tools and now describes it as no longer in use, because conditions of length greater than one in if or while statements are an error (R Internals: Tools). Old code that relied on the first-element behaviour now fails, usually with the first call of a function that was passed a vector; the fix is to say what was meant (any(), all(), isTRUE(), an index like x[[1]], or ifelse() when a value per element is wanted).

The same release made && and || with an operand of length greater than one give a warning, and R 4.3.0 turned it into an error: the NEWS for 4.3.0 says that it is now always an error, reported in the form 'length = 4' in coercion to 'logical(1)', and that the environment variable _R_CHECK_LENGTH_1_LOGIC2_ no longer has any effect (R NEWS: Changes in R 4.3.0).

Turning a vector or an NA into one condition

When the data really can be a vector or can contain NA, reduce it to one definite answer before the if. The helpers are any() and all() (with na.rm), isTRUE() and isFALSE() (which are never NA and never longer than one), the short-circuit operators && and ||, and an explicit length() guard. stopifnot() is the right tool when the condition is a requirement rather than a branch. The help pages for the logical operators explain that & and | work element by element and && and || take length-one operands and evaluate their right-hand side only if necessary (R documentation: Logic).

# The usual ways to turn a vector or an NA into a single TRUE or FALSE.
x <- c(3, NA, 8)
any(x > 5)
#> [1] TRUE
any(x > 5, na.rm = TRUE)
#> [1] TRUE
all(x > 5)
#> [1] FALSE
all(x > 5, na.rm = TRUE)
#> [1] FALSE
isTRUE(NA)
#> [1] FALSE
isTRUE(c(TRUE, TRUE))
#> [1] FALSE
isFALSE(FALSE)
#> [1] TRUE

# isTRUE() and isFALSE() are never NA and never longer than one.
is_big <- function(v) {
  if (isTRUE(v > 5)) "big" else "not clearly big"
}
is_big(8)
#> [1] "big"
is_big(NA)
#> [1] "not clearly big"
is_big(c(8, 9))
#> [1] "not clearly big"

# && and || take length-one operands; longer ones are an error since R 4.3.0.
r <- tryCatch(c(TRUE, FALSE) && TRUE, error = conditionMessage)
r
#> [1] "'length = 2' in coercion to 'logical(1)'"
r <- tryCatch(c(TRUE, FALSE) || TRUE, error = conditionMessage)
r
#> [1] "'length = 2' in coercion to 'logical(1)'"

# Short-circuit: the right-hand side is not evaluated when the left decides.
FALSE && stop("never evaluated")
#> [1] FALSE
TRUE || stop("never evaluated")
#> [1] TRUE
NA && FALSE
#> [1] FALSE
NA || TRUE
#> [1] TRUE
NA && TRUE
#> [1] NA

# The vector operators are element-wise and never short-circuit.
c(TRUE, FALSE, NA) & c(TRUE, TRUE, TRUE)
#> [1]  TRUE FALSE    NA
c(TRUE, FALSE, NA) | c(FALSE, FALSE, FALSE)
#> [1]  TRUE FALSE    NA

# Guard the length first so that the condition always has length one.
check <- function(v) {
  if (length(v) == 1L && !is.na(v) && v > 5) "scalar above 5" else "no"
}
check(8)
#> [1] "scalar above 5"
check(NA)
#> [1] "no"
check(numeric(0L))
#> [1] "no"
check(c(8, 9))
#> [1] "no"

# stopifnot() states a requirement and stops with a readable message.
res <- tryCatch(stopifnot(length(x) == 1L), error = conditionMessage)
res
#> [1] "length(x) == 1L is not TRUE"
res <- tryCatch(
  stopifnot("x must have length 1" = length(x) == 1L),
  error = conditionMessage
)
res
#> [1] "x must have length 1"

Two details of the script are worth remembering. NA && FALSE is FALSE and NA || TRUE is TRUE: the result is NA only when the answer depends on the missing value, which makes && and || safer than & and | as the guard of a condition. And stopifnot() can carry a message in the name of the expression (R documentation: stopifnot); the Defensive Programming and Input Validation page uses it as the standard way to validate arguments.

The %||% operator, added to base R in 4.4.0, is a one-line shorthand for the common branch if (is.null(x)) y else x; it is defined in the Control help page (R documentation: Control) and listed in the NEWS for R 4.4.0 (R NEWS: Changes in R 4.4.0).

ifelse(): the vectorised conditional

ifelse(test, yes, no) is the vectorised form of if/else. The introduction manual describes it as a vectorised version of the if/else construct that returns a vector of the same length as the condition, with an element of yes where the condition is true and of no where it is false, both recycled as necessary (An Introduction to R: Conditional execution). The current help page puts it more precisely: ifelse returns a value with the same shape as test, filled with elements selected from yes or no (R documentation: ifelse). The details follow from that sentence, and the script shows each of them.

  • Missing values in test give missing values in the result.

  • yes and no are recycled when they are too short, and yes is evaluated if and only if some element of test is true (and likewise for no).

  • The result has the length and the attributes of test, including names, dim, dimnames and class, and it never takes them from yes or no.

  • The type of the result is the type of the logical vector test coerced to accommodate first the values taken from yes and then those taken from no, so it depends on the values in test, not only on the types of the branches.

  • test alone decides the length of the result, so a length-one test gives a length-one result however long yes and no are.

# ifelse() is the vectorised if/else: the result has the shape of `test`.
x <- c(6, -1, 0, NA, 3)
ifelse(x > 0, "positive", "not positive")
#> [1] "positive"     "not positive" "not positive" NA             "positive"

# A missing value in test gives a missing value in the result.
ifelse(c(TRUE, NA, FALSE), 1L, 2L)
#> [1]  1 NA  2

# yes and no are recycled when they are too short.
ifelse(c(TRUE, FALSE, TRUE, FALSE), c("a", "b"), "z")
#> [1] "a" "z" "a" "z"

# The type of the result depends on which branches were used.
str(ifelse(c(TRUE, TRUE), 1L, "none"))
#>  int [1:2] 1 1
str(ifelse(c(FALSE, FALSE), 1L, "none"))
#>  chr [1:2] "none" "none"
str(ifelse(c(TRUE, FALSE), 1L, "none"))
#>  chr [1:2] "1" "none"
str(ifelse(c(TRUE, TRUE), 1L, 2.5))
#>  int [1:2] 1 1
str(ifelse(c(FALSE, FALSE), 1L, 2.5))
#>  num [1:2] 2.5 2.5

# test alone determines the length: yes and no are not used for their length.
yes <- 1:3
no <- pi^(1:4)
str(ifelse(NA, yes, no))
#>  logi NA
str(ifelse(TRUE, yes, no))
#>  int 1
str(ifelse(FALSE, yes, no))
#>  num 3.14

# Attributes come from test (names, dim, dimnames, class), not from yes or no.
named <- c(a = 1, b = -2, c = 3)
ifelse(named > 0, "up", "down")
#>      a      b      c
#>   "up" "down"   "up"
m <- matrix(c(1, -1, 2, -2), nrow = 2L)
ifelse(m > 0, m, 0)
#>      [,1] [,2]
#> [1,]    1    2
#> [2,]    0    0
f <- factor(c("x", "y", "x"))
ifelse(f == "x", 1, 2)
#> [1] 1 2 1
ifelse(f == "x", f, NA)
#> [1]  1 NA  1

# A named test gives a named result.
test <- c(first = TRUE, second = FALSE)
ifelse(test, "yes", "no")
#>  first second
#>  "yes"   "no"

# yes is only evaluated if some element of test is TRUE.
ifelse(FALSE, stop("not evaluated"), "no")
#> [1] "no"
r <- tryCatch(
  ifelse(c(TRUE, FALSE), stop("evaluated"), "no"),
  error = conditionMessage
)
r
#> [1] "evaluated"

# ifelse() on dates drops the class: the pitfall from its help page.
d <- as.Date(c("2026-06-01", "2026-06-15", "2026-06-30"))
ifelse(d > as.Date("2026-06-10"), d, NA)
#> [1]    NA 20619 20634
ifelse(d > as.Date("2026-06-10"), d, NA) |> as.Date()
#> [1] NA           "2026-06-15" "2026-06-30"
d2 <- d
d2[d <= as.Date("2026-06-10")] <- NA
d2
#> [1] NA           "2026-06-15" "2026-06-30"

Three results in the script are traps that the help page warns about.

  • The type of the result depends on the data. ifelse(c(TRUE, TRUE), 1L, "none") is an integer vector, but changing one value of test gives a character vector, because only then is a string taken from no. A function that returns ifelse() can therefore return different types on different inputs.

  • The attributes come from test. A factor passed as yes loses its levels and comes back as integer codes (ifelse(f == "x", f, NA)), and a Date passed as yes comes back as a number of days.

  • The class of test is kept. With a test of class factor or Date the result would carry that class even if the values do not suit it; test is almost always a plain logical vector, so this rarely shows.

The help page works the date case through: restoring the class afterwards is one repair, and an assignment on a copy (the last lines of the script) is the version it calls more efficient and still clear. It also notes that if (test) yes else no is much more efficient than ifelse(test, yes, no) whenever test is a simple true/false result, that is, when length(test) == 1. In that case ifelse() is simply the wrong tool, and if also evaluates only the branch it uses.

ifelse() on the shelf: classifying overdue loans

A loan is overdue when it was kept more than loan_days (21) days; a loan that is still out is measured up to shelf_today. The loan log has two rows that cannot be classified: one loan has the impossible date 2025-02-30, which as.Date() turns into NA, and one was returned three days before it was made. Nested ifelse() calls express the three outcomes; assigning in order on a copy gives the same answer. The first step of the script uses ifelse() on the return date, so it meets the date pitfall itself and repairs it with as.Date(), which since R 4.3.0 no longer needs an origin for a numeric argument (R NEWS: Changes in R 4.3.0).

source("scenario.R")

# A loan is overdue when it was kept more than loan_days days. A loan that is
# still out is measured against shelf_today.
end_date <- ifelse(is.na(loans$return_date), shelf_today, loans$return_date)
end_date <- as.Date(end_date)
days_out <- as.integer(end_date - loans$loan_date)

# Some rows cannot be classified: the impossible loan date is NA, and one loan
# was returned before it was made.
status <- ifelse(
  is.na(days_out) | days_out < 0L, "check",
  ifelse(days_out > loan_days, "overdue", "on time")
)
table(status, useNA = "ifany")
#> status
#>   check on time overdue
#>       2      26      32

# The same classification with one expression per case, assigned in order
# (the last assignment that matches wins, so the rare case goes last).
status2 <- rep("on time", nrow(loans))
status2[days_out > loan_days] <- "overdue"
status2[is.na(days_out) | days_out < 0L] <- "check"
identical(status, status2)
#> [1] TRUE

# Which loans need attention?
loans[status == "check", c("loan_id", "loan_date", "return_date")]
#>    loan_id  loan_date return_date
#> 12    L012 2025-04-23  2025-04-20
#> 20    L020       <NA>  2025-07-15

# An NA in days_out stays NA when there is no is.na() branch.
head(ifelse(days_out > loan_days, "overdue", "on time"), 12L)
#>  [1] "on time" "on time" "on time" "overdue" "overdue" "on time" "overdue"
#>  [8] "on time" "overdue" "on time" "overdue" "on time"
sum(is.na(ifelse(days_out > loan_days, "overdue", "on time")))
#> [1] 1

Of the 60 loans, 26 were on time, 32 were overdue (25 returned late and the 7 still out are all past 21 days at shelf_today) and two need a look. The NA loan date stays NA through the plain ifelse(), which is the behaviour the help page documents; deciding what an unclassifiable loan means is a job for the code, and the script gives it its own "check" level instead of letting NA flow through.

Alternatives to nested ifelse()

Deeply nested ifelse() is hard to read and evaluates every branch for the whole vector. R has simpler tools for the common shapes.

# Alternatives to ifelse(): replace(), subsetting, cut() and findInterval().
x <- c(6, -1, 0, NA, 3)
replace(x, x < 0, 0)
#> [1]  6  0  0 NA  3
y <- x
y[!is.na(y) & y < 0] <- 0
y
#> [1]  6  0  0 NA  3

# A single TRUE/FALSE test: use if/else, which evaluates one branch only.
describe <- function(n) {
  if (n == 1L) "one book" else paste(n, "books")
}
describe(1L)
#> [1] "one book"
describe(4L)
#> [1] "4 books"

# Several levels: cut() and findInterval() instead of nested ifelse().
pages <- c(80L, 250L, 312L, 701L)
ifelse(pages < 100L, "short", ifelse(pages < 300L, "medium", "long"))
#> [1] "short"  "medium" "long"   "long"
cut(pages, breaks = c(0, 99, 299, Inf), labels = c("short", "medium", "long"))
#> [1] short  medium long   long
#> Levels: short medium long
findInterval(pages, c(0, 100, 300))
#> [1] 1 2 3 3

# %in% for a membership test.
genres <- c("Fiction", "Mystery", "Poetry", "Classic")
ifelse(genres %in% c("Fiction", "Classic"), "novel shelf", "other shelf")
#> [1] "novel shelf" "other shelf" "other shelf" "novel shelf"

replace() and logical subsetting change selected elements in place (R documentation: replace); cut() turns a numeric vector into a factor of intervals, and findInterval() returns the interval number (R documentation: findInterval). In the tidyverse, dplyr::if_else() is a stricter ifelse() that checks that yes and no have compatible types (so the type no longer depends on the data), keeps the Date class, and has a missing argument for NA; dplyr::case_when() replaces a chain of nested ifelse() calls. if_else() was checked against dplyr 1.2.1, and both are covered in The tidyverse and dplyr; this page sticks to base R.

switch(): choosing among many cases

switch(EXPR, ...) evaluates EXPR and chooses one of the alternatives in .... The language definition notes that technically it is just another function, whose semantics are close to those of the control structures of other languages (The R Language Definition: switch), and the help page says that it is a primitive that evaluates only its first argument and the one alternative that is selected (R documentation: switch). It works in two distinct ways, depending on whether EXPR is a character string or a number.

  • Character selector. The string is matched exactly against the names of the alternatives, with no partial matching. The first matching name wins. If there is no match, the single unnamed alternative, if any, is the default; with no default the result is NULL, invisibly.

  • Fall-through. An alternative written with no value (novel = ,) is missing, so switch() evaluates the next non-missing alternative. The help page’s own example is switch("cc", a = 1, cc =, cd =, d = 2), which gives 2.

  • Numeric selector. The number is coerced to an integer and picks the alternative by position (names are ignored). A number below 1 or above the number of alternatives selects nothing and the result is NULL, invisibly. A numeric selector cannot have a default.

  • Visibility. The result has the visibility of the alternative that was evaluated, so switch("a", a = invisible("hidden")) prints nothing.

  • Errors. More than one unnamed (default) alternative is an error, as is an EXPR that is not of length one. A call with no alternatives gives a warning, because it is usually a coding error.

source("scenario.R")

# switch() with a character selector: exact match on the names.
shelf_for <- function(genre) {
  switch(genre,
    Fiction = "A",
    Mystery = "B",
    Poetry = "C",
    "unshelved"
  )
}
shelf_for("Mystery")
#> [1] "B"
shelf_for("Cookery")
#> [1] "unshelved"

# Fall-through: an empty alternative takes the next non-empty one.
loan_period <- function(kind) {
  switch(kind,
    novel = ,
    classic = ,
    poetry = 21L,
    reference = 0L,
    stop("unknown kind: ", kind)
  )
}
loan_period("novel")
#> [1] 21
loan_period("classic")
#> [1] 21
loan_period("reference")
#> [1] 0
res <- tryCatch(loan_period("map"), error = conditionMessage)
res
#> [1] "unknown kind: map"

# No match and no default: NULL, invisibly.
r <- switch("z", a = 1, b = 2)
is.null(r)
#> [1] TRUE
switch("z", a = 1, b = 2)
withVisible(switch("z", a = 1, b = 2))$visible
#> [1] FALSE

# The result has the visibility of the evaluated alternative.
switch("a", a = invisible("hidden"), b = "shown")
switch("b", a = invisible("hidden"), b = "shown")
#> [1] "shown"

# More than one default (unnamed alternative) is an error.
res <- tryCatch(switch("z", a = 1, 2, 3), error = conditionMessage)
res
#> [1] "duplicate 'switch' defaults: '2' and '3'"

# A numeric selector picks the alternative by position and ignores names.
switch(2L, "first", "second", "third")
#> [1] "second"
switch(3, "first", "second", "third")
#> [1] "third"
switch(2.9, "first", "second", "third")
#> [1] "second"
switch(4L, "first", "second", "third")
is.null(switch(0L, "first", "second", "third"))
#> [1] TRUE
is.null(switch(-1L, "first", "second", "third"))
#> [1] TRUE
for (i in c(-1:3, 9)) {
  print(switch(i, 1, 2, 3, 4))
}
#> NULL
#> NULL
#> [1] 1
#> [1] 2
#> [1] 3
#> NULL

# A numeric selector has no default: an unnamed last alternative is simply the
# alternative at the last position.
switch(4, "first", "second", "third", "default?")
#> [1] "default?"

# A shelf example: the genre labels of the catalogue need trimming first, and
# the rare ones fall to the default.
genre_shelf <- vapply(
  trimws(books$genre),
  \(g) switch(g, Fiction = , fiction = "A", Mystery = "B", Poetry = "C", "Z"),
  character(1L)
)
table(genre_shelf)
#> genre_shelf
#> A B C Z
#> 4 2 2 8

The last block of the script puts switch() where it is most useful, the mapping of a small set of labels to values. The genres in books.csv are untidy ("Fiction " with a trailing blank, "fiction" and "Fiction"), so the labels are trimmed first and two spellings fall through to the same shelf, and anything else goes to the default. A named lookup vector (c(Fiction = "A", Mystery = "B")[[genre]]) is often even better for data that is really a table, because it can be extended without changing code and works on a whole vector with [; use switch() when the alternatives are code to run or when fall-through expresses the rule.

Edge cases and warnings

The corner cases are worth knowing because each one has a real message.

# Corner cases of switch().
try_sw <- function(expr) {
  tryCatch(expr, error = function(e) cat("error:", conditionMessage(e), "\n"),
           warning = function(w) cat("warning:", conditionMessage(w), "\n"))
}

# EXPR must be a length-one vector.
try_sw(switch(c("a", "b"), a = 1, b = 2))
#> error: EXPR must be a length 1 vector
try_sw(switch(character(0L), a = 1, b = 2))
#> error: EXPR must be a length 1 vector
try_sw(switch(NULL, a = 1))
#> error: EXPR must be a length 1 vector

# NA as a selector.
is.null(switch(NA_character_, a = 1, 2))
#> [1] FALSE
switch(NA_character_, a = 1, "default")
#> [1] "default"
switch("NA", "NA" = "the string NA", "default")
#> [1] "the string NA"
is.null(switch(NA_integer_, 1, 2))
#> [1] TRUE

# No alternatives: a warning.
try_sw(switch("a"))
#> warning: 'switch' with no alternatives

# A factor is coerced to its integer codes, with a warning.
ff <- factor(c("B", "A"), levels = c("B", "A"))
try_sw(switch(ff[1L], A = "I am A", B = "I am B"))
#> warning: EXPR is a "factor", treated as integer.
#>  Consider using 'switch(as.character( * ), ...)' instead.
suppressWarnings(switch(ff[1L], A = "I am A", B = "I am B"))
#> [1] "I am A"
switch(as.character(ff[1L]), A = "I am A", B = "I am B")
#> [1] "I am B"

# Partial matching of the names does not happen: the match is exact.
switch("pro", program = "p", "no match")
#> [1] "no match"
switch("program", program = "p", "no match")
#> [1] "p"

# Only the selected alternative is evaluated.
switch("a", a = "fine", b = stop("never evaluated"))
#> [1] "fine"

# A name written with backticks or quotes can hold any string.
switch("long name", "long name" = 1, `other name` = 2)
#> [1] 1

# If the selector is named it must match EXPR; after it the rest is `...`.
switch(EXPR = "b", a = 1, b = 2)
#> [1] 2

# switch() inside a function: the selector is usually an argument checked with
# match.arg(), which turns a typo into an error at the top of the function.
centre <- function(x, type = c("mean", "median")) {
  type <- match.arg(type)
  switch(type,
    mean = mean(x),
    median = median(x)
  )
}
centre(c(1, 2, 10))
#> [1] 4.333333
centre(c(1, 2, 10), "median")
#> [1] 2
res <- tryCatch(centre(c(1, 2, 10), "mode"), error = conditionMessage)
res
#> [1] "'arg' should be one of “mean”, “median”"
  • EXPR must be a length 1 vector covers a vector, character(0) and NULL; switch() is not vectorised, so use vapply() or ifelse() for a vector of selectors.

  • A factor selector is treated as its integer codes, with a warning whose text recommends switch(as.character(*), ...). This is the classic bug: the first level is level 1, so switch(ff[1], A = ..., B = ...) picks the alternative by position, not by name. Convert with as.character() first, as the last line of that block does.

  • The selector and the names are compared exactly. switch("pro", program = "p", "no match") does not match, in contrast with argument matching of function calls (see Functions).

  • An NA_character_ selector matches no name and so takes the default; an NA_integer_ selector selects nothing. Neither is an error, so a missing value is silently treated as "other" unless you test for it first.

  • Always put EXPR first and name it when partial matching is possible, and give the character form a single unnamed default after the named values: the help page recommends both for clarity and compatibility.

  • match.arg() is the standard companion when the selector is an argument of a function: it checks the value against the allowed choices at the top of the function and gives a clear error (R documentation: match.arg). Its message uses curly quotes in a UTF-8 locale, as the last line of the script shows.

Loops

R has three statements for explicit looping, for, while and repeat, and two statements that control them, break and next; the language definition points out that R also provides implicit looping through tapply, apply and lapply, and that many operations, especially arithmetic ones, are vectorised, so a loop may not be needed (The R Language Definition: Looping). The value of a loop statement is always NULL, returned invisibly. All three loops take a single expression as the body, which in practice is a braced block.

for

The syntax is for (name in vector) statement, where the vector can also be a list (The R Language Definition: for). For each element the variable is set to that element and the body is evaluated. The help page for Control adds the precise rules, which the script confirms.

  • The sequence is evaluated once, at the start of the loop. Changing it afterwards does not affect the loop. If it has length zero the body is skipped.

  • The variable is assigned in turn the value of each element. Assigning to it inside the body does not affect the next iteration.

  • When the loop terminates the variable remains as a variable containing its latest value. After a loop over a sequence of length zero it is NULL. The variable lives in the environment where the loop runs, not in a new scope: this is difference 3.3.3 between R and S in the R FAQ, where the index of a for loop is local to the loop (R FAQ 3.3.3: Others).

  • The sequence is implicitly unclassed. For classed objects, the help page suggests as.list() to prepare a list to iterate over, or seq_along() to get an index vector and then extract the elements explicitly. A factor is coerced to a character vector, and the sequence can be a long vector (supported in for since R 4.0.0, see the NEWS entry in R NEWS: Changes in R 4.0.0).

source("scenario.R")

# for over a vector: the loop variable takes each element in turn.
for (n in c(2L, 5L, 10L)) {
  cat(n, "squared is", n^2, "\n")
}
#> 2 squared is 4
#> 5 squared is 25
#> 10 squared is 100

# for over a list: each element, whatever its type.
shelf <- list(title = "The Quiet Streets", pages = 312L, in_stock = TRUE)
for (item in shelf) {
  print(class(item))
}
#> [1] "character"
#> [1] "integer"
#> [1] "logical"

# for over names, then index the object by name.
for (nm in names(shelf)) {
  cat(nm, "->", format(shelf[[nm]]), "\n")
}
#> title -> The Quiet Streets
#> pages -> 312
#> in_stock -> TRUE

# for over a position vector made by seq_along(): needed when the index matters.
titles <- c("The Quiet Streets", "Rivers of Glass", "Orchard Logic")
for (i in seq_along(titles)) {
  cat(i, ":", titles[[i]], "\n")
}
#> 1 : The Quiet Streets
#> 2 : Rivers of Glass
#> 3 : Orchard Logic

# for over the first rows of a data frame.
for (i in seq_len(3L)) {
  cat(books$title[[i]], "-", books$pages[[i]], "pages\n")
}
#> The Dispossessed - 387 pages
#> Pride and Prejudice - 432 pages
#>   the quiet streets  - 214 pages

# Zero iterations are fine.
for (i in seq_len(0L)) {
  print("never printed")
}
for (x in NULL) {
  print("never printed")
}
for (x in character(0L)) {
  print("never printed")
}

# for evaluates its sequence once: changing the vector does not change the loop.
v <- 1:3
for (x in v) {
  v <- c(v, x * 10L)
  cat("x =", x, "length(v) =", length(v), "\n")
}
#> x = 1 length(v) = 4
#> x = 2 length(v) = 5
#> x = 3 length(v) = 6
v
#> [1]  1  2  3 10 20 30

# Assigning to the loop variable does not affect the next iteration.
for (i in 1:3) {
  cat("i before:", i)
  i <- i * 100L
  cat(", i after:", i, "\n")
}
#> i before: 1, i after: 100
#> i before: 2, i after: 200
#> i before: 3, i after: 300

# After the loop the variable keeps the last value it had.
for (k in c("a", "b", "c")) {
  last_seen <- k
}
k
#> [1] "c"
last_seen
#> [1] "c"

# After an empty loop the variable is NULL (the help page: "or to NULL if it was
# of length zero").
for (z in integer(0L)) {
  NULL
}
z
#> NULL

The script uses the idioms to remember. Looping over a vector gives the elements; looping over names(x) and indexing with x[[nm]] gives name and value; looping over seq_along(x) gives positions when the index matters; and looping over seq_len(nrow(df)) visits the rows of a data frame. The version of the same loops over 1:length(x) or 1:nrow(df) is the next trap.

seq_along() and seq_len() versus 1:length(x)

1:length(x) is the idiom of older tutorials, and the introduction manual itself uses 1:length(yc) in its worked example of for (An Introduction to R: Repetitive execution). It is correct whenever x is known to be non-empty. When x is empty it counts down: 1:0 is c(1, 0), so a loop over it runs twice, with i equal to 1 and then 0, indexing out of range and, if the body assigns, writing to the wrong element. seq_along(x) and seq_len(n) return an empty integer vector for zero length, so the loop body is skipped. They are described, together with seq(), in the seq help page (R documentation: seq).

# The empty-vector trap: 1:length(x) counts down when x is empty.
x <- numeric(0L)
1:length(x) # nolint: seq_linter.
#> [1] 1 0
seq_along(x)
#> integer(0)

# With an empty vector the loop below runs twice and indexes out of range.
for (i in 1:length(x)) { # nolint: seq_linter.
  cat("i =", i, " x[i] =", x[i], "\n")
}
#> i = 1  x[i] = NA
#> i = 0  x[i] =
for (i in seq_along(x)) {
  cat("never printed\n")
}

# A data frame with zero rows.
empty <- data.frame(title = character(0L))
nrow(empty)
#> [1] 0
for (i in 1:nrow(empty)) { # nolint: seq_linter.
  cat("row", i, "\n")
}
#> row 1
#> row 0
for (i in seq_len(nrow(empty))) {
  cat("never printed\n")
}

# seq_len(), seq_along() and 1:n are integer; seq() with by = is double, and
# seq(x) with a length-one x means 1:x; with a longer x it is seq_along(x).
typeof(seq_len(3L))
#> [1] "integer"
typeof(seq_along(c("a", "b")))
#> [1] "integer"
typeof(1:3)
#> [1] "integer"
typeof(seq(2, 8, by = 2))
#> [1] "double"
seq(5)
#> [1] 1 2 3 4 5
seq(c(5))
#> [1] 1 2 3 4 5
seq(c(5, 6))
#> [1] 1 2

# for unclasses its sequence: dates become numbers and factors become character.
d <- as.Date(c("2026-06-01", "2026-06-15"))
for (day in d) {
  print(day)
}
#> [1] 20605
#> [1] 20619
for (day in as.list(d)) {
  print(day)
}
#> [1] "2026-06-01"
#> [1] "2026-06-15"
for (i in seq_along(d)) {
  print(d[[i]])
}
#> [1] "2026-06-01"
#> [1] "2026-06-15"
f <- factor(c("low", "high", "low"), levels = c("low", "high"))
for (lev in f) {
  print(lev)
}
#> [1] "low"
#> [1] "high"
#> [1] "low"
for (lev in levels(f)) {
  print(lev)
}
#> [1] "low"
#> [1] "high"
for (lev in as.integer(f)) {
  print(lev)
}
#> [1] 1
#> [1] 2
#> [1] 1

# A loop over a data frame iterates over its columns, not its rows.
df <- data.frame(a = 1:2, b = c("x", "y"))
for (col in df) {
  print(col)
}
#> [1] 1 2
#> [1] "x" "y"
for (nm in names(df)) {
  cat(nm, class(df[[nm]]), "\n")
}
#> a integer
#> b character

Remember these rules from the script.

  • Use seq_along(x) to loop over the positions of a vector or list, seq_len(n) to loop n times, and seq_len(nrow(df)) for the rows of a data frame. the default seq_linter of lintr reports 1:length(x), 1:nrow(x) and seq_len(length(x)).

  • seq(x) is not a safe shortcut: with a length-one x it means 1:x, so seq(n) with n = 0 gives c(1, 0), the same trap.

  • for (day in dates) gives numbers, not dates, because the sequence is unclassed. Loop over seq_along(dates) and index with [[, or over as.list(dates).

  • for (col in df) visits the columns of a data frame, not its rows.

  • A loop over a factor visits the elements as their character labels, not as integer codes; use as.integer(f) for the codes.

while, repeat, break and next

while (cond) body tests the condition before each iteration, so the body may never run. repeat body has no condition and runs until a break (or an error, or a return() inside a function); the language definition notes that you need to be careful because of the danger of an infinite loop, and that the body of a repeat has to be a block, because you usually need two statements, the computation and the test (The R Language Definition: repeat and The R Language Definition: while). break leaves the innermost loop at once, and next abandons the rest of the current iteration and moves to the next one; both apply only to the innermost of nested loops (R documentation: Control).

# while: the condition is tested before every iteration.
copies <- 0L
while (copies < 3L) {
  copies <- copies + 1L
  cat("printed copy", copies, "\n")
}
#> printed copy 1
#> printed copy 2
#> printed copy 3
copies
#> [1] 3

# The body may never run.
n <- 10L
while (n < 5L) {
  n <- n + 1L
}
n
#> [1] 10

# repeat: an endless loop that ends only with break (or an error or return()).
attempt <- 0L
repeat {
  attempt <- attempt + 1L
  cat("attempt", attempt, "\n")
  if (attempt >= 3L) {
    break
  }
}
#> attempt 1
#> attempt 2
#> attempt 3

# next skips the rest of the body and goes to the next iteration.
for (i in 1:6) {
  if (i %% 2L == 0L) {
    next
  }
  cat(i, "")
}
#> 1 3 5
cat("\n")
#>

# break leaves only the innermost loop.
for (i in 1:3) {
  for (j in 1:3) {
    if (j == 2L) {
      break
    }
    cat("i =", i, "j =", j, "\n")
  }
}
#> i = 1 j = 1
#> i = 2 j = 1
#> i = 3 j = 1

# while (TRUE) and repeat are the same thing.
count <- 0L
while (TRUE) {
  count <- count + 1L
  if (count == 4L) {
    break
  }
}
count
#> [1] 4

# A while loop on floating-point numbers: ten additions of 0.1 are not exactly 1
# (R FAQ 7.31), so `!=` or `<` against 1 may run once too often or forever.
x <- 0
for (i in 1:10) {
  x <- x + 0.1
}
x == 1
#> [1] FALSE
sprintf("%.17g", x)
#> [1] "0.99999999999999989"
isTRUE(all.equal(x, 1))
#> [1] TRUE

# A guard counter keeps a loop that tests `!=` from running forever.
y <- 0
guard <- 0L
while (y != 1 && guard < 15L) {
  y <- y + 0.1
  guard <- guard + 1L
}
guard
#> [1] 15

# Compare with a tolerance instead.
z <- 0
steps <- 0L
while (!isTRUE(all.equal(z, 1)) && steps < 15L) {
  z <- z + 0.1
  steps <- steps + 1L
}
steps
#> [1] 10

The last part of the script is a while loop on floating-point numbers. Ten additions of 0.1 do not give exactly 1, so a loop that tests != against 1 never stops on its own and a loop that tests < against 1 runs once too often. The R FAQ entry on this is R FAQ 7.31: Why doesn’t R think these numbers are equal? Compare with all.equal(), wrapped in isTRUE(), or use a tolerance, and give a while (TRUE) or repeat loop a counter as a guard so that a mistake becomes an error instead of an endless loop.

A loop that stops at the first bad value

A search that stops at the first hit is where break earns its place, and next expresses "skip this one" without nesting. In the shelf scenario, the loan log has one ISBN with a missing digit; is_valid_isbn13() is the check from the scenario.

source("scenario.R")

# Stop at the first invalid ISBN in the loan log.
first_bad <- NA_integer_
for (i in seq_len(nrow(loans))) {
  if (!is_valid_isbn13(loans$isbn[[i]])) {
    first_bad <- i
    break
  }
}
first_bad
#> [1] 5
loans[first_bad, c("loan_id", "isbn")]
#>   loan_id         isbn
#> 5    L005 978030640615
i
#> [1] 5

# The loop variable keeps its value after the break, so the search needs no
# extra variable: but the value is only meaningful if the loop did break.
found <- FALSE
for (i in seq_len(nrow(loans))) {
  if (!is_valid_isbn13(loans$isbn[[i]])) {
    found <- TRUE
    break
  }
}
if (found) cat("first invalid ISBN at row", i, "\n")
#> first invalid ISBN at row 5

# next skips loans that have been returned; the loop counts the ones still out.
still_out <- 0L
for (i in seq_len(nrow(loans))) {
  if (!is.na(loans$return_date[[i]])) {
    next
  }
  still_out <- still_out + 1L
}
still_out
#> [1] 7

# The vectorised answers to the same questions.
which(!is_valid_isbn13(loans$isbn))[1L]
#> [1] 5
sum(is.na(loans$return_date))
#> [1] 7

The loop variable keeps the value it had at the break, which is what lets the script report row 5 without an extra variable. Remember, though, that it also keeps its last value when the loop ends without a break, so the script uses a found flag when "not found" is a possible answer. The last two lines show the vectorised answers to the same questions: which(...)[1] is NA when nothing is found, and sum(is.na(...)) counts the missing dates. The loop earns its keep when the test is expensive and stopping early saves work, or when the iterations depend on each other.

break, next and return

break and next are lexically tied to a loop. Used outside one, or inside a function called from a loop, they fail with no loop for break/next, jumping to top level. That is why the body of the function passed to vapply() or lapply() cannot use next: it is not a loop, it is a function. Return the value for the element instead (NA, an empty value, or a flag you filter on). return() leaves the whole function, even from inside a loop (see Functions), and a loop that is the last expression of a function makes the function return NULL invisibly. Recall() is the base function that calls the function currently running, a way to write a loop as recursion (R documentation: Recall).

# break and next are only meaningful inside a loop.
res <- tryCatch(eval(quote(break)), error = conditionMessage)
res
#> [1] "no loop for break/next, jumping to top level"
res <- tryCatch(eval(quote(next)), error = conditionMessage)
res
#> [1] "no loop for break/next, jumping to top level"

# They cannot cross a function boundary: the body of an anonymous function
# passed to a loop-like function is not a loop.
res <- tryCatch(
  vapply(1:3, function(i) {
    if (i == 2L) next
    i
  }, integer(1L)),
  error = conditionMessage
)
res
#> [1] "no loop for break/next, jumping to top level"

# return() leaves the whole function, even from inside a loop.
first_long <- function(pages, limit) {
  for (i in seq_along(pages)) {
    if (!is.na(pages[[i]]) && pages[[i]] > limit) {
      return(i)
    }
  }
  NA_integer_
}
first_long(c(214L, NA, 387L, 432L), 300L)
#> [1] 3
first_long(c(214L, NA), 300L)
#> [1] NA

# A loop as the last expression of a function returns NULL, invisibly.
report <- function(x) {
  for (v in x) {
    cat("value", v, "\n")
  }
}
out <- report(1:2)
#> value 1
#> value 2
is.null(out)
#> [1] TRUE
report(1:2)
#> value 1
#> value 2

# Recall() calls the function that is running, a way to loop with recursion.
countdown <- function(n) {
  if (n == 0L) {
    return("liftoff")
  }
  cat(n, "\n")
  Recall(n - 1L)
}
countdown(3L)
#> 3
#> 2
#> 1
#> [1] "liftoff"

Loop values and preallocation

A loop is run for its side effects. Because its value is always NULL, invisibly, x <- for (...) ... stores NULL, and a result has to be collected in a variable that is updated inside the loop. How it is collected decides the cost.

  • Growing with c() (out <- c(out, value)) allocates a new vector and copies all the existing elements on every iteration, so the total work grows with the square of the length.

  • Preallocating (out <- numeric(n), then out[[i]] <- value) allocates once and fills by position. The constructors numeric(n), integer(n), character(n) and logical(n) give vectors of the right type filled with 0, 0L, "" and FALSE; vector("list", n) gives a list of n NULL values, and vector(mode, n) is the general form (R documentation: vector).

  • Assigning past the end grows a vector, filling the gap with NA. Since R 3.4.0 this over-allocates by a small fraction, so building a vector by assigning to the next element is cheaper than c(), but the NEWS entry still says that preallocating is preferred (R NEWS: Changes in R 3.4.0). It also hides off-by-one bugs, as the last lines of the script show.

  • For results that are data frames or other objects, fill a list with vector("list", n) and combine once at the end with do.call(rbind, parts) (R documentation: do.call), instead of calling rbind() inside the loop.

source("scenario.R")

# A loop returns NULL, invisibly: it cannot be used as an expression.
res <- for (i in 1:3) {
  i
}
is.null(res)
#> [1] TRUE
withVisible(for (i in 1:3) i)
#> $value
#> NULL
#>
#> $visible
#> [1] FALSE
#>
res <- while (FALSE) 1
is.null(res)
#> [1] TRUE

# A loop is used for its side effects, so results must be stored explicitly.
# Growing a vector with c() copies it at every step:
squares <- c()
for (i in 1:5) {
  squares <- c(squares, i^2)
}
squares
#> [1]  1  4  9 16 25

# Preallocate the result and fill it by position:
squares <- numeric(5L)
for (i in 1:5) {
  squares[[i]] <- i^2
}
squares
#> [1]  1  4  9 16 25

# The constructors for the common types.
numeric(3L)
#> [1] 0 0 0
integer(3L)
#> [1] 0 0 0
character(3L)
#> [1] "" "" ""
logical(3L)
#> [1] FALSE FALSE FALSE
vector("list", 2L)
#> [[1]]
#> NULL
#>
#> [[2]]
#> NULL
#>
vector("numeric", 2L)
#> [1] 0 0
vector("character", 2L)
#> [1] "" ""

# Fill a list by position, then combine once at the end.
parts <- vector("list", 3L)
for (i in seq_along(parts)) {
  parts[[i]] <- data.frame(id = i, square = i^2)
}
do.call(rbind, parts)
#>   id square
#> 1  1      1
#> 2  2      4
#> 3  3      9

# A shelf example: days out for each loan, filled by position.
days_out <- integer(nrow(loans))
for (i in seq_len(nrow(loans))) {
  returned <- loans$return_date[[i]]
  end <- if (is.na(returned)) shelf_today else returned
  days_out[[i]] <- as.integer(end - loans$loan_date[[i]])
}
head(days_out, 10L)
#>  [1] 21 10  1 26 26 17 24 10 31 21

# The vectorised form gives the same numbers in one expression.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_vec <- as.integer(end_date - loans$loan_date)
identical(days_out, days_vec)
#> [1] TRUE

# The same with vapply(), which returns the values instead of assigning them.
days_apply <- vapply(seq_len(nrow(loans)), function(i) {
  returned <- loans$return_date[[i]]
  end <- if (is.na(returned)) shelf_today else returned
  as.integer(end - loans$loan_date[[i]])
}, integer(1L))
identical(days_out, days_apply)
#> [1] TRUE

# An element past the end silently grows a vector, which hides off-by-one bugs.
v <- numeric(3L)
v[[5L]] <- 1
v
#> [1]  0  0  0 NA  1
length(v)
#> [1] 5

The script computes the number of days each loan was out three ways: a preallocated loop, one vectorised expression, and vapply(), and checks that they agree with identical(). Timing the variants is the job of Performance, Profiling and Benchmarking, and the copying behind the difference is explained in Memory and Copy Semantics; the point here is that the preallocated loop and the vectorised version both avoid the repeated copy.

When loops are fine

An explicit loop is not wrong in R; it is just not the first tool to reach for. The practical rule is the one the language definition hints at: prefer a vectorised operation when one exists (arithmetic, comparison, ifelse(), sum(), cumsum(), subsetting) and an apply-family function or a purrr-style map when you only need the values, and use an explicit for loop when it is the clearer way to say it:

  • the iterations depend on each other (each step uses the previous result: a running balance, a simulation, a search that stops, a convergence test);

  • the work per iteration is large and not vectorisable (reading a file, calling an API, fitting a model), so the loop overhead is negligible;

  • the loop is run for its side effects (printing, writing files, drawing plots, updating a database);

  • the clarity matters more than the speed: a short loop over a handful of items is easier to read than a clever vectorised one.

When the loop does nothing but compute one value per element, vapply() is the safer replacement: it checks the type and length of every result and returns a vector, which sapply() does not guarantee. See The apply Family and Functionals and Vectorisation and Iteration for the vectorised and functional styles in depth, and the section on preallocation above for the cost of building a result in a loop.

Version notes

Version Change

R 3.4.0

Assigning to an element past the end of a vector over-allocates, making growth by assignment cheaper; preallocation is still preferred.

R 4.0.0

Long vectors are supported as the sequence of a for loop.

R 4.2.0

if() and while() with a condition of length greater than one are an error instead of a warning; _R_CHECK_LENGTH_1_CONDITION_ has no effect; && and || with an operand longer than one give a warning.

R 4.3.0

&& and || with an operand longer than one are always an error; as.Date() of a number no longer needs origin.

R 4.4.0

The base operator %||% is added.

Where older tutorials differ

Many pages on the web predate these changes. When one of them disagrees with this page, the table says why.

Older tutorial Current practice Why Version

if (c(TRUE, FALSE)) "uses the first element" (also the R-lang text)

reduce the condition with any(), all() or isTRUE()

it is an error: the condition has length greater than one

R 4.2.0

c(TRUE, FALSE) && TRUE works with a warning or silently

use a length-one operand

it is an error: 'length = 2' in coercion to 'logical(1)'

R 4.2.0 warning, R 4.3.0 error

else on its own line at the top level of a script

} else { on the closing-brace line

the newline ends the if, so unexpected 'else'

always

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

seq_along(x), seq_len(nrow(df))

1:0 counts down, so an empty input runs the body twice

always

for (d in dates) expecting dates

loop over seq_along(dates) or as.list(dates)

the sequence is unclassed, so Date becomes a number

always

ifelse() for a single TRUE/FALSE

if/else

it is more efficient, evaluates one branch, and keeps the class and length of yes and no

always

ifelse() on dates or factors

assignment on a copy, or dplyr::if_else()

the result takes the attributes from test and drops the class of yes and no

always

switch(factor, ...)

switch(as.character(f), ...)

a factor is used through its integer codes, with a warning

always

result <- c(); for (...) result <- c(result, x)

result <- vector("list", n) or numeric(n) and fill by position, or vapply()

growing by c() copies the vector at every step

always

sapply() over the indices to collect values

vapply() with a type template

sapply() can return a list or a matrix when the shapes vary

always

while (x != 1) on computed numbers

compare with all.equal(), use a tolerance and a guard counter

floating-point results are not exact (R FAQ 7.31)

always

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

Where to go next

References