Idioms and Common Pitfalls

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 utils packages that ship with R) except two add-on packages that are named where they appear: withr 3.0.3, with one call in the pitfall about the working directory, and lintr 3.4.0, which also checked every script and is shown linting a few lines of old-style code. R is forgiving in ways that are convenient in an interactive session and costly in a program: a name is completed for you, a vector is silently recycled, a one-column result becomes a vector, a loop over nothing runs twice. Most of the bugs that come from this are not exotic. They are the same few, in the same places, and the R FAQ, the manuals and the style guides have been describing them for decades. This page is the short list of habits that avoid them (ten idioms) and a catalogue of sixteen pitfalls, each shown failing with R’s own output, fixed, tied to its R FAQ entry (or marked as having none) and linked to the page of this reference that explains the concept in depth.

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 bad idiom on show is exactly what a linter dislikes, so the linter does not hide the lesson. Errors and warnings that a demonstration provokes are caught with tryCatch() and only the message is shown, so every message quoted here is R’s own text, with the typographic quotes it prints in a UTF-8 locale. 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"), and books.csv is read with read.csv("books.csv"). Paths are never printed, and the one place where a script changes the working directory, in the pitfall about global state, restores it before the script ends.

This is a summary page: it repeats nothing that a concept page explains, and every section ends with the link to the page that does. Three further conventions hold. The page never recommends a bad idiom: T, F, 1:length(x), sapply(), attach() and the working-directory setter appear only inside the demonstrations of the pitfalls they cause. The pitfalls are in the order of the outline, not of importance. And chapter 7 of the R FAQ ("R Miscellanea") is the official list of the traps that users ask about; several pages of this reference quote it by number, so the section R FAQ chapter 7 at a glance near the end lists all 44 entries with the page that treats each. The changes between R 4.0.0 and 4.6.x that make some of these traps disappear are collected on What’s New and Migrating Older Code.

The idioms

An idiom is a way of writing something that other R programmers recognise, that does the right thing at the edges, and that a linter can check. Each of the ten below is paired with a pitfall that is what happens without it, and each has an example you can paste into a session.

Vectorise

Arithmetic, comparison and most mathematical functions in R work on whole vectors, and the loop that applies them runs in compiled code. Writing days <- return_date - loan_date instead of a loop over the rows is shorter, has no index to get wrong, and is the form that the other idioms below build on. The script computes the loan lengths of the shelf scenario both ways and shows that they agree, then counts the loans longer than the 21-day period; the count is the reference result of the scenario (25 returned loans exceed 21 days), and na.rm = TRUE leaves out the unreturned loans and the impossible date of row 20, which are NA. A loop is still the right tool when each step depends on the previous one; Vectorisation and Iteration says when, and why the vectorised form is faster.

source("scenario.R")

# Two ways to compute how many days each loan lasted.
days_loop <- numeric(nrow(loans))
for (i in seq_len(nrow(loans))) {
  days_loop[i] <- as.numeric(loans$return_date[i] - loans$loan_date[i])
}

# One expression: subtraction of two Date vectors works on whole vectors.
days <- as.numeric(loans$return_date - loans$loan_date)
identical(days_loop, days)
#> [1] TRUE
head(days, 6L)
#> [1] 21 10  1 26 26 17

# Comparisons and sum() or mean() also work on whole vectors.
sum(days > loan_days, na.rm = TRUE)
#> [1] 25
round(mean(days > loan_days, na.rm = TRUE), 3L)
#> [1] 0.481

seq_along() and seq_len()

When a loop is needed, its index comes from seq_along(x) (the positions of x) or seq_len(n) (the numbers 1 to n), never from 1:length(x) or 1:n. The two functions return integer(0) for an empty input, so the loop body does not run, and the colon operator does not (the first pitfall below). The seq help page says that seq_along and seq_len are very fast primitives for these two common cases and that using them is much preferred to the one-argument form of seq(). The function below numbers a vector of titles; it gives the right answer for three titles and for none, and the same two lines work for the rows of a data frame.

source("scenario.R")

number_titles <- function(x) {
  out <- character(length(x))
  for (i in seq_along(x)) {
    out[[i]] <- sprintf("%d. %s", i, x[[i]])
  }
  out
}

titles <- trimws(books$title)
number_titles(titles[1:3])
#> [1] "1. The Dispossessed"    "2. Pride and Prejudice" "3. the quiet streets"

# An empty input gives an empty result and the loop body never runs.
number_titles(character(0))
#> character(0)

# seq_len() is the same idea for a count such as the rows of a data frame.
seq_len(nrow(books))[1:4]
#> [1] 1 2 3 4
seq_len(0L)
#> integer(0)

The loop styles are compared in Control Flow, and lintr flags the colon form with seq_linter.

vapply() with a declared FUN.VALUE

To apply a function to every element and collect the results in a vector, use vapply(X, FUN, FUN.VALUE). The third argument is a template: the type and length every call must return. If one call returns something else, R stops with a message that names the element. If the input is empty, the result is an empty vector of the declared type. sapply() has neither guarantee and its result changes shape with the data (the sapply pitfall below). A template with names, such as c(min = 0, max = 0), makes vapply() return a matrix whose rows carry the names. When the result is a list on purpose, lapply() says so.

source("scenario.R")

genre <- tolower(trimws(books$genre))
pages_by_genre <- split(books$pages, genre)

# FUN.VALUE says what each call returns: one number.
vapply(pages_by_genre, \(p) mean(p, na.rm = TRUE), numeric(1L))
#>         classic         fiction         mystery     non-fiction          poetry
#>        356.0000        222.6667        272.5000        263.5000         92.0000
#> science fiction
#>        373.0000

# A named template gives a matrix with named rows.
vapply(
  pages_by_genre,
  \(p) range(p, na.rm = TRUE),
  c(min = 0, max = 0)
)
#>     classic fiction mystery non-fiction poetry science fiction
#> min     280     198     244         175     88             322
#> max     432     256     301         352     96             410

# Empty input gives an empty vector of the declared type.
vapply(list(), length, integer(1L))
#> integer(0)
vapply(character(0), nchar, integer(1L))
#> named integer(0)

When X is a character vector, USE.NAMES = TRUE names the result after it, which is why the empty character vector above gives named integer(0); pass USE.NAMES = FALSE when the names are not wanted. The family is described in The apply Family and Functionals.

stopifnot() with named messages

stopifnot() checks that every one of its arguments is TRUE and stops with an error otherwise. Since R 4.0.0 an argument can be named, and the name is used instead of the default message, which is the expression followed by "is not TRUE" (this is in the R 4.0.0 NEWS: the function allows customizing error messages via argument names). The first lines of a function that state what it accepts are the cheapest documentation and the earliest failure. Use stop() with a message of your own, or a classed condition, for errors that the caller is meant to handle.

source("scenario.R")

loan_length <- function(loan_date, return_date) {
  stopifnot(
    "loan_date must be a Date" = inherits(loan_date, "Date"),
    "return_date must be a Date" = inherits(return_date, "Date"),
    "the two dates must have the same length" =
      length(loan_date) == length(return_date)
  )
  as.numeric(return_date - loan_date)
}

loan_length(loans$loan_date[1:3], loans$return_date[1:3])
#> [1] 21 10  1

tryCatch(
  loan_length("2025-01-14", loans$return_date[1L]),
  error = \(e) conditionMessage(e)
)
#> [1] "loan_date must be a Date"
tryCatch(
  loan_length(loans$loan_date[1:3], loans$return_date[1:2]),
  error = \(e) conditionMessage(e)
)
#> [1] "the two dates must have the same length"

More forms (exprs, the checks for type and length, the checkmate package) are in Defensive Programming and Input Validation.

on.exit(add = TRUE)

on.exit() registers an expression to run when the function ends, whether it ends normally or with an error. By default a second call replaces the first; add = TRUE appends, and since R 3.5.0 after = FALSE puts the new expression in front, so that cleanup runs last in, first out. Since R 3.5.0 all the registered expressions also run even if one of them signals an error. The idiom is to write the on.exit() line straight after the line that acquires the resource, with add = TRUE every time, so that a later edit cannot silently cancel it. The same pattern puts back the options a function changed, which is what R FAQ 7.7 shows with options().

# A second on.exit() replaces the first one unless add = TRUE.
replaced <- function() {
  on.exit(cat("first\n"))
  on.exit(cat("second\n"))
  invisible(NULL)
}
replaced()
#> second

added <- function() {
  on.exit(cat("first\n"), add = TRUE)
  on.exit(cat("second\n"), add = TRUE)
  invisible(NULL)
}
added()
#> first
#> second

# after = FALSE puts the new expression first (last in, first out).
lifo <- function() {
  on.exit(cat("opened the file\n"), add = TRUE)
  on.exit(cat("closed the file\n"), add = TRUE, after = FALSE)
  invisible(NULL)
}
lifo()
#> closed the file
#> opened the file

# The cleanup also runs when the function stops with an error.
count_rows <- function(path) {
  con <- file(path, "r")
  on.exit(close(con), add = TRUE)
  stop("something went wrong after opening")
}
connections_before <- nrow(showConnections())
tryCatch(count_rows("books.csv"), error = \(e) conditionMessage(e))
#> [1] "something went wrong after opening"
nrow(showConnections()) == connections_before
#> [1] TRUE

# Options changed inside a function are restored on the way out.
show_pi <- function(digits) {
  old <- options(digits = digits)
  on.exit(options(old), add = TRUE)
  print(pi)
}
show_pi(3L)
#> [1] 3.14
print(pi)
#> [1] 3.141593

The first function prints only second because the second call replaced the first; the second prints first and second in the order of the calls; the third, with after = FALSE, prints closed the file before opened the file. The connection counter compares the number of open connections before and after the failing call, and it is TRUE: the file was closed although the function stopped. See Functions and Conditions and Error Handling.

Explicit namespaces

Write pkg::fun when you use a function from a package, in scripts as well as in package code. A function defined in the global environment (or attached later on the search path) with the same name as a package function hides it, and pkg::fun goes to the package whatever the search path holds. In a package the same intent is written in the NAMESPACE file with importFrom(). The :: operator loads the namespace without attaching it, so it does not change what other code sees.

source("scenario.R")

# A global function with the name of a package function masks it.
head <- function(x, ...) "my own head"
head(books$title)
#> [1] "my own head"
find("head")
#> [1] ".GlobalEnv"    "package:utils"

# pkg::fun says which function is meant, whatever the search path holds.
utils::head(books$title, 2L)
#> [1] "The Dispossessed"    "Pride and Prejudice"
stats::median(books$pages, na.rm = TRUE)
#> [1] 268

# rm() removes the mask again.
rm(head)
find("head")
#> [1] "package:utils"

find("head") lists the places where a name is found, in search order: after the global definition it names .GlobalEnv first. See Packages, Libraries and Namespaces for :: and :::, and masking and conflicts.

TRUE and FALSE

TRUE and FALSE are reserved words, so they cannot be changed. They are written in full everywhere, including the arguments na.rm, decreasing, drop and stringsAsFactors. A logical vector is produced by a comparison and counted with sum() or mean(), because TRUE counts as 1 and FALSE as 0. The pitfall about T and F below is the reason for the rule.

source("scenario.R")

# TRUE and FALSE are written out in full: na.rm = TRUE, decreasing = TRUE.
mean(books$pages, na.rm = TRUE)
#> [1] 268.2143
newest <- books[order(books$year, decreasing = TRUE), "title"]
head(trimws(newest), 3L)
#> [1] "THE LONG TABLE" "Ten Winters"    "Loud Roofs"

# A logical vector is made by a comparison, and counted with sum() or mean().
long <- books$pages > 400
class(long)
#> [1] "logical"
sum(long, na.rm = TRUE)
#> [1] 2

# TRUE and FALSE cannot be reassigned; the next line is an error.
tryCatch(FALSE <- 1, error = \(e) conditionMessage(e))
#> [1] "invalid (do_set) left-hand side to assignment"

An Introduction to R says it in one sentence (An Introduction to R: Logical vectors): T and F are variables that can be overwritten, so you should always use TRUE and FALSE. The styles are in Style, Linting and Static Analysis.

identical(), isTRUE() and all.equal()

Three functions answer "are these the same?", and they answer different questions. identical(x, y) is TRUE only when the two objects are the same in every respect, attributes and type included, and it always returns one TRUE or FALSE. all.equal(x, y) compares numbers with a tolerance (about 1.5e-8) and returns either TRUE or a character string that describes the difference; it can therefore not be used directly in if. isTRUE(x) is TRUE only for a single TRUE, so isTRUE(all.equal(x, y)) is the idiom for "equal within the tolerance", and isTRUE(NA) is a plain FALSE. Since R 3.5.0 isTRUE() also accepts a named logical of length one, and isFALSE() was added at the same time.

x <- c(a = 1, b = 2)
y <- c(a = 1, b = 2)

# identical() compares whole objects, attributes included, and returns one
# TRUE or FALSE.
identical(x, y)
#> [1] TRUE
identical(1L, 1)
#> [1] FALSE
identical(x, unname(y))
#> [1] FALSE

# all.equal() compares with a tolerance, but returns a message when it differs.
all.equal(1L, 1)
#> [1] TRUE
all.equal(1, 1.1)
#> [1] "Mean relative difference: 0.1"

# isTRUE() makes it a condition that is TRUE or FALSE, never a string or NA.
isTRUE(all.equal(1, 1 + 1e-10))
#> [1] TRUE
isTRUE(all.equal(1, 1.1))
#> [1] FALSE
isTRUE(NA)
#> [1] FALSE
isFALSE(NA)
#> [1] FALSE

if (isTRUE(all.equal(0.1 + 0.2, 0.3))) {
  cat("equal within the tolerance\n")
}
#> equal within the tolerance

Never use == for the comparison of computed doubles (see the pitfall below), and never test a condition with if (x == NA); is.na() is the question. See Defensive Programming and Input Validation.

Early returns

A function that checks its special cases first and returns at once reads top to bottom and has no staircase of nested if and else. Each return() handles one case; the last expression of the function is the ordinary case, and it is not written with return() (the return_linter of lintr flags that). The function below classifies a loan from its two dates, NA meaning "not returned", and the table counts the 60 loans of the scenario by status: the impossible date, the impossible return before the loan and the unreturned loans each have their own early exit.

source("scenario.R")

loan_status <- function(loan_date,
                        return_date,
                        today = shelf_today) { # nolint: object_usage_linter.
  if (is.na(loan_date)) {
    return("invalid date")
  }
  if (is.na(return_date)) {
    if (today - loan_date > loan_days) { # nolint: object_usage_linter.
      return("out and overdue")
    }
    return("out")
  }
  if (return_date < loan_date) {
    return("returned before it was loaned")
  }
  if (return_date - loan_date > loan_days) { # nolint: object_usage_linter.
    return("returned late")
  }
  "returned on time"
}

status <- vapply(
  seq_len(nrow(loans)),
  \(i) loan_status(loans$loan_date[[i]], loans$return_date[[i]]),
  character(1L)
)
table(status)
#> status
#>                  invalid date               out and overdue
#>                             1                             7
#> returned before it was loaned                 returned late
#>                             1                            25
#>              returned on time
#>                            26

Two cautions. A function that returns early must return a value of the same type on every path; the vapply() above enforces it. And on.exit() is the right way to clean up on every path, not a copy of the cleanup before each return(). See Functions and Control Flow.

Small pure functions

A pure function depends only on its arguments and changes nothing outside itself: the same arguments give the same result, a test is three lines, and the function works inside vapply(), in parallel and in a package. The impure version below reads and writes a variable of the global environment with <<-, so calling it twice with the same argument gives two different answers; the pure version returns the count and leaves the accumulating to the caller. stopifnot() is a fine unit test for a function this small (the testing framework is in Testing with testthat).

# Impure: reads and changes a variable outside the function.
total_late <- 0
count_late <- function(days) {
  total_late <<- total_late + sum(days > 21) # nolint: assignment_linter.
  total_late
}
count_late(c(10, 30, 25))
#> [1] 2
count_late(c(10, 30, 25))
#> [1] 4

# Pure: the result depends only on the arguments, and nothing else changes.
count_late_pure <- function(days, limit = 21) {
  sum(days > limit)
}
count_late_pure(c(10, 30, 25))
#> [1] 2
count_late_pure(c(10, 30, 25))
#> [1] 2

# Small enough to test with plain vectors.
stopifnot(
  count_late_pure(numeric(0)) == 0,
  count_late_pure(c(21, 22)) == 1,
  count_late_pure(c(10, 30), limit = 5) == 2
)

The assignment in the impure function carries a nolint because lintr asks for an explicit environment instead of <<-. Closures are the legitimate use of <<- and are explained in Scoping, Environments and Closures; Functions covers how to write them.

The pitfalls at a glance

The table lists the sixteen pitfalls of the catalogue. The first column links to the section of this page; the last names the page of the reference that explains the concept. The R FAQ column gives the entry of chapter 7 where one exists; "no entry" means that the FAQ has no question about it, and then the documentation that does cover it is named in the section.

Pitfall Symptom Fix R FAQ In depth

1:length(x) on empty input

The loop runs twice, for 1 and for 0; x[[i]] fails with "subscript out of bounds".

seq_along(x), seq_len(n)

no entry

Control Flow

Partial matching with $

rec$pag returns the field pages; a new field called pagination turns the answer into NULL.

Full names, rec[["pages"]], options(warnPartialMatchDollar = TRUE)

no entry (3.3.3 mentions it)

Subsetting and Indexing

T and F overwritten

After T <- 0, na.rm = T means na.rm = FALSE and no message is printed.

TRUE and FALSE

no entry (3.3.3 mentions it)

Style, Linting and Static Analysis

Factor to numeric

as.numeric(f) returns the codes 2, 1, 11 …​ instead of the years.

as.numeric(as.character(f)) or as.numeric(levels(f))[f]

7.10

Factors

Floating-point equality

3 * 0.1 == 0.3 is FALSE; a lookup with == finds nothing.

isTRUE(all.equal(x, y)), or compare with a tolerance

7.31

Numbers and Arithmetic

sapply simplification

The result is a vector, a matrix or a list depending on the data; empty input gives list().

vapply() with a FUN.VALUE, or lapply()

no entry

The apply Family and Functionals

drop on matrices and data frames

m[idx, ] is a vector when idx has length 1; df[, cols] is a vector for one column.

drop = FALSE, or df[cols]

7.5

Matrices and Arrays

NULL in lists

rec$isbn <- NULL removes the element instead of storing NULL.

rec["isbn"] <- list(NULL), or store NA

7.1

Lists

if (NA) and long conditions

"missing value where TRUE/FALSE needed"; the condition has length  1 (an error since R 4.2.0).

isTRUE(), is.na(), any(), all()

no entry

Control Flow

Global state and setwd

A function moves the session to another directory or leaves an option changed.

Paths with file.path(); on.exit(); withr::with_dir()

7.3, 7.7, 7.8 (related)

Sessions, Workspaces and Startup

Growing vectors

Time grows with the square of the length when c() is called in a loop.

Preallocate, vapply(), vectorise

no entry

Vectorisation and Iteration

attach

The attached copy goes stale, global variables hide columns, a second attach stacks another copy.

with(df, expr), df$col, data =

no entry

Data Frames

source() printing nothing

A sourced file shows no output for mean(x) at top level.

print(), source(file, echo = TRUE), print.eval = TRUE

7.16

Scripts and the Command Line

stringsAsFactors

Old code expects factors from data.frame() and read.csv(); new R gives character vectors.

factor() where a factor is meant; stringsAsFactors = TRUE as an argument

no entry

Data Frames

Integer overflow

.Machine$integer.max + 1L is NA with a warning; a 13-digit ISBN does not fit.

Doubles, or keep identifiers as strings

no entry

Numbers and Arithmetic

sample(x) with a length-1 x

sample(280L) draws from 1:280; a one-element selection of rows gives the wrong row.

x[sample.int(length(x), size)]

no entry

Distributions and Random Numbers

The pitfalls

Each section shows the bug first, with the output R printed, then the repair in the same script. Where a function appears with the suffix _bad or _old it is the code to avoid; where it is called _safe or _fixed it is the repair.

1:length(x) on empty input

The colon operator counts down when its second operand is smaller than the first, so 1:length(x) for an empty x is 1:0, which is the two numbers 1 and 0. A loop over it runs twice. In the first run x[1] is NA, an index past the end of the vector; in the second x[0] is an empty vector, which cat() prints as nothing. With [[ the first run is an error. The bug is invisible while the data has elements and appears the first time a filter selects nothing, which is why it is found in production. The same trap sits in 1:nrow(df), 1:ncol(m) and 1:n whenever the count can be zero. The script selects the titles of books newer than 2100 (there are none), shows the sequence, the two failures and a data frame version, and then the repair.

source("scenario.R")

# No book in the catalogue is newer than 2100, so this selection is empty.
future <- books$title[which(books$year > 2100)]
length(future)
#> [1] 0

# 1:length(x) is not an empty sequence when x is empty: it counts down.
1:length(future) # nolint: seq_linter.
#> [1] 1 0

describe_all <- function(x) {
  for (i in 1:length(x)) { # nolint: seq_linter.
    cat(i, ":", x[i], "\n")
  }
}
describe_all(future)
#> 1 : NA
#> 0 :

tryCatch(
  for (i in 1:length(future)) print(future[[i]]), # nolint: seq_linter.
  error = \(e) conditionMessage(e)
)
#> [1] "subscript out of bounds"

# The same trap with the rows of a data frame that has none.
none <- books[which(books$year > 2100), ]
nrow(none)
#> [1] 0
1:nrow(none) # nolint: seq_linter.
#> [1] 1 0

# seq_along() and seq_len() are empty when the input is empty.
seq_along(future)
#> integer(0)
seq_len(nrow(none))
#> integer(0)

describe_all_fixed <- function(x) {
  for (i in seq_along(x)) {
    cat(i, ":", x[i], "\n")
  }
}
describe_all_fixed(future)
describe_all_fixed(books$title[1:2])
#> 1 : The Dispossessed
#> 2 : Pride and Prejudice

The repair is seq_along(x) for the positions of a vector and seq_len(n) for a count; both return integer(0) for nothing, so the loop body does not run and the function returns what it should for an empty input. When the position is not needed, loop over the values: for (title in titles). lintr flags the colon forms with seq_linter and its message names the empty edge case. R FAQ: no entry. The seq help page recommends seq_along and seq_len over the one-argument seq() and says they are very fast primitives. In depth: Control Flow and Defensive Programming and Input Validation.

Partial matching with $

x$name is x[["name", exact = FALSE]], as the Extract help page says: the $ operator completes a prefix of a name, on lists, on data frames and on S3 objects built on lists, as long as the prefix is unambiguous. [[ requires the exact name unless exact = FALSE is given (and exact = NA allows a match with a warning), and [ never matches partially. The convenience hides typos: rec$pag is accepted for pages. It also makes the meaning of code depend on what else the object contains: when a second field with the same prefix is added, the prefix is ambiguous and $ returns NULL, with no message. Environments do not match partially. The option warnPartialMatchDollar (default FALSE) turns the silence into a warning for both lists and data frames; the R 3.1.1 NEWS records that the warning for data frames is only given when the option is TRUE.

source("scenario.R")

# $ completes an unambiguous prefix of a name, silently.
rec <- list(title = "Loud Roofs", pages = 198L)
rec$pag
#> [1] 198
rec$ti
#> [1] "Loud Roofs"

# [[ ]] needs the whole name, unless exact = FALSE is given.
rec[["pag"]]
#> NULL
rec[["pag", exact = FALSE]]
#> [1] 198

# A data frame column is completed the same way.
books$pag[1:3]
#> [1] 387 432 214

# The option turns the silence into a warning (restore it afterwards).
getOption("warnPartialMatchDollar")
#> [1] FALSE
op <- options(warnPartialMatchDollar = TRUE)
tryCatch(rec$pag, warning = \(w) conditionMessage(w))
#> [1] "partial match of 'pag' to 'pages'"
tryCatch(books$pag, warning = \(w) conditionMessage(w))
#> [1] "partial match of 'pag' to 'pages'"
options(op)

# An ambiguous prefix gives NULL, so adding a field changes the answer.
rec$pagination <- "A5"
rec$pag
#> NULL
rec$pages
#> [1] 198

# An S3 object built on a list behaves like a list.
bk <- book(
  "Loud Roofs", "Mara Quill", "978-0-00-200029-1", 2021, "fiction", 198
)
bk$pag
#> [1] 198
bk$pages
#> [1] 198

The repair is to write the full name, to use [["name"]] when the name is computed, and to switch the option on while developing and in tests, where the warning is caught by tryCatch() as above (it is a warning, so with options(warn = 2) it becomes an error). The scenario’s book objects are lists, so bk$pag completes to pages in the same way. R FAQ: no chapter 7 entry. Section 3.3.3 ("Others") of the FAQ, which lists differences between R and S, says that indexing with [ and a character vector looks only for exact matches whereas [[ and $ allow partial matches. In depth: Subsetting and Indexing, Lists and Defensive Programming and Input Validation.

T and F overwritten

T and F are not reserved words. They are ordinary variables in the base package whose values are TRUE and FALSE, and any code can assign to them. The logical help page states the difference: TRUE and FALSE are reserved words, while T and F are global variables that start out with those values. If a script, a package or a console session assigns T (a temperature, a time, a transposed matrix), every na.rm = T after it silently means something else: 0 is FALSE, so mean(x, na.rm = T) stops removing the missing values and returns NA, with no error and no warning. A function whose default is T looks T up where the function was defined, so it is affected too. The script defines such a function, breaks T on purpose, shows three wrong answers and removes the broken variable to uncover the real one.

# T and F are ordinary variables that hold TRUE and FALSE.
T # nolint: T_and_F_symbol_linter.
#> [1] TRUE
f <- function(x, na = T) mean(x, na.rm = na) # nolint: T_and_F_symbol_linter.
f(c(1, NA, 3))
#> [1] 2

# Anything can assign to them, and the code above changes meaning.
T <- 0 # nolint: T_and_F_symbol_linter, object_name_linter.
if (T) "yes" else "no" # nolint: T_and_F_symbol_linter.
#> [1] "no"
f(c(1, NA, 3))
#> [1] NA
mean(c(1, NA, 3), na.rm = T) # nolint: T_and_F_symbol_linter.
#> [1] NA

# Removing the global T uncovers the one in the base package again.
rm(T) # nolint: T_and_F_symbol_linter.
T # nolint: T_and_F_symbol_linter.
#> [1] TRUE

# TRUE and FALSE are reserved words and cannot be assigned.
tryCatch(TRUE <- 0, error = \(e) conditionMessage(e))
#> [1] "invalid (do_set) left-hand side to assignment"

The repair is to write TRUE and FALSE, always; lintr has T_and_F_symbol_linter in its default set. R FAQ: no entry in chapter 7; section 3.3.3 states that T and F are just variables, that they can be overwritten and that code should always use TRUE and FALSE, and An Introduction to R (An Introduction to R: Logical vectors) says the same. In depth: Style, Linting and Static Analysis, Atomic Vectors and Types and Assignment, Names and Values (shadowing base names).

Factor to numeric

A factor stores small integer codes and a set of labels, the levels. as.numeric(f) returns the codes, not the numbers the labels spell. The levels are sorted, so the codes look plausible and the wrong answer is not noticed: in the output below the year 1974 becomes 2 because 1813 is the first level. The pitfall used to be common because read.csv() turned a column into a factor whenever one stray value ("n/a") made it text and stringsAsFactors was TRUE (the default before R 4.0.0, see the pitfall on stringsAsFactors). It still happens when a factor is made on purpose and then used as a number. R FAQ 7.10 gives the two repairs: as.numeric(as.character(f)), which converts every element, and as.numeric(levels(f))[as.integer(f)], which converts each level once and is more efficient, "but harder to remember"; and it says not to call as.numeric() or unclass() on the factor for this, as both give the internal codes.

source("scenario.R")

# The years as a factor, the way old read.csv() calls produced them.
year_f <- factor(books$year)
head(year_f, 3L)
#> [1] 1974 1813 2019
#> 15 Levels: 1813 1974 1999 2005 2008 2012 2014 2016 2017 2018 2019 2020 ... 2023
nlevels(year_f)
#> [1] 15

# Wrong: as.numeric() returns the internal codes, not the years.
as.numeric(year_f)[1:5]
#> [1]  2  1 11 13  8

# Right: go through the labels (R FAQ 7.10) ...
as.numeric(as.character(year_f))[1:5]
#> [1] 1974 1813 2019 2021 2016
# ... or index the numeric levels by the codes: one conversion per level.
as.numeric(levels(year_f))[year_f][1:5]
#> [1] 1974 1813 2019 2021 2016

identical(
  as.numeric(as.character(year_f)),
  as.numeric(levels(year_f))[year_f]
)
#> [1] TRUE

# Better still: do not create the factor until a factor is needed.
class(books$year)
#> [1] "integer"

The best repair is not to have the factor: books$year is an integer column already. A missing value stays NA through either conversion. R FAQ: 7.10. In depth: Factors and Atomic Vectors and Types.

Floating-point equality

Doubles are stored in binary with 53 significant bits. The only numbers that are exact are integers and fractions whose denominator is a power of two, so 0.1 is not exact and 3 * 0.1 is 0.30000000000000004, which == correctly reports as different from 0.3. R FAQ 7.31 is the question that asks it, with sqrt(2)^2 == 2 as the example, and answers with all.equal(), which compares with a tolerance of .Machine$double.eps ^ 0.5, about 1.5e-8. The comparison with == also fails inside lookups: unique(), table(), match(), %in% and which(x == value) treat two doubles that print the same as different values. The script shows the late fee of three days at 0.1 per day, the FAQ’s example, the lookup failures and the repairs.

fee_per_day <- 0.1
fee <- 3 * fee_per_day

fee == 0.3
#> [1] FALSE
fee - 0.3
#> [1] 5.551115e-17
print(fee, digits = 17L)
#> [1] 0.30000000000000004

# all.equal() compares with a tolerance (about 1.5e-8) ...
all.equal(fee, 0.3)
#> [1] TRUE
.Machine$double.eps^0.5
#> [1] 1.490116e-08
# ... but returns a message, not FALSE, when the numbers differ.
all.equal(fee, 0.31)
#> [1] "Mean relative difference: 0.03333333"
isTRUE(all.equal(fee, 0.31))
#> [1] FALSE
tryCatch(
  if (all.equal(fee, 0.31)) "same",
  error = \(e) conditionMessage(e)
)
#> [1] "argument is not interpretable as logical"

# The example of R FAQ 7.31.
a <- sqrt(2)
a * a == 2
#> [1] FALSE
a * a - 2
#> [1] 4.440892e-16
isTRUE(all.equal(a * a, 2))
#> [1] TRUE

# == in a lookup misses values that print the same.
x <- c(0.1 + 0.2, 0.3)
x
#> [1] 0.3 0.3
x == 0.3
#> [1] FALSE  TRUE
length(unique(x))
#> [1] 2
length(unique(round(x, 10L)))
#> [1] 1
grid <- seq(0, 1, by = 0.1)
which(grid == 0.3)
#> integer(0)
which(abs(grid - 0.3) < 1e-9)
#> [1] 4

# Integers and values that are exact in binary are safe to compare.
5L == 5
#> [1] TRUE
0.5 + 0.25 == 0.75
#> [1] TRUE

all.equal() returns TRUE or a character string such as "Mean relative difference: 0.03333333", never FALSE, so if (all.equal(a, b)) fails with "argument is not interpretable as logical" when the numbers differ: wrap it in isTRUE(). For a lookup, round both sides to a fixed number of digits first, or compare with an explicit tolerance as abs(grid - 0.3) < 1e-9. Money is better kept in whole units (cents as integers) than in doubles; integers and values like 0.5 are exact, as the last two lines show. R FAQ: 7.31. In depth: Numbers and Arithmetic and Defensive Programming and Input Validation.

sapply simplification

sapply() is lapply() followed by simplify2array(), and what that does depends on the lengths of the answers: all of length one gives a vector, all of the same length greater than one gives a matrix, anything else gives a list. So the same call returns different shapes for different data, and for empty input it returns a list (list(), or named list() for a character vector), not an empty vector. Code tested on three elements fails on none. The script shows the three shapes, a longest() helper that works for three titles and fails for none with "invalid 'type' (list) of argument", and the vapply() version that returns 0 for none; the two vapply() calls at the end show its checks with R’s own text.

source("scenario.R")

# The same call returns a vector, a matrix or a list, depending on the data.
sapply(1:3, \(n) n^2)
#> [1] 1 4 9
sapply(1:3, \(n) c(n, n^2))
#>      [,1] [,2] [,3]
#> [1,]    1    2    3
#> [2,]    1    4    9
sapply(1:3, \(n) seq_len(n))
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 1 2
#>
#> [[3]]
#> [1] 1 2 3
#>

# Empty input gives a list, not an empty vector.
none <- books$title[which(books$year > 2100)]
sapply(none, nchar)
#> named list()

longest <- function(titles) max(sapply(titles, nchar))
longest(books$title[1:3])
#> [1] 20
tryCatch(longest(none), error = \(e) conditionMessage(e))
#> [1] "invalid 'type' (list) of argument"

# vapply() states the type and length of every result.
longest_safe <- function(titles) max(vapply(titles, nchar, integer(1L)), 0L)
longest_safe(books$title[1:3])
#> [1] 20
longest_safe(none)
#> [1] 0

tryCatch(
  vapply(1:3, \(n) c(n, n^2), numeric(1L)),
  error = \(e) conditionMessage(e)
)
#> [1] "values must be length 1,\n but FUN(X[[1]]) result is length 2"
tryCatch(
  vapply(1:3, \(n) "a", numeric(1L)),
  error = \(e) conditionMessage(e)
)
#> [1] "values must be type 'double',\n but FUN(X[[1]]) result is type 'character'"

The repair is vapply() with a FUN.VALUE whenever the result is meant to be a vector or a matrix, and lapply() whenever it is meant to be a list. lintr has sapply in the list of the functions that undesirable_function_linter reports, with the advice to use vapply(). R FAQ: no entry (FAQ 7.17 is about outer() and shape, a different function). The lapply help page describes vapply as similar to sapply but "safer". In depth: The apply Family and Functionals and Defensive Programming and Input Validation.

drop on matrices and data frames

R drops dimensions of length one when it can. Selecting a single row or column of a matrix gives a plain vector, with the dim attribute gone and the other dimnames turned into names; selecting one column of a data frame with a comma gives a vector too. A function written for a matrix of any number of rows then works for two rows and fails for one. R FAQ 7.5 says this was determined, after much discussion, to be a feature, that drop = FALSE prevents it, and that drop = FALSE should be used defensively when programming, because mat[index, ] returns a vector rather than a matrix when index has length one. The first script shows it on a matrix of years and page counts, with the failure message of apply().

source("scenario.R")

m <- as.matrix(books[1:4, c("year", "pages")])
m
#>   year pages
#> 1 1974   387
#> 2 1813   432
#> 3 2019   214
#> 4 2021   198

# One row selected: the matrix becomes a named vector.
m[2, ]
#>  year pages
#>  1813   432
dim(m[2, ])
#> NULL
m[2, , drop = FALSE]
#>   year pages
#> 2 1813   432

row_totals <- function(m, rows) apply(m[rows, ], 1L, sum)
row_totals(m, 1:2)
#>    1    2
#> 2361 2245
tryCatch(row_totals(m, 1L), error = \(e) conditionMessage(e))
#> [1] "dim(X) must have a positive length"

# drop = FALSE keeps the shape whatever the length of the index.
row_totals_safe <- function(m, rows) apply(m[rows, , drop = FALSE], 1L, sum)
row_totals_safe(m, 1:2)
#>    1    2
#> 2361 2245
row_totals_safe(m, 1L)
#>    1
#> 2361
row_totals_safe(m, integer(0))
#> integer(0)

For data frames the rule is slightly different: with a comma, df[, "col"] drops to a vector when one column is left; df["col"] (a single index, no comma) never drops, and neither does df[1, ] (one row). A helper that selects columns by name and passes the result to a function that needs a data frame or matrix, such as colMeans(), fails for exactly one column, with a message about arrays of at least two dimensions.

source("scenario.R")

# [ with a comma drops a one-column result to a vector; a single index does not.
class(books[, "pages"])
#> [1] "integer"
class(books[, "pages", drop = FALSE])
#> [1] "data.frame"
class(books["pages"])
#> [1] "data.frame"
class(books[1, ])
#> [1] "data.frame"

col_means <- function(df, cols) colMeans(df[, cols], na.rm = TRUE)
col_means(books, c("year", "pages"))
#>      year     pages
#> 1998.7333  268.2143
tryCatch(col_means(books, "pages"), error = \(e) conditionMessage(e))
#> [1] "'x' must be an array of at least two dimensions"

col_means_safe <- function(df, cols) {
  colMeans(df[, cols, drop = FALSE], na.rm = TRUE)
}
col_means_safe(books, "pages")
#>    pages
#> 268.2143

The repair is drop = FALSE in every subscript whose index length is not known in advance, or df[cols] for data frames. Tibbles and data.table do not drop, which is one reason people choose them; this page does not run either package. R FAQ: 7.5 for matrices and arrays; no entry for data frames. In depth: Matrices and Arrays, Subsetting and Indexing and Data Frames.

NULL in lists

Assigning NULL to a list element removes the element. rec$isbn <- NULL, rec[["isbn"]] <- NULL and rec["isbn"] <- NULL all delete the field, so a book without an ISBN loses the isbn entry and names(rec) shrinks. R FAQ 7.1 gives the repair: use x[i] <- list(NULL) to set component i of the list x to NULL, similarly for named components, and do not set x[i] or x[[i]] to NULL, because that removes the component. The same two-sided behaviour shows up elsewhere: list(1, NULL, 3) has three elements but c(list(1), NULL, list(3)) has two, because c() drops a bare NULL; and assigning NULL to an element of a result list in a loop shortens the list so later positions shift.

rec <- list(title = "Loud Roofs", isbn = "9780002000291", pages = 198L)

# Assigning NULL removes the element.
rec$isbn <- NULL
names(rec)
#> [1] "title" "pages"

# R FAQ 7.1: assign list(NULL) to store a NULL instead.
rec <- list(title = "Loud Roofs", isbn = "9780002000291", pages = 198L)
rec["isbn"] <- list(NULL)
names(rec)
#> [1] "title" "isbn"  "pages"
str(rec)
#> List of 3
#>  $ title: chr "Loud Roofs"
#>  $ isbn : NULL
#>  $ pages: int 198
is.null(rec$isbn)
#> [1] TRUE
"isbn" %in% names(rec)
#> [1] TRUE

# Several elements at once.
x <- list(a = 1, b = 2, c = 3)
x[c("a", "b")] <- list(NULL)
str(x)
#> List of 3
#>  $ a: NULL
#>  $ b: NULL
#>  $ c: num 3
x <- list(a = 1, b = 2, c = 3)
x[c("a", "b")] <- NULL
str(x)
#> List of 1
#>  $ c: num 3

# list(NULL) elements count, but c() drops a bare NULL.
length(list(1, NULL, 3))
#> [1] 3
length(c(list(1), NULL, list(3)))
#> [1] 2

# A result list that has a NULL: lapply() keeps it, assigning NULL deletes it.
res <- lapply(1:3, \(i) if (i == 2L) NULL else i)
length(res)
#> [1] 3
res[[2]] <- NULL
length(res)
#> [1] 2

# Missing values are better stored as NA than as NULL.
rec <- list(title = "Loud Roofs", isbn = NA_character_, pages = 198L)
str(rec)
#> List of 3
#>  $ title: chr "Loud Roofs"
#>  $ isbn : chr NA
#>  $ pages: int 198

Often what is really meant is "unknown", which is better stored as a typed NA such as NA_character_: it keeps the field, the type and the length. is.null(rec$isbn) cannot tell a field that is NULL from a field that does not exist, so use "isbn" %in% names(rec) for that. R FAQ: 7.1. In depth: Lists, Subsetting and Indexing and Missing and Special Values.

if (NA) and conditions longer than one

if and while need a condition that is exactly one TRUE or FALSE. The conditions that are not give an error each, with a message of its own: NA gives "missing value where TRUE/FALSE needed" (and if (year > 2000L) is NA when year is, as it is for the book in row 7), NULL or a zero-length vector give "argument is of length zero", a string that is not a logical gives "argument is not interpretable as logical". A condition of length greater than one used to take the first element with a warning; it has been an error since R 4.2.0 (the condition has length  1), and && and || with an operand of length greater than one warned in 4.2.0 and are an error since R 4.3.0 ("'length = 2' in coercion to 'logical(1)'"). The first script is about NA.

source("scenario.R")

year <- books$year[7L]
year
#> [1] NA
tryCatch(
  if (year > 2000L) "new" else "old",
  error = \(e) conditionMessage(e)
)
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(if (NA) 1, error = \(e) conditionMessage(e))
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(while (NA) 1, error = \(e) conditionMessage(e))
#> [1] "missing value where TRUE/FALSE needed"

# Other conditions that are not one TRUE or FALSE.
tryCatch(if (NULL) 1, error = \(e) conditionMessage(e))
#> [1] "argument is of length zero"
tryCatch(if (logical(0)) 1, error = \(e) conditionMessage(e))
#> [1] "argument is of length zero"
tryCatch(if ("yes") 1, error = \(e) conditionMessage(e))
#> [1] "argument is not interpretable as logical"

# Decide what NA means, then test for it.
if (isTRUE(year > 2000L)) "new" else "old or unknown"
#> [1] "old or unknown"
if (is.na(year)) "unknown" else if (year > 2000L) "new" else "old"
#> [1] "unknown"
!is.na(year) && year > 2000L
#> [1] FALSE

The decision belongs to the programmer: decide what NA means and write it. isTRUE(cond) turns NA into FALSE; is.na(x) first, then the comparison, makes the third case explicit; !is.na(x) && x > 2000L short-circuits. The second script is about the length of the condition, with the fix: any() and all() (with na.rm = TRUE when NA is possible) say whether some or every element qualifies, and ifelse() computes a value per element.

source("scenario.R")

tryCatch(
  if (books$year > 2000L) "some are new",
  error = \(e) conditionMessage(e)
)
#> [1] "the condition has length > 1"
tryCatch(
  c(TRUE, FALSE) && TRUE,
  error = \(e) conditionMessage(e)
)
#> [1] "'length = 2' in coercion to 'logical(1)'"
tryCatch(
  books$year > 2000L || TRUE,
  error = \(e) conditionMessage(e)
)
#> [1] "'length = 16' in coercion to 'logical(1)'"

# Say what is meant: any(), all(), or a vectorised ifelse().
any(books$year > 2000L, na.rm = TRUE)
#> [1] TRUE
all(books$year > 1800L, na.rm = TRUE)
#> [1] TRUE
age <- ifelse(is.na(books$year), "unknown",
  ifelse(books$year > 2000L, "new", "old")
)
table(age)
#> age
#>     new     old unknown
#>      12       3       1

The rule of thumb that goes with it is that && and || are for conditions of length one and & and | are for vectors; the vector_logic_linter of lintr reports the vector forms inside if. R FAQ: no entry. The changes are in the R NEWS of 4.2.0 and 4.3.0 (R NEWS: Changes in R 4.2.0) and An Introduction to R describes the conditional (An Introduction to R: Conditional execution). In depth: Control Flow, conditions of length greater than one and Missing and Special Values.

Global state and setwd

Global state is anything a function can change that outlives the call: the working directory, the options, the search path (library(), attach()), environment variables, the locale, the random seed, graphics parameters, and objects in the global environment (<<-, assign()). Code that changes it without putting it back is correct when run once, from a clean session, in the author’s folder, and wrong afterwards. Two cases are common enough to deserve a demonstration. The first is the working directory. A script that starts by moving to a folder of the author’s machine cannot run anywhere else, and a function that moves the session leaves the caller in a directory it did not choose: the next relative path fails or, worse, reads another file with the same name. The script below makes a scratch folder with a copy of the catalogue, shows the bad function, the failure it causes, and three repairs. R FAQ 7.8 is the related entry for file names on Windows (use forward slashes or escape the backslash).

source("scenario.R")

# A scratch folder with a copy of the catalogue, as a stand-in for a project.
folder <- tempfile("shelf")
dir.create(folder)
invisible(file.copy("books.csv", folder))
start <- getwd()

# Bad: the function changes the working directory of the whole session.
read_books_bad <- function(dir) {
  setwd(dir)
  read.csv("books.csv")
}
nrow(read_books_bad(folder))
#> [1] 16
identical(getwd(), start)
#> [1] FALSE

# Later code that expects the old directory now fails.
tryCatch(
  read.csv("loans.csv"),
  warning = \(w) conditionMessage(w)
)
#> [1] "cannot open file 'loans.csv': No such file or directory"
setwd(start)

# Fix 1: build the path instead of moving; nothing global changes.
read_books <- function(dir) read.csv(file.path(dir, "books.csv"))
nrow(read_books(folder))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE

# Fix 2: if a function must change directory, it puts the old one back.
read_books_in <- function(dir) {
  old <- setwd(dir)
  on.exit(setwd(old), add = TRUE)
  read.csv("books.csv")
}
nrow(read_books_in(folder))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE

# Fix 3: withr::with_dir() does the same in one call.
nrow(withr::with_dir(folder, read.csv("books.csv")))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE

unlink(folder, recursive = TRUE)
file.exists(folder)
#> [1] FALSE

The repair in order of preference is to move nothing: build the path (file.path(dir, "books.csv")) from a root that the project knows (an RStudio or Positron project, or Rscript started in the project folder, so that relative paths are relative to it); then, if a function must run in another directory, to restore it with on.exit() using the value that the setter returns; or to let withr 3.0.3 do the same in one call with withr::with_dir(). The getwd help page documents that the setter returns the old directory, invisibly. The second case is options. A function that calls options() and does not restore it changes every later printout in the session, and rm(list = ls()), the line at the top of many scripts "to start clean", does not reset it, because it removes objects and nothing else; a fresh process does (see the next script). R FAQ 7.7 recommends the pattern of the repair, taking the old values from options() and putting them back with on.exit().

# A function that sets an option and never puts it back.
set_digits <- function() {
  options(digits = 3L)
  invisible(NULL)
}

print(pi)
#> [1] 3.141593
set_digits()
print(pi)
#> [1] 3.14

# rm(list = ls()) removes objects. It does not reset options.
rm(list = ls())
print(pi)
#> [1] 3.14
getOption("digits")
#> [1] 3

# Restore the option by hand here; inside a function use on.exit().
options(digits = 7L)
print(pi)
#> [1] 3.141593

# R FAQ 7.7: take the old values, put them back on the way out.
print_pi <- function(digits) {
  old <- options(digits = digits)
  on.exit(options(old), add = TRUE)
  print(pi)
}
print_pi(3L)
#> [1] 3.14
print(pi)
#> [1] 3.141593

R FAQ: none for the working directory itself; 7.3 ("How can I clean up my workspace?" answers with rm(list = ls(all.names = TRUE)), which removes objects only, and does not claim more), 7.7 for options and 7.8 for file names. In depth: Sessions, Workspaces and Startup, options(), Connections and the File System and Functions.

Growing vectors

out <- c(out, value) in a loop allocates a new vector of length n + 1 and copies the n elements already there on every iteration, so the time grows with the square of the final length: doubling the number of iterations makes the loop about four times slower. Assigning past the end (out[[length(out) + 1]] <- value) is cheaper since R 3.4.0, whose NEWS records that assigning beyond the length now over-allocates by a small fraction so that building a vector this way is more efficient, "though pre-allocating is still preferred" and that the implementation is subject to change. The same holds for lists and, much worse, for rbind(df, row) on a data frame in a loop. The script computes 30000 squares four ways, checks that they agree for ten of them, times the first three and prints only comparisons, because seconds depend on the machine, the BLAS and what else is running: the first two comparisons are TRUE when the growing loop is more than five times slower than the preallocated loop and than vapply(); the third doubles the length and is TRUE when the time grows by more than a factor of 2.5, which a quadratic loop 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

# Growing: every c() allocates a new vector and copies the old one.
grow <- function(n) {
  out <- c()
  for (i in seq_len(n)) {
    out <- c(out, i^2)
  }
  out
}

# Preallocating: one allocation, then the loop fills it in place.
prealloc <- function(n) {
  out <- numeric(n)
  for (i in seq_len(n)) {
    out[[i]] <- i^2
  }
  out
}

# vapply(): the loop and the allocation are done for you.
mapped <- function(n) {
  vapply(seq_len(n), \(i) i^2, numeric(1L))
}

# Fully vectorised: no loop at all.
direct <- function(n) seq_len(n)^2

identical(grow(10L), prealloc(10L))
#> [1] TRUE
identical(mapped(10L), prealloc(10L))
#> [1] TRUE
identical(direct(10L), prealloc(10L))
#> [1] TRUE

t_grow <- elapsed(grow(n))
t_pre <- elapsed(prealloc(n))
t_map <- elapsed(mapped(n))

# Printed as comparisons, not as seconds.
t_grow > 5 * t_pre
#> [1] TRUE
t_grow > 5 * t_map
#> [1] TRUE

# Doubling n roughly quadruples the time of the growing loop.
t_grow2 <- elapsed(grow(2L * n))
t_grow2 / t_grow > 2.5
#> [1] TRUE

The repairs, in order: vectorise (seq_len(n)^2), vapply() (the loop and the allocation done once), or preallocate with numeric(n), integer(n), character(n) or vector("list", n) and fill by position; when the final length is unknown, collect the pieces in a list and combine them once with unlist() or do.call(rbind, parts). R FAQ: no entry. In depth: Vectorisation and Iteration, Control Flow and Memory and Copy Semantics.

attach

attach(df) puts a copy of a data frame on the search path so that its columns can be used without df$. The copy is the problem. Later changes to the data frame do not reach it; a global variable with a column’s name hides the column (R prints a message when an attach causes masking); attaching again stacks a second copy; and every function called in between sees the extra entry, so the meaning of a name depends on what happened earlier in the session. An Introduction to R describes the first of these itself: after an assignment the new value is not visible until the data frame is detached and attached again. The script reproduces each effect and ends with the repair.

source("scenario.R")

attach(books)
mean(pages, na.rm = TRUE)
#> [1] 268.2143

# The attached copy does not follow later changes to the data frame.
books$pages[1] <- 1000L
mean(books$pages, na.rm = TRUE)
#> [1] 312
mean(pages, na.rm = TRUE) # nolint: object_usage_linter.
#> [1] 268.2143

# Attaching twice puts two copies on the search path.
attach(books)
#> The following objects are masked from books (pos = 3):
#>
#>     author, genre, isbn, pages, title, year
search()[1:4]
#> [1] ".GlobalEnv"    "books"         "books"         "package:stats"
detach(books)
detach(books)

# A global variable with a column's name hides the column.
pages <- 1:3
attach(books)
#> The following object is masked _by_ .GlobalEnv:
#>
#>     pages
mean(pages)
#> [1] 2
detach(books)
rm(pages)

# Fix: say where the column comes from.
books <- read.csv("books.csv")
with(books, mean(pages, na.rm = TRUE))
#> [1] 268.2143
mean(books$pages, na.rm = TRUE)
#> [1] 268.2143

After the first attach(books), mean(pages) is 268.2143. The data frame then changes (books$pages[1] <- 1000L) and its own mean is 312, but the attached pages still gives 268.2143. The second attach(books) prints a message that the objects of the new entry mask those of the first, and search() shows books twice. With pages <- 1:3 in the workspace, mean(pages) is 2: the workspace comes before every attached entry, and the message says that the object is masked by .GlobalEnv. The repair is with(books, mean(pages, na.rm = TRUE)), books$pages, or the data = argument of the modelling functions; use() (R 4.4.0) attaches only the names you list. lintr reports attach in the same list as sapply and the working-directory setter. R FAQ: no entry on its misuse; section 3.3.3 only notes that attach() works for lists and data frames. In depth: Data Frames, Scoping, Environments and Closures and Packages, Libraries and Namespaces.

source() printing nothing

At the console a visible value is printed automatically. In a file that is run with source(), inside a function and in the body of a loop it is not: only an explicit print() or cat() shows anything. That is the content of R FAQ 7.16, which gives the repairs: print(1+1) instead of 1+1, or source(file, echo = TRUE). A file run with Rscript is read as a script and does print its visible top-level values, which is why the same file can look different under the two. The source help page documents print.eval, which defaults to the value of echo and can be set alone, and withAutoprint() for code that is already in the session; the value that source() returns is a list with the value and its visibility.

script <- tempfile(fileext = ".R")
writeLines(
  c(
    "x <- c(2, 4, 6)",
    "mean(x)",
    "print(sum(x))",
    "invisible(99)",
    "1 + 1"
  ),
  script
)

# Only the explicit print() produces output.
source(script)
#> [1] 12

# echo = TRUE shows each expression and prints what it returns (R FAQ 7.16).
source(script, echo = TRUE)
#>
#> > x <- c(2, 4, 6)
#>
#> > mean(x)
#> [1] 4
#>
#> > print(sum(x))
#> [1] 12
#>
#> > invisible(99)
#>
#> > 1 + 1
#> [1] 2

# print.eval = TRUE prints the visible values without echoing the code.
source(script, print.eval = TRUE)
#> [1] 4
#> [1] 12
#> [1] 2

# source() returns the value of the last expression and its visibility.
res <- source(script)
#> [1] 12
res$value
#> [1] 2
res$visible
#> [1] TRUE

# withAutoprint() does the same for code that is already in the session.
withAutoprint({
  x <- 1:3
  x * 2
})
#> > x <- 1:3
#> > x * 2
#> [1] 2 4 6

unlink(script)
file.exists(script)
#> [1] FALSE

The block shows, in order, the output of source(script) (only the print(sum(x)) line of the file), echo = TRUE (the code, each expression after a prompt, and the visible values; invisible(99) prints nothing) and print.eval = TRUE (the values alone). The script file is a temporary file, deleted at the end, and its path is never shown. R FAQ: 7.16. In depth: Scripts and the Command Line and Functions for visibility.

stringsAsFactors

Until R 4.0.0 data.frame() and read.table() (so read.csv()) turned character columns into factors by default. R 4.0.0 changed the default to FALSE, and the data.frame help page says so: the old factory-fresh default was TRUE and it became FALSE in R 4.0.0. The helper default.stringsAsFactors() was deprecated in R 4.2.0 and is defunct since R 4.3.0, and options(stringsAsFactors = TRUE) is deprecated and warns. Code from before 2020 therefore breaks in both directions: scripts that expected factors (levels(df$genre) is now NULL) and scripts that begin with options(stringsAsFactors = FALSE) or end with as.character() calls to undo the conversion, which are now noise. The script shows today’s default, the old behaviour on request, the deprecation and the defunct function with their messages.

source("scenario.R")

# Since R 4.0.0 strings stay strings.
str(data.frame(genre = c("Fiction", "Poetry"), n = 1:2))
#> 'data.frame':	2 obs. of  2 variables:
#>  $ genre: chr  "Fiction" "Poetry"
#>  $ n    : int  1 2
str(books$genre)
#>  chr [1:16] "Science fiction" "Classic" "Fiction" "fiction" "Mystery" ...

# The old behaviour is still available when it is asked for.
str(data.frame(
  genre = c("Fiction", "Poetry"), n = 1:2, stringsAsFactors = TRUE
))
#> 'data.frame':	2 obs. of  2 variables:
#>  $ genre: Factor w/ 2 levels "Fiction","Poetry": 1 2
#>  $ n    : int  1 2
str(read.csv("books.csv", stringsAsFactors = TRUE)$genre)
#>  Factor w/ 9 levels "Classic","fiction",..: 9 1 3 2 5 6 7 1 6 4 ...

# The global switch is gone: setting it to TRUE warns ...
tryCatch(
  options(stringsAsFactors = TRUE),
  warning = \(w) conditionMessage(w)
)
#> [1] "'options(stringsAsFactors = TRUE)' is deprecated and will be disabled"
# ... and the helper is defunct.
tryCatch(
  default.stringsAsFactors(),
  error = \(e) conditionMessage(e)
)
#> [1] "'default.stringsAsFactors' is defunct.\nUse '`stringsAsFactors = FALSE`' instead.\nSee help(\"Defunct\")"
getOption("stringsAsFactors")
#> NULL

# Make a factor on purpose, after cleaning the text.
genre <- factor(tolower(trimws(books$genre)))
levels(genre)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"

The repair is to make a factor on purpose, after cleaning the text (the last lines trim and lower-case the nine spellings of the six genres of the scenario, then call factor()), and to pass stringsAsFactors = TRUE as an argument when the old conversion is wanted. R FAQ: no entry about this change; 7.10 is the related one for the conversion back. The R NEWS 4.0.0 entry says the default is stringsAsFactors = FALSE and that "a large number of packages relied on the previous behaviour". In depth: Data Frames and Factors.

Integer overflow

R’s integer type has 32 bits, so the largest integer is .Machine$integer.max, 2147483647. Arithmetic on integers that goes beyond it does not wrap or switch to doubles: the Arithmetic help page says the result is NA_integer_ with a warning. Mixing in a double (2 or 1.0, not 2L) gives a double and no problem. sum() of integers has not overflowed since R 3.5.0 (the sum help page: "Integer overflow should no longer happen since R version 3.5.0"), but cumsum() still does, with its own message. The scenario has a natural victim: an ISBN-13 is a 13-digit number, larger than any integer, and as.integer("9780306406157") is NA. Doubles hold integers exactly up to 2 to the power 53, so 13 digits survive as doubles; but an identifier is not a number (you never add two ISBNs, ISBN-10 has an X, and a leading zero would be lost), so the scenario keeps ISBNs as strings.

.Machine$integer.max
#> [1] 2147483647

tryCatch(
  .Machine$integer.max + 1L,
  warning = \(w) conditionMessage(w)
)
#> [1] "NAs produced by integer overflow"
.Machine$integer.max + 1
#> [1] 2147483648

big <- 100000L
tryCatch(big * big, warning = \(w) conditionMessage(w))
#> [1] "NAs produced by integer overflow"
as.numeric(big) * big
#> [1] 1e+10
class(big * 2)
#> [1] "numeric"

# sum() has not overflowed since R 3.5.0, cumsum() still does.
sum(c(.Machine$integer.max, 1L))
#> [1] 2147483648
tryCatch(
  cumsum(c(.Machine$integer.max, 1L)),
  warning = \(w) conditionMessage(w)
)
#> [1] "integer overflow in 'cumsum'; use 'cumsum(as.numeric(.))'"
cumsum(as.numeric(c(.Machine$integer.max, 1L)))
#> [1] 2147483647 2147483648

# An ISBN-13 does not fit in an integer, a double holds it exactly.
isbn <- "9780306406157"
tryCatch(as.integer(isbn), warning = \(w) conditionMessage(w))
#> [1] "NAs introduced by coercion to integer range"
format(as.numeric(isbn), scientific = FALSE)
#> [1] "9780306406157"
nchar(isbn)
#> [1] 13

For integers that must exceed 32 bits and stay integers, the CRAN package bit64 provides a 64-bit type; it is not used or run on this page. In base R the repair is to use doubles: write 1 or 1e5 rather than 1L, convert with as.numeric() before multiplying, and keep identifiers as character. R FAQ: no entry. In depth: Numbers and Arithmetic and Atomic Vectors and Types.

sample(x) with a length-1 x

sample(x) has a convenience that is a trap: if x has length one, is numeric and is at least 1, it samples from 1:x instead of from x. So sample(280L) is a random permutation of the numbers 1 to 280 and sample(280L, 1L) is one of them, not 280. It is harmless when x is a typed-in vector and a bug when x comes from a computation whose length varies, such as the rows that match a filter: with two matching rows sample(rows, 1L) picks one of them; with one matching row it picks a random row number between 1 and that row. The sample help page warns about it and ends its examples with the repair. Here which(books$pages > 420) is the single row 2 ("Pride and Prejudice", 432 pages), and drawing 200 times from it gives row 1 about half the time.

source("scenario.R")

set.seed(282)
pick <- function(x) sample(x, 1L)
pick(c(432L, 280L))
#> [1] 280

# With a single number, sample() draws from 1:x, not from x.
pick(280L)
#> [1] 166

# The books with more than 420 pages: here exactly one, so the trap is live.
long <- which(books$pages > 420)
long
#> [1] 2
draws <- replicate(200L, sample(long, 1L))
table(draws)
#> draws
#>   1   2
#>  91 109

# The helper from the help page of sample() always indexes.
resample <- function(x, ...) x[sample.int(length(x), ...)]
draws <- replicate(200L, resample(long, 1L))
table(draws)
#> draws
#>   2
#> 200

# sample.int(length(x), size) is the underlying call.
resample(c(432L, 280L), 2L)
#> [1] 280 432
tryCatch(
  resample(integer(0), 1L),
  error = \(e) conditionMessage(e)
)
#> [1] "invalid first argument"

The repair is the resample() function from the help page, x[sample.int(length(x), ...)], which always indexes; sample.int() takes the number of items and never reinterprets its first argument. Character vectors are not affected (sample("a") is "a"), which hides the bug in tests that use titles and shows it with row numbers. With nothing to sample from both functions fail with "invalid first argument", so a caller that can get an empty set checks length(x) first. Results of sample() also changed in R 3.6.0, whose default became rejection sampling, so seeds from older scripts give other draws. R FAQ: no entry. In depth: Distributions and Random Numbers and the R 3.6.0 change.

Letting lintr find them

Several of the sixteen pitfalls leave a trace in the source that a linter can see before the code runs: the colon sequence (seq_linter), T and F (T_and_F_symbol_linter), a comparison with NA (equals_na_linter), & in a condition (vector_logic_linter), a redundant return() (return_linter), and, once undesirable_function_linter is added to the default set, attach, options and sapply. The script lints nine lines of old-style code from a string, so nothing is run, and prints the line and the name of every linter that fired. The default list of undesirable_function_linter also names the working-directory setter, source, library, par, Sys.setenv, Sys.setlocale and mapply, which is how the global-state pitfall shows up in a report; the last expression of the script confirms five of the names against the list that the installed lintr 3.4.0 carries.

# Nine lines of older-tutorial R, linted from a string (nothing is run).
code <- c(
  "f <- function(x, rm_na = T) {",
  "  for (i in 1:length(x)) print(i)",
  "  if (x == NA) 1",
  "  if (rm_na & TRUE) 2",
  "  attach(x)",
  "  options(digits = 3)",
  "  y <- sapply(x, nchar)",
  "  return(y)",
  "}"
)

linters <- lintr::linters_with_defaults(lintr::undesirable_function_linter())
found <- lintr::lint(text = paste(code, collapse = "\n"), linters = linters)

for (lint in found) {
  cat(sprintf("line %d: %s\n", lint$line_number, lint$linter))
}
#> line 1: T_and_F_symbol_linter
#> line 2: seq_linter
#> line 3: equals_na_linter
#> line 4: vector_logic_linter
#> line 5: undesirable_function_linter
#> line 6: undesirable_function_linter
#> line 7: undesirable_function_linter
#> line 8: return_linter

# The default list of undesirable functions includes these.
intersect(
  c("attach", "setwd", "sapply", "options", "source"),
  names(lintr::default_undesirable_functions)
)
#> [1] "attach"  "setwd"   "sapply"  "options" "source"

Line 1 is T, line 2 the colon sequence, line 3 the comparison with NA, line 4 the &, lines 5 to 7 attach, options and sapply from the undesirable list, line 8 the explicit return(). The other linters of the default set (names, spacing, quotes, line length) are about style and are described in Style, Linting and Static Analysis; a line that shows a bad idiom on purpose, as the demonstrations on this page do, carries # nolint: <linter>. as explained in excluding code. The reference pages of the individual linters are seq_linter, T_and_F_symbol_linter, equals_na_linter, vector_logic_linter and return_linter. A linter finds the pattern, not the data: it cannot know that a filter may return nothing, so the habits above remain necessary.

Before and after

The last script puts four of the pitfalls into one small function and repairs it with the idioms. The task: the average page count per genre of the books published after a given year. The older-tutorial version loops with 1:length(), grows a vector, writes na.rm = T and does not think about the case with no books; the repaired version selects with which(), returns early on an empty selection, splits by genre and uses vapply(). Both give the same numbers for a normal call (the genre order differs, so the comparison indexes the old result by the new names). For a year after the last book, the old version answers with NaN for every genre, and for a catalogue with no rows the loop 1:length(kinds) runs for 1 and for 0, so it answers with two NaN values whose names are NA; the new version returns an empty numeric vector both times.

source("scenario.R")

# Older-tutorial style: the empty case, T, a growing vector and a loop index.
average_pages_old <- function(books, after) {
  genre <- tolower(trimws(books$genre))
  kinds <- unique(genre)
  result <- c()
  for (i in 1:length(kinds)) { # nolint: seq_linter.
    rows <- genre == kinds[i] & books$year > after
    pages <- books$pages[rows]
    average <- mean(pages, na.rm = T) # nolint: T_and_F_symbol_linter.
    result <- c(result, average)
  }
  names(result) <- kinds
  result
}

# The same task with the idioms of this page.
average_pages <- function(books, after) {
  recent <- books[which(books$year > after), ]
  if (nrow(recent) == 0L) {
    return(numeric(0))
  }
  genre <- tolower(trimws(recent$genre))
  vapply(
    split(recent$pages, genre),
    \(p) mean(p, na.rm = TRUE),
    numeric(1L)
  )
}

old <- average_pages_old(books, 2000L)
new <- average_pages(books, 2000L)
round(new, 1L)
#>         fiction         mystery     non-fiction          poetry science fiction
#>           222.7           272.5           263.5            88.0           366.0
all.equal(old[names(new)], new)
#> [1] TRUE

# On the empty cases the old version answers with noise, or fails.
average_pages_old(books, 2100L)
#> science fiction         classic         fiction         mystery     non-fiction
#>             NaN             NaN             NaN             NaN             NaN
#>          poetry
#>             NaN
tryCatch(
  average_pages_old(books[0, ], 2000L),
  error = \(e) conditionMessage(e)
)
#> <NA> <NA>
#>  NaN  NaN
average_pages(books, 2100L)
#> numeric(0)
average_pages(books[0, ], 2000L)
#> numeric(0)

The old function is not wrong on the usual input, which is the reason it survives: it is wrong on the edges, and R gives no message at any point. The repaired version has a nrow(recent) == 0L guard because mean() of nothing is NaN and a table of NaN would be silently meaningful to nobody. Both functions are small, take their data as an argument and read no global variable, as the last idiom asks.

R FAQ chapter 7 at a glance

Chapter 7 of the R FAQ, "R Miscellanea", has 44 entries (the numbers are those of the live FAQ, 7.1 to 7.44). Several of them are the pitfalls of this page; the others are the questions on the pages of this reference listed in the last column. Entries that concern graphics devices of particular platforms or add-on packages are not treated in a section of their own, and the table says so instead of pointing to a page that does not cover them. The FAQ is at R FAQ; the anchors of its entries are used in the Source: lines above.

R FAQ Question Where in this reference

7.1

How can I set components of a list to NULL?

NULL in lists and Lists

7.2

How can I save my workspace?

Sessions, Workspaces and Startup

7.3

How can I clean up my workspace?

Global state and the workspace

7.4

How can I get eval() and D() to work?

Evaluation and Non-Standard Evaluation for the environment in which the first argument is evaluated; D() is not treated

7.5

Why do my matrices lose dimensions?

drop and Matrices and Arrays

7.6

How does autoloading work?

the search path at startup (the Autoloads environment)

7.7

How should I set options?

Global state and options()

7.8

How do file names work in Windows?

Connections and the File System

7.9

Why does plotting give a color allocation error?

Not treated: an X11-specific problem; see Base Graphics and Devices

7.10

How do I convert factors to numeric?

Factor to numeric and Factors

7.11

Are Trellis displays implemented in R?

Grid Graphics and Lattice

7.12

What are the enclosing and parent environments?

Scoping, Environments and Closures

7.13

How can I substitute into a plot label?

Base Graphics and Devices

7.14

What are valid names?

Syntax and Lexical Structure

7.15

Are GAMs implemented in R?

Not treated: it names the gam and mgcv packages; see Statistical Models for the modelling functions of base R

7.16

Why is the output not printed when I source() a file?

source() printing nothing and Scripts and the Command Line

7.17

Why does outer() behave strangely with my function?

The apply Family and Functionals

7.18

Why does the output from anova() depend on the order of factors in the model?

Statistical Models and Formulas

7.19

How do I produce PNG graphics in batch mode?

Base Graphics and Devices and Scripts and the Command Line

7.20

How can I get command line editing to work?

IDEs and the R Console

7.21

How can I turn a string into a variable?

Evaluation and Non-Standard Evaluation and Language Objects and Quoting

7.22

Why do lattice/trellis graphics not work?

Grid Graphics and Lattice

7.23

How can I sort the rows of a data frame?

Data Frames

7.24

Why does the help.start() search engine not work?

Getting Help and Documentation

7.25

Why did my .Rprofile stop working when I updated R?

Sessions, Workspaces and Startup

7.26

Where have all the methods gone?

S3 Classes and Generics and Getting Help and Documentation

7.27

How can I create rotated axis labels?

Not treated in a section; see Base Graphics and Devices for par() and text()

7.28

Why is read.table() so inefficient?

Reading and Writing Text Data

7.29

What is the difference between package and library?

Packages, Libraries and Namespaces

7.30

I installed a package but the functions are not there

loading and attaching

7.31

Why doesn’t R think these numbers are equal?

Floating-point equality and Numbers and Arithmetic

7.32

How can I capture or ignore errors in a long simulation?

Conditions and Error Handling

7.33

Why are powers of negative numbers wrong?

Numbers and Arithmetic

7.34

How can I save the result of each iteration in a loop into a separate file?

file.path, not paste

7.35

Why are p-values not displayed when using lmer()?

Statistical Models

7.36

Why are there unwanted borders, lines or grid-like artifacts when viewing a plot saved to a PS or PDF file?

Not treated: a viewer problem of PostScript and PDF output; see Base Graphics and Devices

7.37

Why does backslash behave strangely inside strings?

Strings and Regular Expressions

7.38

How can I put error bars or confidence bands on my plot?

Not treated: it names add-on packages; see Base Graphics and Devices and ggplot2 and the Grammar of Graphics

7.39

How do I create a plot with two y-axes?

Base Graphics and Devices

7.40

How do I access the source code for a function?

Getting Help and Documentation

7.41

Why does summary() report strange results for the R^2 estimate when I fit a linear model with no intercept?

Statistical Models and Formulas

7.42

Why is R apparently not releasing memory?

Memory and Copy Semantics

7.43

How can I enable secure https downloads in R?

HTTP and Web APIs

7.44

How can I get CRAN package binaries for outdated versions of R?

Not treated in a section; the repos option is described in Packages, Libraries and Namespaces

Version notes

The behaviour of the pitfalls changed over the years, and the changes explain which advice is old. The entries come from the R NEWS files of the releases (R NEWS); those before R 4.0.0 are in the R 3.x NEWS (R NEWS: Changes in R 3.x). The changes from R 4.0.0 on, with before and after code for each, are collected on What’s New and Migrating Older Code.

Version Change

R 3.1.1

The warning for partial matching with $ on data frames is given only when options("warnPartialMatchDollar") is TRUE.

R 3.4.0

Assigning to an element of a vector beyond its length over-allocates by a small fraction, so building a vector element by element that way is cheaper; pre-allocating is still preferred and the implementation is subject to change.

R 3.5.0

isTRUE() is more tolerant (a named TRUE counts) and isFALSE() is new. on.exit() gains after, and with add = TRUE all the expressions run even if one signals an error. sum() of integer or logical vectors no longer overflows: it returns a double in such cases.

R 3.6.0

The default method of generating a discrete uniform sample, as used by sample(), changed (rejection sampling); RNGkind() and RNGversion() request the old one.

R 4.0.0

stringsAsFactors = FALSE is the default of data.frame() and read.table(). stopifnot() allows customised messages through argument names.

R 4.2.0

if() and while() with a condition of length greater than one are an error instead of a warning. && and || with an operand of length greater than one give a warning. default.stringsAsFactors() is formally deprecated.

R 4.3.0

&& and || with an operand of length greater than one are an error. default.stringsAsFactors() is defunct.

R 4.4.0

use() is available (it is announced in the R 4.5.0 NEWS, which says that it was already available since R 4.4.0).

Where older tutorials differ

The advice below is still found in books and posts from before 2020. The right-hand columns say what to do now.

Older tutorial writes Current practice Why Since

for (i in 1:length(x))

seq_along(x) and seq_len(n)

the colon counts down when x is empty

always

na.rm = T, if (T)

na.rm = TRUE

T and F are variables that can be assigned

always

options(stringsAsFactors = FALSE) as the first line of a script

nothing; factor() where a factor is meant

the default is FALSE and the option is deprecated

R 4.0.0

as.numeric(f) on a factor read from a file

keep the column numeric or character; as.numeric(as.character(f)) or as.numeric(levels(f))[f] for a factor

as.numeric() returns the codes (R FAQ 7.10)

always

attach(df) to avoid typing df$

with(df, expr), df$col, data =

the attached copy goes stale and hides or is hidden by other names; use() attaches only the names you list

always; use() R 4.4.0

A setwd call with an absolute path of one machine at the top of a script

a project or Rscript started in the project folder; file.path() for paths

a path of one machine; global state

always

rm(list = ls()) at the top of a script "to start clean"

a fresh R process

it removes objects only, not options, packages or attached data (R FAQ 7.3 documents what it does)

always

sapply(x, f)

vapply(x, f, numeric(1)) or lapply(x, f)

type and shape depend on the data

always

result <- c(result, value) in a loop

preallocate, vapply() or vectorise

each step copies the vector

R 3.4.0 made [[<- growth cheaper

if (x == y) with vectors, relying on a warning

if (isTRUE(all.equal(x, y))), identical(), any(), all()

the condition must be one TRUE or FALSE

R 4.2.0 (error)

x == 0.3 for computed doubles

isTRUE(all.equal(x, 0.3)) or a tolerance

binary fractions (R FAQ 7.31)

always

sample(x, 1) when x can have length one

x[sample.int(length(x), 1)]

sample() reinterprets a single number (the help page)

always

source("file.R") expecting printed output

source("file.R", echo = TRUE) or print()

R FAQ 7.16

always

return(x) as the last line of every function

the value as the last expression

the style guide; return_linter

always

The tidyverse style guide (https://style.tidyverse.org/) and Advanced R (https://adv-r.hadley.nz/) are community references that cover the same ground from the point of view of style and of the language; they are named here and not used as sources of the facts on this page.

Where to go next