R Programming Interview Questions · 2026

R Programming Interview Questions (2026): 50 Q&A

A biostatistics team lead told me she stopped asking "what's a vector in R" years ago, because every candidate has the definition memorized and it tells her nothing. What she asks instead: what happens when you run c(1, "two", TRUE), and why. The candidates who've actually debugged a real R script answer in one sentence, coercion to character, because a vector can only hold one type. The candidates who've only read a tutorial start guessing. R is still the language of record in academic statistics, clinical trials, and a good chunk of applied econometrics, and CRAN currently hosts more than 22,000 packages (CRAN package archive), which is exactly why R interviews rarely stay on syntax for long. There's too much surface area, so interviewers probe for whether you understand why the language behaves the way it does.

Here's the pattern across the real questions companies actually ask: fundamentals about vectors, data frames, and factors show up first as a filter, then things get specific fast. NA versus NULL versus NaN. The apply family versus a for loop, and whether you can explain the tradeoff instead of just reciting "apply is faster." Lazy evaluation and environments, which trip up people who learned R from a stats class and never had to reason about how a function argument actually gets evaluated. S3 versus S4, which most working R users can use but far fewer can explain. R's own language documentation is the most reliable source for the mechanics here (R Language Definition, CRAN), and it's worth reading directly rather than trusting a paraphrase, because a lot of interview prep content gets the NA/NULL distinction subtly wrong.

This page covers R interview questions across eight areas: vectors and core data types, data frames and factors, the three flavors of missing data, the apply family versus loops, functions and scoping, S3 versus S4 versus R6 object systems, environments and memory, and where base R still beats or loses to the tidyverse. Every code example is real R, run through in my head against actual R semantics, not invented syntax.

50Questions
22,000+CRAN Packages
Atomic VectorsCore Structure
R 4.1Native Pipe Since

Vectors and the basic data types R interviews open with

Almost every R loop starts near here. It's fast, but a wrong answer on coercion or recycling tells an interviewer everything they need to know in under a minute.

Easy questions

15

They all coerce to the same type, because a vector in R can only hold one atomic type at a time. R picks the type that can represent every element without losing information, following a fixed hierarchy: logical gets promoted to integer, integer to double, double to complex, and anything to character last, since character can represent all of them as text.

r
x <- c(1, "two", TRUE)
x
# [1] "1"  "two" "TRUE"
class(x)
# [1] "character"

The moment a single character string enters the vector, everything else in it becomes character too. This is the single most common "gotcha" question in an R screen, and it's a fair one, because it explains a whole class of real bugs where a column silently turns into text after a bad merge.

When you apply an operation to two vectors of different lengths, R recycles, repeating the shorter vector from the start until it matches the longer one's length. If the longer vector's length is an exact multiple of the shorter one's, this works exactly as intended and is genuinely convenient. If it isn't a multiple, R still does it, but issues a warning rather than an error.

r
c(1, 2, 3, 4) + c(1, 2)
# [1] 2 4 4 6, recycled cleanly, no warning

c(1, 2, 3, 4) + c(1, 2, 3)
# [1] 2 4 6 5
# Warning: longer object length is not a multiple of shorter object length

The dangerous case is when someone runs code that emits the warning, ignores it because warnings scroll by constantly in a real analysis, and ships a data pipeline that's silently misaligned. I've seen a join produce exactly this bug where two columns were off by one row.

A vector holds elements of one atomic type only, character, numeric, logical, integer, complex, or raw, and every element gets coerced to match if you mix types. A list can hold anything in each slot, including other lists, data frames, functions, or vectors of different types and lengths, because a list doesn't require its elements to share a type at all.

r
v <- c(1, 2, 3)     # atomic vector, all numeric
l <- list(1, "a", TRUE, c(1, 2, 3)) # list, mixed types, no coercion
l[[2]]
# [1] "a", still a string, no coercion happened

A data frame is, internally, a list of equal-length vectors. That single fact answers a lot of "why does this data frame behave weirdly" questions once it clicks.

Single bracket [] always returns the same type of object you indexed into, so subsetting a list with [] gives back a smaller list, even if you only ask for one element. Double bracket [[]] extracts the actual element itself, unwrapped from the list container.

r
l <- list(a = 1, b = "text", c = TRUE)
l[1]   # list(a = 1), still a list, length 1
l[[1]]  # 1, the actual numeric value
class(l[1])  # "list"
class(l[[1]]) # "numeric"

This is the source of a very common real bug: passing l[1] into a function expecting a raw numeric value, and getting a confusing type error two calls deep instead of at the point where the mistake actually happened.

Inside a function call, = is reserved for naming an argument and can't be used for general assignment, while <- assigns a value to a name anywhere, including inside a function call's arguments, where it has a different meaning than = entirely. Outside a function call, at the top level or inside a function body, both work for assignment in the common case, but they're not fully interchangeable.

r
mean(x = c(1, 2, 3))   # x names the argument, doesn't create a variable x
mean(x <- c(1, 2, 3))  # creates a variable x in the calling environment, then passes it

Most style guides, including the tidyverse style guide, recommend <- for assignment and reserve = strictly for naming arguments, precisely to avoid this ambiguity.

str() gives a compact, nested view of an object's structure, its type, dimensions, and a preview of its actual values, all in one line per level, which is what makes it the fastest way to sanity-check an unfamiliar object. class() only tells you the object's class label, and summary() gives statistical summaries that assume you already know roughly what kind of object you're looking at.

r
df <- data.frame(id = 1:3, name = c("a", "b", "c"), active = c(TRUE, FALSE, TRUE))
str(df)
# 'data.frame': 3 obs. of 3 variables:
# $ id  : int 1 2 3
# $ name : chr "a" "b" "c"
# $ active: logi TRUE FALSE TRUE

I default to running str() on anything returned by an unfamiliar function before doing anything else with it. It catches type surprises, like a column that came back as a factor instead of character, before they become a confusing downstream error.

A data frame is a list of vectors of equal length, each vector becoming one column, displayed and indexed like a table. Because it's a list underneath, each column can be a different atomic type, unlike a matrix, where every cell must share one type.

r
df <- data.frame(id = 1:3, score = c(9.1, 7.4, 8.8), pass = c(TRUE, TRUE, FALSE))
is.list(df)  # TRUE
df$score   # extracts the column like a list element

A factor stores categorical data as integer codes internally, paired with a set of character labels called levels, so R can represent a fixed, known set of categories, like a survey response scale, without repeating the same strings in memory for every row. Statistical models in R, like lm(), rely on factors to know how to build dummy variables for categorical predictors.

r
f <- factor(c("low", "high", "medium", "low"), levels = c("low", "medium", "high"))
as.integer(f)  # 1 3 2 1, the underlying integer codes
levels(f)    # "low" "medium" "high"

NULL represents the complete absence of a value or an empty object, there is exactly one NULL in R and every reference to it points to that same object. NA represents a missing value within a vector that otherwise has a defined length and type, a placeholder for "unknown," and it actually comes in several typed variants (NA_integer_, NA_character_, and so on) even though they usually print the same way. NaN means "not a number," the result of an undefined arithmetic operation like 0/0, and it only exists for double and complex types.

r
length(NULL)  # 0, it's not even a value slot
length(NA)   # 1, it occupies a real position in a vector
0/0      # NaN
is.na(NaN)   # TRUE, NaN also counts as NA for most purposes
is.nan(NA)   # FALSE, but NA does not count as NaN

That last pair of lines is the exact detail that separates memorized trivia from real understanding: is.na() treats NaN as missing, but is.nan() does not treat a plain NA as NaN. The relationship only goes one direction.

NA represents an unknown value, so comparing anything to NA with ==, including NA to itself, returns NA, not TRUE or FALSE, because the answer to "is this unknown thing equal to that unknown thing" is itself unknown. Indexing a vector with a logical vector full of NA returns NA in every one of those positions rather than skipping them.

r
x <- c(1, NA, 3)
x == NA     # NA NA NA, useless for filtering
x[x == NA]    # NA NA NA, not what you wanted
is.na(x)     # FALSE TRUE FALSE, the correct check
x[is.na(x)]   # NA, correctly finds the missing element

lapply() always returns a list, one element per input, regardless of what the function returns. sapply() calls lapply() underneath, then tries to simplify the result into a vector or matrix if every element comes back the same length and type, but silently falls back to a list if it can't. vapply() does the same simplification as sapply(), but you specify the expected output type and length up front, and it errors immediately if any call doesn't match, instead of silently returning something different.

r
lapply(1:3, function(x) x^2)     # list(1, 4, 9)
sapply(1:3, function(x) x^2)     # c(1, 4, 9), simplified to a vector
vapply(1:3, function(x) x^2, numeric(1)) # c(1, 4, 9), with a type guarantee

I ask this because vapply() is the one candidates almost never use in real code even after they can define it correctly, which is a small but real signal about whether someone writes defensive production R or exploratory analysis scripts.

Not inherently, no. Beneath that, most of the apply family is still implemented as a loop in C or in R itself, and modern R for loops that avoid growing a vector inside the loop body perform comparably. The real advantage of apply functions is readability and avoiding a specific, very common for-loop mistake, not raw speed.

r
# Slow: grows the vector every iteration, reallocating memory each time
result <- c()
for (i in 1:10000) {
 result <- c(result, i^2)
}

# Fast: pre-allocated, same underlying cost as sapply
result <- numeric(10000)
for (i in 1:10000) {
 result[i] <- i^2
}

The honest answer here matters more than the folklore version. A candidate who says "apply is always faster, use it instead of loops" hasn't actually benchmarked anything. A candidate who explains that the real cost is growing a vector inside a loop has.

library() throws an error and stops execution if the package isn't installed. require() returns FALSE instead of erroring, letting a script keep running and handle the missing package itself, typically by checking the return value and installing it conditionally.

r
library(nonexistent_package)  # Error, script stops here
if (!require(nonexistent_package)) {
 install.packages("nonexistent_package") # gracefully handled instead
}

Both operators do the same core thing: take the result on the left and feed it in as the first argument of the function call on the right, letting you chain a sequence of transformations left to right instead of nesting function calls inside each other. %>% came from the magrittr package years before R itself had any pipe syntax, and became so widely used across the tidyverse that R added a native |> operator directly into the language in R 4.1.0, so pipe-style code no longer needs any package dependency at all.

r
# magrittr pipe, needs library(magrittr) or library(dplyr)
result <- data %>% filter(x > 0) %>% summarize(total = sum(y))

# native pipe, R 4.1+, base R, zero dependencies
result <- data |> filter(x > 0) |> ((d) summarize(d, total = sum(y)))()

The native pipe has fewer features than %>%, notably no built-in placeholder support until later point releases added _, but it means a package can offer a pipe-friendly API without forcing every downstream user to also install magrittr.

pkg::function() calls a function from a specific package by full path, without loading the entire package's namespace onto the search list. It works whether or not the package is already attached with library(), as long as the package is installed, and it's the standard way to call a function once, in a script or inside another package's own source code, without pulling in every other function and object that package would otherwise attach.

r
dplyr::filter(df, x > 0)  # calls filter from dplyr specifically, no library(dplyr) needed
stats::filter(x, rep(1, 3)) # disambiguates from dplyr's filter of the same name

That second example is the real-world reason this matters: both stats and dplyr export a function called filter, and whichever package got attached most recently wins if you call filter() unqualified. Package authors use :: specifically to avoid this exact collision inside their own code.

Medium questions

25

R has two numeric atomic types, integer and double, but a bare numeric literal like 1 is stored as a double, not an integer, unless you explicitly suffix it with L. Doubles are 64-bit floating point, which is why they carry the usual floating-point precision caveats, and integers are a genuinely separate, smaller type used mostly for indexing and memory efficiency.

r
class(1)   # "numeric" (double)
class(1L)  # "integer"
is.integer(1)  # FALSE
is.integer(1L) # TRUE
0.1 + 0.2 == 0.3 # FALSE, classic floating-point comparison issue

The floating-point comparison trap is universal across languages, but candidates who've only used R interactively for stats work often haven't hit it, because most analysis code compares ranges or uses all.equal() rather than exact equality on doubles.

1:length(x) breaks silently on an empty vector, because length(x) is 0 and 1:0 produces c(1, 0), a descending sequence of length two, not an empty sequence as intended. seq_along(x) handles the empty case correctly, returning an actual empty integer vector, so the loop body simply never executes.

r
x <- c()
1:length(x)   # 1 0, the loop runs twice with wrong values
seq_along(x)   # integer(0), the loop correctly runs zero times

It's a small thing until it isn't. This exact bug shows up in production R scripts that assumed a filtered dataset would always have at least one row, and then silently misbehaved the one time it didn't.

Older R versions defaulted data.frame() and read.csv() to stringsAsFactors = TRUE, silently converting every character column into a factor on read. That made sense for the modeling-heavy workflows R was originally built around, but it caused constant surprise for anyone doing general data manipulation, since a factor doesn't behave like a plain string for concatenation, regex, or comparison against a fresh string value.

r
# Pre-R 4.0.0 default behavior
df <- data.frame(name = c("alice", "bob"))
class(df$name) # "factor" before R 4.0.0

# R 4.0.0 onward, default flipped to FALSE
class(df$name) # "character" from R 4.0.0 onward

R 4.0.0, released in April 2020, flipped the default to FALSE, which is one of the more consequential quiet changes in the language's history, since it silently changed the behavior of a huge amount of existing code that relied on the old default.

is.na() applied to a whole data frame returns a matrix of logicals the same shape as the data frame, which you can summarize with colSums() to get a per-column missing count. complete.cases() instead returns one logical per row, telling you whether that entire row has zero missing values across all columns, which is what you actually want before dropping rows.

r
df <- data.frame(a = c(1, NA, 3), b = c(NA, 2, 3))
colSums(is.na(df))    # a: 1, b: 1
df[complete.cases(df), ] # only the row where neither a nor b is NA

merge() handles all four join types through the all.x and all.y arguments, but its default row order isn't guaranteed to match either input, and its column-name collision handling adds .x and .y suffixes automatically, which surprises people the first time. dplyr's left_join(), inner_join(), and friends are named explicitly per join type, preserve the row order of the left-hand table, and generally run faster on larger data frames.

r
merge(df1, df2, by = "id", all.x = TRUE)  # base R left join
dplyr::left_join(df1, df2, by = "id")   # equivalent, explicit intent

The honest answer here is that both work fine for most sizes of data. I'd rather hear a candidate explain the tradeoff than declare one objectively correct, because plenty of production R scripts still use base merge() deliberately to avoid a tidyverse dependency.

When you subset a data frame, factor columns keep every level that existed in the original data, even levels with zero rows left in the subset. droplevels() strips out any level with no remaining observations. Forgetting this matters most when you're building a model or a plot per group in a loop, since a factor still carrying an empty level can throw off group counts, plot legends, or a model matrix that expects only observed categories.

r
df <- data.frame(group = factor(c("a", "b", "c")))
sub <- df[df$group != "c", ]
levels(sub$group)       # "a" "b" "c", c is still listed
levels(droplevels(sub)$group) # "a" "b", c correctly dropped

read.csv() in base R is slower on large files, guesses column types with heuristics that sometimes get things wrong silently, and historically defaulted to converting strings to factors before R 4.0.0. readr::read_csv() parses faster on large files, returns a tibble instead of a data frame, prints an explicit column type specification so you can see exactly what it inferred, and never silently converts strings to factors.

r
read.csv("data.csv")      # data.frame, slower, older type-guessing rules
readr::read_csv("data.csv")   # tibble, faster, prints inferred column spec

The printed column spec is the underrated feature here. It turns a silent type-guessing decision into something you actually see and can catch before it causes a downstream bug.

na.rm = TRUE tells the function to drop NA values before computing, so mean(c(1, NA, 3), na.rm = TRUE) computes the mean of just 1 and 3. It defaults to FALSE deliberately: R's designers decided that silently ignoring missing data and returning a plausible-looking number is more dangerous than forcing you to explicitly acknowledge missingness exists in your data before the function proceeds.

r
mean(c(1, NA, 3))        # NA, forces you to notice the missing value
mean(c(1, NA, 3), na.rm = TRUE) # 2, computed on the non-missing values only

I want to hear a candidate defend this default, not just describe it. A function that silently drops missing values by default hides data quality problems, and R's choice here is a genuinely opinionated, defensible design decision.

Assigning NULL to a list element with l[["key"]] <- NULL removes that element from the list entirely, shrinking its length. Doing the equivalent to a data frame column, df$col <- NULL, likewise deletes that column completely, since a data frame is a list beneath that. This is different from setting an element to NA, which keeps the element or column in place but marks its value as missing.

r
l <- list(a = 1, b = 2, c = 3)
l[["b"]] <- NULL
names(l)  # "a" "c", b is gone entirely, not present-but-NA

df <- data.frame(x = 1:3, y = 4:6)
df$y <- NULL
names(df) # "x", y column deleted

apply() works on a matrix or array, and MARGIN tells it which dimension to iterate over: MARGIN = 1 applies the function across each row, MARGIN = 2 applies it across each column, and MARGIN = c(1, 2) applies it to every individual cell.

r
m <- matrix(1:6, nrow = 2)
apply(m, 1, sum)  # sum of each row
apply(m, 2, sum)  # sum of each column

sapply() iterates over a single input, calling the function once per element. mapply() iterates over multiple input vectors in parallel, calling the function with corresponding elements from each vector at the same time, which is what you want when a computation depends on more than one varying argument.

r
mapply(function(x, y) x + y, c(1, 2, 3), c(10, 20, 30))
# 11 22 33, pairs elements from both vectors positionally

Map() is a thin wrapper around mapply() with SIMPLIFY = FALSE hardcoded, so it always returns a list rather than trying to simplify the result into a vector or matrix. That predictability is exactly why it shows up as the base-R equivalent underlying purrr's map() family, functions people expect a consistent list return type from, without the surprise of silent simplification changing the output shape depending on the data.

r
Map(function(x, y) x + y, list(1, 2), list(10, 20))
# list(11, 22), always a list, no silent simplification

sapply()'s simplification behavior depends on the shape of the data at runtime, which is exactly the problem: it can return a vector on one call and a list on another call with slightly different input, silently changing the type your downstream code has to handle. In interactive, one-off analysis that's a minor inconvenience you notice immediately. In a function buried inside a package or an automated pipeline, it's a latent bug that only surfaces on some future edge-case input.

r
sapply(list(1, 2), identity)     # c(1, 2), a numeric vector
sapply(list(1, "a"), identity)    # list(1, "a"), silently falls back to a list

vapply() exists specifically to close this gap, by forcing you to declare the expected shape up front so the failure happens loudly at the call site instead of quietly three functions downstream.

Lexical scoping means a function looks up a free variable, one not defined locally or passed as an argument, in the environment where the function was defined, not the environment it happens to be called from. Under dynamic scoping, the lookup would instead depend on the calling context, so the same function could resolve a free variable differently depending on who called it and from where.

r
y <- 10
f <- function() y
g <- function() {
 y <- 20
 f()  # still returns 10, because f looks up y in its own definition environment
}
g()

This makes R's behavior predictable from reading the function's source code alone, without having to trace every call site, which is exactly the property lexical scoping is designed to guarantee.

R doesn't evaluate a function's arguments the moment the function is called. Instead, each argument gets wrapped in a promise, an object holding the unevaluated expression plus a pointer to the environment it should be evaluated in, and R only forces that evaluation the first time the argument is actually accessed inside the function body. Once forced, the result is cached, so a promise never gets evaluated twice.

r
f <- function(x, y) {
 if (missing(y)) return("y never touched")
 x + y
}
f(1) # "y never touched", the missing y is never evaluated, no error
f(1, stop("boom")) # only errors if the function body actually references y

This is why you can pass an argument that would throw an error if evaluated, and the function still runs fine, as long as the function body never actually touches that argument.

Default argument expressions are evaluated inside the function's own execution environment, not the caller's, so a default can legally reference another parameter of the same function, since by the time it's actually needed, that parameter will already have a value bound in that environment. It fails specifically when the referenced parameter also depends on a default and neither one has an actual value supplied yet, which R resolves in the order the defaults are actually forced, not in the order they're written.

r
f <- function(x, y = x * 2) {
 x + y
}
f(3)  # 9, y's default (x * 2) resolves fine because x = 3 is already known

<- always creates or updates a variable in the current, local environment. <<- searches up through the chain of enclosing environments for an existing variable with that name, and assigns to the first one it finds, only creating a new global variable as a last resort if no matching variable exists anywhere up the chain.

r
counter <- function() {
 n <- 0
 function() {
  n <<- n + 1 # modifies n in the enclosing environment, not a new local n
  n
 }
}
tick <- counter()
tick() # 1
tick() # 2, n persists between calls because <<- found and updated the enclosing n

This exact pattern, a closure using <<- to maintain state across calls, is how you build a counter or a memoized function in R without reaching for a global variable, and it's a good follow-up question after a candidate defines what a closure is in the abstract.

S3 classes are just a "class" attribute attached to any ordinary R object, nothing enforces that the object actually has the fields a method expects. Dispatch works by generic functions, like print() or summary(), calling UseMethod(), which then looks for a function named generic.classname and calls that instead, falling back to generic.default if no specific method exists for the object's class.

r
make_dog <- function(name) {
 structure(list(name = name), class = "dog")
}
print.dog <- function(x,...) cat("Woof, I'm", x$name, "n")

d <- make_dog("Rex")
print(d)  # dispatches to print.dog, prints "Woof, I'm Rex"

Nothing stops you from assigning class "dog" to a plain integer with no name field at all, and print.dog() would just error at runtime when it tried to access a field that isn't there. That's the exact tradeoff S4 exists to close.

S4 classes are defined formally with setClass(), declaring named slots and their required types up front, so an object simply can't be constructed with a field of the wrong type, R errors immediately at construction time. setValidity() goes a step further, letting you enforce logical constraints between slots, not just their individual types, and that check runs automatically every time new() builds an object of that class.

r
setClass("Range", representation(low = "numeric", high = "numeric"))
setValidity("Range", function(object) {
 if (object@low > object@high) "low must not exceed high" else TRUE
})
new("Range", low = 10, high = 5)
# Error: invalid class "Range" object: low must not exceed high

This is the exact behavior S3 has no built-in mechanism for at all. An S3 object with an internally inconsistent state just exists silently until something downstream breaks on it.

Multiple dispatch means a method gets selected based on the classes of more than one argument, not just the first one the way S3's UseMethod() works. It matters whenever an operation's behavior genuinely depends on the combination of two object types, arithmetic between two different unit-typed quantities, or a collision check between two different shape classes, where no single argument's type alone tells you which implementation to run.

r
setGeneric("combine", function(x, y) standardGeneric("combine"))
setMethod("combine", signature("numeric", "character"), function(x, y) paste(x, y))
setMethod("combine", signature("numeric", "numeric"), function(x, y) x + y)

combine(5, "apples") # dispatches on both argument types: "5 apples"
combine(5, 3)     # dispatches to the other method: 8

Almost every object in R, vectors, lists, data frames, follows copy-on-modify semantics: passing one into a function and changing it inside creates a local copy, leaving the caller's original untouched. Environments are the deliberate exception, they're mutable reference objects, so a function that modifies an environment passed to it changes that same environment everywhere else it's referenced, including back in the caller.

r
modify_list <- function(l) { l$x <- 99; l }
l <- list(x = 1)
modify_list(l)
l$x  # still 1, the original list was never touched

modify_env <- function(e) { e$x <- 99 }
e <- new.env()
e$x <- 1
modify_env(e)
e$x  # 99, the same environment was mutated in place

Vectors in R have a fixed length once created. Every call like result <- c(result, new_value) allocates an entirely new, larger vector, copies every existing element into it, and only then appends the new one, discarding the old vector for garbage collection. Do that inside a loop running thousands of times and you're re-copying an ever-growing amount of data on every single iteration, which is quadratic work for what should be linear.

r
# O(n^2) total work: reallocates and copies on every iteration
x <- c()
for (i in 1:5000) x <- c(x, i)

# O(n) total work: one allocation up front, then in-place updates
x <- numeric(5000)
for (i in 1:5000) x[i] <- i

Pre-allocating with numeric(), vector(), or character() at the correct final length before the loop starts is the single highest-value performance fix in beginner R code, and it's a fair, common interview question precisely because it's so cheap to fix once you understand why it's slow.

gc() triggers R's garbage collector immediately, reclaiming memory used by objects no longer referenced anywhere, and returns a report of memory usage before and after. R already runs garbage collection automatically whenever it needs more memory and has to decide whether to free unused objects first, so calling gc() manually doesn't usually change the total memory your session ends up using, it just changes when the collection happens.

r
x <- rnorm(1e7)
rm(x)
gc() # forces immediate collection, returns a memory usage table

The one legitimate reason to call it manually is benchmarking, where you want a clean memory baseline before timing a block of code, not because your script is actually leaking memory that only manual gc() can fix.

A tibble is a modern reimagining of the data frame, defined in the tibble package, that never converts strings to factors, never partially matches column names on $ extraction the way a plain data frame does, and prints only the first ten rows with each column's type labeled instead of dumping the entire table to console. At the implementation level, a tibble is still technically a data frame, it just adds stricter, more predictable behavior on top.

r
df <- data.frame(customer_name = "Alice")
df$customer  # partial match works on a data.frame, returns "Alice", silently
tb <- tibble::tibble(customer_name = "Alice")
tb$customer  # NULL on a tibble, partial matching is deliberately disabled

That partial-matching difference is a real, common bug source in base R scripts, a typo'd or abbreviated column name silently "working" instead of throwing an error, and it's exactly the kind of gotcha a tibble is designed to close off.

Functionally, for most common cases, not much, both filter rows and select columns and can produce the same result. The real differences: dplyr's verbs use non-standard evaluation, so you reference column names directly without quoting them or prefixing the data frame name every time, and dplyr's pipeline style composes multiple operations more readably than nested base R subsetting brackets. Base R subsetting has no external dependency and is occasionally faster for very simple, single-condition filters on smaller data.

r
# Base R
df[df$age > 30 & df$active == TRUE, c("name", "age")]

# dplyr
df |> dplyr::filter(age > 30, active == TRUE) |> dplyr::select(name, age)

I don't think there's a universally correct answer here, and I'd be skeptical of a candidate who insists there is one. Plenty of production R at pharma and clinical trial companies is still base-R-only by policy, precisely to minimize dependency surface area in a regulated environment.

Hard questions

10

R follows IEEE 754 floating-point arithmetic for its double type, and under that standard, subtracting infinity from infinity is mathematically indeterminate, since there's no single defined answer, it could represent any value depending on how each infinity was reached. IEEE 754 defines this case, along with 0/0 and Inf * 0, as producing NaN rather than raising an error or arbitrarily picking a result.

r
Inf - Inf  # NaN
Inf / Inf  # NaN
Inf + 1   # Inf, this one is well-defined
1 / 0    # Inf, also well-defined under IEEE 754

Candidates who've done real numerical work, simulations, optimization, anything with iterative fitting, have usually hit a NaN propagate silently through a long calculation and know to check for it early rather than at the very end when the whole result is corrupted.

apply() and sapply() don't validate the shape of each call's return value against the others as they go, they just try to combine everything at the end and either succeed, fall back to a list, or throw a somewhat generic combination error that doesn't point at which element caused the problem. vapply() checks each call's return value against the FUN.VALUE template you supplied immediately, so the error message names the exact index that violated the expected type or length the moment it happens.

r
f <- function(x) if (x > 1) c(x, x) else x
sapply(1:3, f)
# returns a list, silently, because the shapes don't match, no error at all

vapply(1:3, f, numeric(1))
# Error in vapply(1:3, f, numeric(1)) :
#  values must be length 1, but FUN(X[[2]]) result is length 2

This is the difference between a bug that shows up as a confusing downstream type error and one that fails loudly with a line number, and it's the single strongest practical argument for reaching for vapply() in anything beyond a throwaway script.

A closure is a function bundled together with the environment it was created in, so it keeps access to variables from that enclosing environment even after the outer function that created it has already finished running. In R specifically, this works because environments aren't garbage collected while something still references them, so the enclosing environment stays alive as long as the closure itself is reachable, even though the outer function's own call frame is long gone.

r
make_adder <- function(n) {
 function(x) x + n  # this inner function is a closure over n
}
add5 <- make_adder(5)
add5(10) # 15, still remembers n = 5 even though make_adder() already returned

The part that trips people up is realizing that n genuinely still exists in memory somewhere, attached to add5's environment, not copied into add5 as a literal value. You can even confirm it with environment(add5)$n.

match.arg() lets a function's default value for a string parameter list every valid option as a vector, then automatically resolves whatever the caller actually passed, including partial matching, against that list, throwing a clear error if it doesn't match any valid option. It replaces a manual if-else chain checking each valid string, and gives you partial matching and a consistent error message for free.

r
summarize_data <- function(method = c("mean", "median", "sum")) {
 method <- match.arg(method)
 switch(method, mean = mean(x), median = median(x), sum = sum(x))
}
summarize_data("med") # partial match resolves to "median"
summarize_data("mode") # Error: 'arg' should be one of "mean", "median", "sum"

This is a real base-R idiom that shows up constantly in package source code, and it's a good sign when a candidate has actually read package internals rather than only ever calling functions from the outside.

S4 objects, like every base R object, are copy-on-modify: changing a slot creates a new copy of the object rather than mutating the original in place, which matches R's functional roots but is awkward for anything that genuinely needs shared, mutable state, a database connection, a running counter, a cache. R6, a CRAN package rather than a base R feature, implements reference semantics instead, so modifying an R6 object's field actually mutates that same object everywhere else it's referenced, matching the behavior most engineers coming from Python or Java expect from a class.

r
Counter <- R6::R6Class("Counter", public = list(
 count = 0,
 increment = function() {
  self$count <- self$count + 1
  invisible(self)
 }
))
c1 <- Counter$new()
c2 <- c1     # both variables point at the same underlying object
c1$increment()
c2$count     # 1, mutation through c1 is visible through c2 too

That last line is the entire reason R6 exists. The same code written with a base R list or an S4 object would leave c2$count at 0, because c2 <- c1 would have copied the object at that point, not aliased it.

S3 wins on simplicity and interoperability cost: base R itself, and the overwhelming majority of CRAN packages, use S3 for things like data.frame, lm model objects, and ggplot objects, so anyone extending those systems with a new method just writes a function named correctly and it works, no formal class registration required. S4's stricter guarantees come at the cost of more boilerplate per class and a genuinely steeper learning curve, and for a huge share of real package code, the extra safety doesn't pay for that cost, since most bugs S4 would catch get caught in testing anyway on a codebase small enough for one or two maintainers to reason about directly.

r
# Extending an S3 generic from an unrelated package takes one function definition
summary.my_model <- function(object,...) {
 cat("Custom summary for my_modeln")
}

Bioconductor, notably, leans much more heavily on S4 than CRAN does overall, because genomics data structures tend to have real invariants worth enforcing formally, unlike a lot of general-purpose data wrangling code where S3's looseness is a feature, not a gap.

Not by default. S4's dispatch machinery only recognizes classes it knows about, formally registered S4 classes plus a small set of base types it understands natively. To make an S4 generic dispatch correctly on an existing S3 class, you first have to register that S3 class with setOldClass(), which tells the S4 system that the class exists and describes its inheritance chain, after which S4 methods can be written against it like any native S4 class.

r
setOldClass("data.frame")
setGeneric("row_report", function(x) standardGeneric("row_report"))
setMethod("row_report", "data.frame", function(x) cat(nrow(x), "rowsn"))
row_report(mtcars) # now dispatches correctly on the S3 data.frame class

This is the actual bridge that lets Bioconductor packages, which are heavily S4-based, still accept and work with plain data frames and other S3 objects from base R without every function needing a manual type-check branch.

R's default copy-on-modify means a copy only actually happens the moment you modify an object that shares memory with another reference, not the instant you assign it to a new name, R is lazy about the copy for efficiency. The data.table package deliberately opts out of this for its own object type, using reference semantics similar to environments, so operations like dt[, new_col := value] modify the table in place with no copy at all, which is exactly why data.table is dramatically faster than base R or dplyr on very large datasets.

r
library(data.table)
dt <- data.table(x = 1:5)
add_col <- function(d) d[, y := x * 2] # modifies in place, no copy
add_col(dt)
dt$y # column y exists in dt, even though dt was never reassigned

This is also exactly why data.table trips up people used to base R or dplyr's copy semantics: a function that modifies a data.table argument silently mutates the caller's object, which is the opposite of how every other data structure in the language behaves.

Assigning y <- x doesn't copy anything immediately, both names point at the same underlying memory, R only makes an actual copy the first time either name is used in a way that would modify the shared data. You can inspect this directly with tracemem(), which prints a message the moment a copy actually happens, letting you catch an unexpected copy in code you assumed was operating in place.

r
x <- 1:1e6
y <- x
tracemem(x) # returns an address, and starts tracking copies
y[1] <- 99  # NOW a real copy happens, tracemem prints a message here

This matters most in code processing genuinely large vectors or data frames in a loop, where an unexpected copy you didn't account for can silently double memory usage or add real wall-clock time, and tracemem() is the actual tool for finding exactly where it happens, rather than guessing from profiling output alone.

<<- walks up the chain of enclosing environments looking for an existing variable with that name, and only creates a new variable in the global environment as a last resort if it finds nothing along the way. Used inside a proper closure, that's exactly the intended behavior, updating state the closure was deliberately built to own. Used inside a plain, non-nested function by someone who typo'd <- as <<-, it silently creates or overwrites a global variable with the same name, which can be a genuinely hard bug to trace because nothing errors, the wrong variable just quietly changes somewhere far from where you're looking.

r
total <- 0
add_to_total <- function(n) {
 total <<- total + n  # intended if this closes over a specific total by design
}
add_to_total(5)
total # 5, modified the global by design, or by accident, code alone can't tell you which

The code is identical either way. The only thing that tells you which situation you're looking at is whether the function was deliberately designed to own and update that specific enclosing variable, which is exactly why <<- shows up on a lot of R style guides as something to use sparingly and document when you do.

How to prepare for an R interview in 2026

Read R's own language definition once, front to back, on environments and evaluation (R Language Definition, CRAN). Most interview prep sites paraphrase this document, and the paraphrasing is where subtle errors creep in, especially around NA versus NaN and how lazy evaluation actually resolves default arguments. Then build one small thing that forces you to hit the real gotchas: a data cleaning script that handles a genuinely messy CSV with mixed types per column, a closure-based counter, and one S4 class with a validity check. Run tracemem() on a vector inside a loop at least once so the copy-on-modify behavior stops being an abstract rule and becomes something you've actually watched happen.

Across R interviews we've reviewed at LastRoundAI, the NA versus NULL versus NaN question trips up more candidates with years of actual R experience than the S3 versus S4 question does, which is a little counterintuitive, since object systems sound like the "advanced" topic. My read is that most people's daily R work never forces a precise answer about missingness, because na.rm = TRUE and is.na() quietly handle the common cases without anyone ever needing to reason about the underlying distinction. We don't have an exact number on how often it comes up relative to other questions, only that it shows up often enough across review sessions to flag here specifically.

Get the reps in before the real thing

Knowing the difference between is.na() and is.nan() on paper isn't the same as explaining it out loud when an interviewer changes one line of your code and asks what breaks. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough data science and biostatistics roles that actually test R instead of defaulting every question to Python. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

What R topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Do I need hands-on R experience to pass?

It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.

Is R still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Should I memorise R syntax for the interview?

Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.

What is the most common mistake in R interviews?

Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

Leave a Reply

Your email address will not be published. Required fields are marked *