R Interview Questions and Answers
Last updated:
Check out 45 of the most common R interview questions, then take an AI-powered practice interview
Q1Why does c(1, 'a', TRUE) return a character vector, and how do typeof(), class() and mode() differ?
BasicData Types
Answer
An atomic vector in R can hold exactly one type, so c() silently coerces every element up a fixed hierarchy: logical to integer to double to complex to character. c(1, 'a', TRUE) therefore becomes c('1', 'a', 'TRUE'), all character. c(1, TRUE) becomes c(1, 1) as doubles, and c(1L, 2.5) becomes doubles. This implicit coercion is the single most common source of silent bugs in R pipelines: one stray text value like 'NA' or 'N/A' in a CSV column turns an entire numeric column into character, every downstream mean() returns an error, and the actual cause sits three hundred lines upstream. typeof() reports the internal storage type as R's C code sees it: logical, integer, double, character, complex, list, closure, builtin, environment. class() reports the object-oriented class used for S3 dispatch, which may be an attribute set by the user or an implicit class. mode() is an older, coarser classification kept for S compatibility, where integer and double both report numeric. Interviewers use this question to check whether you know that class() can lie: for a plain double vector typeof() gives double and class() gives numeric, while for a Date object typeof() gives double and class() gives Date. Reach for is.numeric(), is.character() and inherits() rather than string-comparing class(), because class() can return a vector of several classes for tibbles and data.tables.
x <- c(1, 'a', TRUE)
typeof(x) # 'character'
y <- c(1L, 2.5)
typeof(y) # 'double' (integer promoted)
d <- as.Date('2026-08-11')
typeof(d) # 'double'
class(d) # 'Date'
mode(d) # 'numeric'
tb <- tibble::tibble(a = 1)
class(tb) # 'tbl_df' 'tbl' 'data.frame' (a vector!)
# Safe checks
inherits(d, 'Date') # TRUE
is.numeric(unclass(d)) # TRUE
Key Points
- Coercion hierarchy: logical < integer < double < complex < character
- typeof() = storage type, class() = S3 dispatch class, mode() = legacy grouping
- class() can return a vector, so never compare it with ==
- One bad string in a CSV column silently converts the whole column
Q2What is the difference between a list and an atomic vector, and when do you use [ , [[ and $ ?
BasicData Structures
Answer
An atomic vector is a flat, homogeneous sequence: every element shares one type and there is no nesting. A list is a generic vector where each element can be any object, including another list, a function or a data frame. Every data frame in R is in fact a list of equal-length columns with class 'data.frame' and a row.names attribute, which is why length(df) returns the number of columns, not rows.
The three subsetting operators behave differently and the distinction is a standard screening question. Single bracket [ always preserves the container: lst[1] returns a list of length one, df[, 'a', drop = FALSE] returns a data frame. Double bracket [[ extracts the element itself and drops one level of structure: lst[['a']] returns the vector inside.
The dollar operator $ is [[ with partial name matching and no computed names, so df$val will happily return df$value if no exact match exists, which is a real production hazard when a column is later renamed. Tibbles deliberately warn on partial matching and always return a tibble from [ , which is one of the main reasons teams standardise on them. In package code use [[ with the full name and avoid $ entirely on data frames whose column set can change.
lst <- list(a = 1:3, b = 'text', c = list(x = 1))
class(lst[1]) # 'list' (container preserved)
class(lst[[1]]) # 'integer' (element extracted)
lst[['c']][['x']] # 1
df <- data.frame(value = 1:3, label = c('a', 'b', 'c'))
length(df) # 2, the number of columns
df$val # 1 2 3 -> partial matching bites
df[['val']] # NULL -> exact, safe
df[, 'value'] # drops to a vector
df[, 'value', drop = FALSE] # stays a data.frame
Key Points
- Atomic vectors are homogeneous and flat; lists are heterogeneous and nestable
- A data.frame is a list of columns, so length(df) is the column count
- [ preserves the container, [[ extracts the element, $ adds partial matching
- Use [[ with exact names in package code; $ silently partial-matches
Q3How do data.frame, tibble, data.table and matrix differ in practice?
BasicData Structures
Answer
A matrix is an atomic vector with a dim attribute, so every cell shares one type and it is the right structure for numeric linear algebra, correlation matrices and model matrices. A data.frame is base R's rectangular table: a list of columns, each with its own type. Since R 4.0.0 data.frame() no longer converts strings to factors by default, which removed years of stringsAsFactors bugs.
A tibble (from the tibble package, used by the tidyverse) is a data.frame subclass that prints only the first ten rows with column types, never partial-matches names, never simplifies to a vector when you subset a single column, and permits list-columns and non-syntactic names. A data.table (from data.table) is also a data.frame subclass but adds modify-by-reference with :=, keyed binary search joins, indices, and a query syntax DT[i, j, by] that is closer to SQL than to dplyr. Rule of thumb used in most Indian analytics teams: matrices for maths, tibbles for interactive and pipeline work where readability matters, data.table when the dataset is tens of millions of rows or the job is memory constrained, and plain data.frame when you are writing a package with zero dependencies. The interviewer usually follows up with the practical consequence: data.table modifies in place, so passing one into a function and using := changes the caller's object too.
m <- matrix(1:6, nrow = 2) # one type, has dim
df <- data.frame(id = 1:2, tag = c('a','b'))
tb <- tibble::tibble(id = 1:2, tag = c('a','b'))
dt <- data.table::data.table(id = 1:2, tag = c('a','b'))
class(df[, 'id']) # 'integer' -> silently dropped
class(tb[, 'id']) # 'tbl_df' -> stays rectangular
# data.table modifies in place, no copy
data.table::setDT(dt)
dt[, tag := toupper(tag)]
# tibble keeps names untouched, data.frame repairs them
names(data.frame(`my col` = 1)) # 'my.col'
names(tibble::tibble(`my col` = 1)) # 'my col'
Key Points
- matrix = one type + dim attribute, for linear algebra
- R 4.0.0 made stringsAsFactors = FALSE the default for data.frame()
- tibble: no partial matching, no drop to vector, list-columns allowed
- data.table: := modifies by reference, keys give binary-search joins
Q4Explain vectorisation and R's recycling rules. Why is a for loop over rows usually the wrong answer?
BasicVectorisation
Answer
Vectorised functions in R push the element-wise loop down into compiled C code, so x + y, ifelse(), pmax(), cumsum() and rowSums() run one C loop instead of thousands of interpreted iterations. An R-level for loop pays interpreter overhead on every iteration plus, in the classic anti-pattern, a full copy of the growing result object. Recycling is the rule that makes vectorised arithmetic work when operand lengths differ: the shorter vector is repeated until it matches the longer one. c(1, 2, 3, 4) + c(10, 20) gives 11, 22, 13, 24.
If the longer length is not an exact multiple of the shorter one, R still recycles but emits a warning about the argument length not being a multiple, and in some contexts (for example comparing vectors of unequal length inside if()) recent versions turned the old warning into a hard error, since if() now requires a length-one condition. Recycling is convenient for scalars and dangerous for everything else: joining two columns of unequal length or filtering with a stale logical vector produces plausible-looking numbers with no error. The practical answer in an interview is that you replace row loops with vectorised operations, then with a matrix or data.table operation, then with vapply or purrr::map when the operation genuinely is not vectorisable, and only then consider Rcpp.
c(1, 2, 3, 4) + c(10, 20) # 11 22 13 24 (clean recycling)
c(1, 2, 3) + c(10, 20) # 11 22 13 + warning: not a multiple
# Slow: interpreted loop, grows the vector every iteration
res <- c()
for (i in 1:1e5) res <- c(res, i^2)
# Fast: one vectorised C-level operation
res <- (1:1e5)^2
# Row-wise work that is genuinely not vectorisable
row_ok <- vapply(seq_len(nrow(df)), function(i) all(!is.na(df[i, ])), logical(1))
# Usually better still
row_ok <- stats::complete.cases(df)
Key Points
- Vectorised code moves the loop into C, avoiding interpreter overhead
- Shorter operands recycle; non-multiple lengths warn but still compute
- if() requires a length-one condition in recent R versions
- Escalation order: vectorise, then matrix/data.table, then vapply, then Rcpp
Q5What is the difference between NA, NULL, NaN and Inf, and how does na.rm change results?
BasicMissing Data
Answer
NA is a missing value marker that occupies a slot in a vector and carries a type: NA_integer_, NA_real_, NA_character_ and plain logical NA all exist, which matters when you build a vector with rep(NA, n) and then assign character values into it. NULL is the absence of an object, it has length zero and disappears when combined, so c(1, NULL, 3) has length two while c(1, NA, 3) has length three. NaN means not a number, the result of an undefined numeric operation such as 0/0, and it is a double.
Inf and -Inf come from overflow or division by a non-zero number, so 1/0 is Inf and log(0) is -Inf. Two behaviours trip candidates. First, is.na(NaN) returns TRUE while is.nan(NA) returns FALSE, because NaN is treated as a kind of missingness.
Second, comparison with NA propagates rather than tests, so x == NA is always NA and never TRUE, which is why filtering must use is.na(x). Aggregation functions default to na.rm = FALSE, so a single NA turns mean(), sum() and sd() into NA. Blindly setting na.rm = TRUE hides the problem: the denominator silently changes per column, and two columns of a summary table end up computed over different sample sizes. Report the missing count alongside the statistic instead.
length(c(1, NULL, 3)) # 2
length(c(1, NA, 3)) # 3
0/0 # NaN
1/0 # Inf
log(0) # -Inf
is.na(NaN) # TRUE
is.nan(NA) # FALSE
is.finite(c(1, NA, NaN, Inf)) # TRUE FALSE FALSE FALSE
x <- c(2, NA, 4)
x == NA # NA NA NA (never use this)
is.na(x) # FALSE TRUE FALSE
mean(x) # NA
mean(x, na.rm = TRUE) # 3, computed over n = 2
# Report n alongside the statistic
c(mean = mean(x, na.rm = TRUE), n = sum(!is.na(x)))
Key Points
- NA is typed and occupies a slot; NULL has length zero and vanishes
- NaN is a double from undefined maths; Inf is overflow or x/0
- x == NA yields NA, always use is.na()
- na.rm = TRUE silently changes the denominator, so report n as well
Q6How are factors stored, and why does as.numeric(f) on a factor of numbers give the wrong answer?
BasicFactors
Answer
A factor is an integer vector with a levels attribute holding the unique labels and a class attribute of 'factor'. The integers are positions in the levels vector, not the labels themselves. So factor(c('10', '20', '30')) stores 1, 2, 3 with levels '10', '20', '30'.
Calling as.numeric() on it returns the internal codes 1, 2, 3 rather than 10, 20, 30, which is the single most reported R data bug. The correct conversion is as.numeric(as.character(f)), or as.numeric(levels(f))[f] which is faster on large vectors because it converts only the unique levels. Two more factor behaviours come up.
Levels are ordered alphabetically by default under the current locale, so a bar chart of months comes out April first unless you set levels explicitly or use forcats helpers like fct_relevel, fct_reorder and fct_infreq. And subsetting keeps unused levels, so after filtering a data frame to one region, table() still shows every other region with a count of zero until you call droplevels(). Since R 4.0.0 read.csv() and data.frame() no longer create factors automatically, so most factor problems in modern code come from explicit conversion or from packages that still return factors. Keep character data as character and convert to factor only at the modelling or plotting step, where level order actually carries meaning.
f <- factor(c('10', '20', '30'))
as.integer(f) # 1 2 3 (internal codes)
as.numeric(as.character(f)) # 10 20 30
as.numeric(levels(f))[f] # 10 20 30, faster on big vectors
months <- factor(c('Apr', 'Jan', 'Mar'))
levels(months) # 'Apr' 'Jan' 'Mar' -> alphabetical, not calendar
months <- factor(months, levels = c('Jan', 'Mar', 'Apr'))
sub <- droplevels(months[months == 'Jan'])
levels(sub) # 'Jan' only
# Ordered factors compare correctly
g <- factor(c('low','high'), levels = c('low','mid','high'), ordered = TRUE)
g[1] < g[2] # TRUE
Key Points
- Factors are integer codes plus a levels attribute, not strings
- Use as.numeric(as.character(f)) or as.numeric(levels(f))[f]
- Levels sort alphabetically by locale; set them explicitly for plots
- droplevels() removes empty levels left behind after filtering
Q7Compare lapply, sapply, vapply, mapply, Map and Reduce. Why do reviewers insist on vapply in production code?
BasicApply Family
Answer
lapply() applies a function to each element and always returns a list of the same length, which makes it predictable. sapply() calls lapply() and then tries to simplify: it returns a vector when every result has length one, a matrix when every result has the same length greater than one, and a list when results are ragged. That return type depends on the data, so a script that works on a full dataset breaks on an empty subset when sapply() returns list() instead of numeric(0). vapply() takes a FUN.VALUE template, for example numeric(1) or character(1), validates every result against it, and errors immediately if the function returns the wrong type or length. That turns a silent structural change into a loud failure at the exact line, which is why code review in analytics teams asks for vapply in anything scheduled. mapply() and Map() iterate over several vectors in parallel; Map() is mapply() with SIMPLIFY = FALSE, so it always gives a list. apply() works on matrix margins (1 for rows, 2 for columns) and coerces data frames to a matrix first, which quietly turns mixed-type columns into character.
Reduce() folds a binary function across a list, which is the idiomatic way to merge a list of data frames or to chain successive joins. The purrr equivalents (map, map_dbl, map2, pmap, reduce) add type-stable variants and better error messages.
x <- list(a = 1:3, b = 4:6)
lapply(x, mean) # list(a = 2, b = 5)
sapply(x, mean) # named numeric vector
vapply(x, mean, numeric(1)) # same, but type-checked
# sapply on an empty input is the classic trap
sapply(list(), mean) # list() -> breaks downstream maths
vapply(list(), mean, numeric(1)) # numeric(0) -> stays numeric
Map(function(a, b) a * b, 1:3, 4:6) # list of 3
mapply(function(a, b) a * b, 1:3, 4:6) # simplified: 4 10 18
Reduce(function(l, r) merge(l, r, by = 'id'), list(df1, df2, df3))
# apply() coerces a mixed data.frame to character first
apply(data.frame(a = 1, b = 'x'), 1, class) # 'character'
Key Points
- lapply always returns a list; sapply guesses and can change shape
- vapply enforces type and length via FUN.VALUE, failing fast
- Map = mapply with SIMPLIFY = FALSE; Reduce folds a binary function
- apply() coerces data frames to a matrix, damaging mixed types
Q8What is the difference between the native pipe |> and magrittr's %>% ?
BasicLanguage Features
Answer
The native pipe |> arrived in R 4.1.0 and is part of the parser, not a function. The parser rewrites x |> f(y) into f(x, y) at parse time, so there is no runtime overhead, no dependency and no extra frame in the traceback. magrittr's %>% is an ordinary infix function that builds and evaluates an expression at runtime, which adds a small cost per call and extra frames when you debug. The differences that matter in an interview: the native pipe requires a function call on the right side, so x |> print works with x |> print() but not with a bare symbol, whereas %>% accepts x %>% print. magrittr uses the dot . as a placeholder anywhere in the call, including nested positions; the native pipe added the underscore placeholder _ in R 4.2.0, but it only works with a named argument, and R 4.3.0 extended it to allow extraction such as x |> _$col. magrittr also supports the exposition pipe %$%, the assignment pipe %<>% and anonymous-function shorthand with braces, none of which the native pipe offers. For new package code prefer |> because it removes a dependency and works on any R 4.1 or later installation; for interactive tidyverse analysis %>% remains common and both appear in the same codebase without conflict.
# Native pipe: parser-level rewrite, zero runtime cost
mtcars |> subset(cyl == 4) |> nrow()
# Placeholder must name the argument (R 4.2.0+)
mtcars |> lm(mpg ~ wt, data = _)
# Extraction with the placeholder (R 4.3.0+)
mtcars |> _$mpg |> mean()
# magrittr: dot goes anywhere, including nested
library(magrittr)
mtcars %>% lm(mpg ~ wt, data = .) %>% summary()
1:10 %>% setdiff(c(2, 4), .)
# Anonymous functions work in both
1:5 |> (\(v) v^2)()
1:5 %>% { .^2 }
Key Points
- |> is syntax added in R 4.1.0; %>% is a runtime function from magrittr
- Native pipe needs a call on the right side, not a bare function name
- _ placeholder (R 4.2.0) only binds to a named argument; _$col added in 4.3.0
- %$%, %<>% and free-position dots exist only in magrittr
Q9Walk through R's subsetting operators, including the drop = TRUE trap.
BasicSubsetting
Answer
R supports five ways of indexing with single brackets: positive integers select, negative integers exclude, a logical vector selects where TRUE (and recycles if shorter), character vectors select by name, and an empty index selects everything. For two-dimensional objects the form is x[rows, cols], and omitting either side keeps all of that dimension. The trap is the drop argument, which defaults to TRUE for matrices and data frames: if your selection leaves a single column, R silently returns a bare vector instead of a table.
Code that then calls ncol() or names() on the result gets NULL, and a function that worked on a five-column frame errors on a one-column frame. Passing drop = FALSE keeps the dimensions, and this is standard practice in package code. Tibbles removed the behaviour entirely, tb[, 'x'] is always a tibble and tb[['x']] is the vector.
Two more details come up. Logical indexing propagates NA, so x[c(TRUE, NA, FALSE)] returns an NA element rather than skipping it, which is why subset() and dplyr::filter() drop NA rows while bare bracket indexing does not. And negative indices cannot be mixed with positive ones in the same index, x[c(-1, 2)] is an error rather than a clever selection.
m <- matrix(1:6, nrow = 3, dimnames = list(NULL, c('a','b')))
dim(m[, 'a']) # NULL -> dropped to a vector
dim(m[, 'a', drop = FALSE]) # 3 1 -> still a matrix
x <- c(10, 20, 30)
x[-1] # 20 30
x[c(TRUE, FALSE)] # 10 30 (logical recycled)
x[c(TRUE, NA, FALSE)] # 10 NA (NA propagates, row not dropped)
df <- data.frame(a = 1:3, b = 4:6)
subset(df, a > 1) # NA rows dropped
df[df$a > 1, ] # NA rows become all-NA rows
x[c(-1, 2)] # Error: can't mix positive and negative subscripts
Key Points
- Index by position, negation, logical mask, name, or empty
- drop = TRUE is the default and silently collapses single columns
- Logical NA in an index produces an NA row instead of skipping it
- Positive and negative subscripts cannot be mixed in one index
Q10Why should you write seq_along(x) instead of 1:length(x), and when do you need the L suffix?
BasicIdioms
Answer
The colon operator counts in whichever direction it needs to. When x is empty, length(x) is 0 and 1:0 evaluates to c(1, 0), so the loop body runs twice with meaningless indices instead of zero times. seq_along(x) returns integer(0) for an empty input and the loop is skipped, which is the correct behaviour. The same argument applies to seq_len(n) instead of 1:n and to seq_len(nrow(df)) for row loops, and it is a favourite screening question because the failure only shows up on the edge case that reaches production at 2 AM.
The L suffix marks an integer literal: 1L has typeof integer while 1 has typeof double. It matters in three places. Integer vectors use four bytes per element versus eight for doubles, which is significant on hundreds of millions of rows.
Identity checks are strict, so identical(1L, 1) is FALSE even though 1L == 1 is TRUE, and a unit test comparing an integer result against a double literal fails for no obvious reason. And integer arithmetic overflows at .Machine$integer.max (2147483647) returning NA with a warning, whereas doubles keep going with loss of precision, so a row-count product or an ID key computed in integers can go NA on large data. For big whole-number keys use the bit64 package or keep them as character.
x <- character(0)
1:length(x) # 1 0 -> loop runs twice, wrongly
seq_along(x) # integer(0) -> loop is skipped
for (i in seq_len(nrow(df))) { } # safe on a zero-row frame
typeof(1L) # 'integer'
typeof(1) # 'double'
identical(1L, 1) # FALSE
1L == 1 # TRUE
.Machine$integer.max # 2147483647
.Machine$integer.max + 1L # NA with an overflow warning
.Machine$integer.max + 1 # 2147483648 (double, no warning)
Key Points
- 1:0 counts downwards, so 1:length(x) misbehaves on empty input
- seq_along() and seq_len() return integer(0) safely
- 1L is integer, 1 is double; identical() distinguishes them
- Integer overflow at 2147483647 returns NA with a warning
Q11Compare read.csv, readr::read_csv and data.table::fread for loading a large file.
BasicData Import
Answer
read.csv() is base R. It is single-threaded, guesses column types by scanning, and is the slowest of the three by a wide margin on files over a few hundred megabytes. Its useful arguments are colClasses (which skips type guessing and speeds things up considerably), nrows, na.strings, and check.names, which by default mangles column names such as 'my col' into 'my.col'.
Since R 4.0.0 it no longer creates factors, so the old stringsAsFactors = FALSE incantation is redundant. readr::read_csv() returns a tibble, guesses types from the first guess_max rows (1000 by default), reports a column specification you can copy into col_types to make the import reproducible, and collects parsing failures in problems() rather than aborting. Its locale argument handles decimal marks, encoding and date formats, which matters for Indian data that arrives with dd-mm-yyyy dates or lakh-style separators. data.table::fread() is usually the fastest: it is multi-threaded, auto-detects the separator, quoting and header, memory-maps the file, and accepts select and drop to read only the columns you need, which is the biggest single speedup on a wide file. It also reads directly from a shell command or a URL. In interviews the expected answer is fread for volume, read_csv for tidyverse pipelines with strict typing, read.csv only inside dependency-free package code, and Parquet through arrow when the file is read repeatedly.
# Base: force types, skip guessing
read.csv('big.csv', colClasses = c(id = 'character', amt = 'numeric'),
na.strings = c('', 'NA', 'N/A'), check.names = FALSE)
# readr: reproducible spec + inspectable failures
library(readr)
d <- read_csv('big.csv',
col_types = cols(id = col_character(), amt = col_double(),
dt = col_date(format = '%d-%m-%Y')),
locale = locale(tz = 'Asia/Kolkata'))
problems(d)
# data.table: multi-threaded, column pruning
library(data.table)
DT <- fread('big.csv', select = c('id', 'amt'), nThread = 4)
# Repeated reads: convert once to Parquet
arrow::write_parquet(DT, 'big.parquet')
Key Points
- fread is multi-threaded with select/drop for column pruning
- read_csv exposes a copyable col_types spec and problems()
- colClasses removes type guessing and speeds up read.csv
- check.names = TRUE silently rewrites column names in base R
Q12Explain the core dplyr verbs and what the .by argument added in dplyr 1.1 changed about grouping.
Basicdplyr
Answer
The core verbs are filter() for rows, select() for columns, mutate() for new or modified columns, arrange() for ordering, summarise() for aggregation and group_by() for splitting the computation. They compose with the pipe, always take the data frame first and always return a data frame, which is what makes them chainable. Two behaviours cause real trouble.
First, group_by() is persistent: the grouping stays attached to the result and every subsequent mutate() operates within groups until you call ungroup(). Analysts routinely compute a group mean, forget to ungroup, and then get a per-group rather than an overall rank several steps later. Second, summarise() on data grouped by more than one variable drops the last grouping level and prints the message about regrouping output, which surprises people who expect an ungrouped result. dplyr 1.1.0 introduced per-operation grouping with the .by argument, so summarise(df, total = sum(amt), .by = region) groups only for that call and always returns an ungrouped result, no message and no cleanup needed.
The same argument works in mutate, filter and slice. It does not sort the output the way group_by does, which is the one behavioural difference to mention. dplyr 1.1 also brought join_by() for non-equi and rolling joins and the relationship argument that makes accidental many-to-many joins an explicit choice rather than a silent row explosion.
library(dplyr)
# Classic grouping: persistent, needs ungroup()
sales |>
group_by(region, month) |>
summarise(total = sum(amount), .groups = 'drop') |>
arrange(desc(total))
# dplyr 1.1: per-operation grouping, always ungrouped output
sales |> summarise(total = sum(amount), .by = region)
# .by works inside mutate too
sales |> mutate(share = amount / sum(amount), .by = region)
# The classic bug: grouped mutate after a grouped summarise
sales |>
group_by(region) |>
summarise(total = sum(amount)) |> # still grouped by nothing here
mutate(rank = rank(-total)) # correct only because it ungrouped
Key Points
- group_by() persists until ungroup(); .by is scoped to one call
- summarise() peels off the last group and messages about it
- .groups = 'drop' silences the message and returns a flat frame
- dplyr 1.1 added join_by() and the relationship argument for joins
Q13What is the grammar of graphics in ggplot2, and why does a plot built inside a for loop not appear?
Basicggplot2
Answer
ggplot2 builds a plot as a layered specification rather than a set of drawing commands. You start with ggplot(data, aes(...)) which binds data columns to aesthetics (x, y, colour, fill, size, shape, group), then add layers with geom_ functions, then optionally add stats, scales, coordinate systems, facets and themes with the + operator. Because the object is a specification, you can store it in a variable, modify it later and print it whenever you like.
That is exactly why a plot created inside a for loop or inside a function body never appears: at the top level R auto-prints the returned object, but inside a loop or function nothing is auto-printed, so you must call print(p) explicitly. The second recurring confusion is aes() versus a fixed value. Anything inside aes() is mapped from data and generates a legend, so geom_point(aes(colour = 'red')) creates a single-level legend labelled red rather than red points.
To set a constant, put it outside aes(): geom_point(colour = 'red'). Third, facet_wrap(~ var) splits into small multiples with shared scales unless you pass scales = 'free_y'. For interviews at analytics and market research firms, be ready to defend chart choices too: why a boxplot over a bar of means, why you added position = 'dodge' instead of stacking, and how you saved output at print resolution with ggsave(dpi = 300).
library(ggplot2)
p <- ggplot(mtcars, aes(x = wt, y = mpg, colour = factor(cyl))) +
geom_point(size = 2, alpha = 0.8) +
geom_smooth(method = 'lm', se = FALSE, formula = y ~ x) +
facet_wrap(~ am, labeller = label_both) +
labs(x = 'Weight (1000 lbs)', y = 'Miles per gallon', colour = 'Cylinders') +
theme_minimal(base_size = 12)
# Inside a loop nothing auto-prints
for (v in c('wt', 'hp')) {
g <- ggplot(mtcars, aes(.data[[v]], mpg)) + geom_point()
print(g) # required
}
geom_point(aes(colour = 'red')) # maps a constant -> legend appears
geom_point(colour = 'red') # sets the colour -> no legend
ggsave('plot.png', p, width = 8, height = 5, dpi = 300)
Key Points
- A ggplot is an object; + adds layers, scales, facets and themes
- Auto-printing only happens at the top level, so use print() in loops
- Inside aes() maps from data, outside aes() sets a constant
- facet_wrap shares scales unless scales = 'free' is requested
Q14What is the difference between library(), require() and requireNamespace(), and when do you write pkg:: ?
BasicPackages
Answer
library(pkg) attaches a package to the search path and throws an error if it is not installed, which is what you want in a script: fail immediately rather than halfway through. require(pkg) attaches it too but returns FALSE with a warning instead of failing, so a script that uses require() carries on and then dies later with a confusing object not found error. The only legitimate use of require() is inside an if() that has a real fallback. requireNamespace('pkg', quietly = TRUE) loads the namespace without attaching it, which is the correct pattern inside packages for optional dependencies listed under Suggests, paired with pkg::fun() calls. The double colon operator accesses an exported object from a namespace without attaching the package, so dplyr::filter() works even when dplyr is not on the search path.
Triple colon pkg:::internal reaches unexported internals and should never appear in production or in a CRAN submission, because those functions can change without notice. Attaching packages creates masking: after library(dplyr), a bare filter() resolves to dplyr::filter() and shadows stats::filter(), and the startup message tells you so. The same clash happens with MASS::select and lubridate::intersect. In long scripts and Shiny apps the safe pattern is to attach only the few packages you use heavily and qualify everything else with ::, or to use the conflicted package which turns an ambiguous call into an error instead of a silent choice.
library(dplyr) # errors loudly if missing (use in scripts)
require(dplyr) # returns FALSE + warning (avoid)
if (!requireNamespace('arrow', quietly = TRUE)) {
stop('Install arrow to read Parquet input')
}
arrow::read_parquet('x.parquet')
# Masking after attaching
stats::filter(1:10, rep(1, 3)) # moving average
dplyr::filter(mtcars, mpg > 20) # row subset
# Make ambiguity an error instead of a coin flip
conflicted::conflict_prefer('filter', 'dplyr')
# Never ship this
dplyr:::across_setup # unexported internal, can vanish any release
Key Points
- library() errors on a missing package, require() only warns
- requireNamespace() + pkg:: is the pattern for Suggests dependencies
- ::: reaches unexported internals and is unsafe in production
- conflicted turns masked-function ambiguity into an explicit error
Q15How does R match arguments, and what does the ... (dots) parameter actually do?
BasicFunctions
Answer
R matches supplied arguments to formals in three passes: exact name match first, then unique partial name match, then positional match for whatever is left. Partial matching is why plot(x, ty = 'l') works, and also why adding a new argument to a function can break a caller whose abbreviation is suddenly ambiguous. Arguments after ... in the formal list must be matched exactly by name, partial matching is disabled there, which is the standard way library authors force explicit calls.
Default values are ordinary expressions evaluated lazily inside the function, so a default can refer to another argument or even to a variable computed in the body, and it is only evaluated if the argument is not supplied. That is how functions like function(x, n = length(x)) work. The ... parameter collects any number of extra arguments and lets you forward them to another function without naming them, which is how plotting wrappers pass graphical parameters through, and how lapply passes extra arguments to FUN.
Inside the function you can inspect them with list(...), count them with ...length(), name them with names(list(...)), or pull a single one with ..1. The main hazard is that dots swallow typos silently: mean(x, na.rm = TRUE) is fine, but a function whose signature is f(x, ...) accepts f(x, narm = TRUE) without complaint and quietly ignores the option. Validating names(list(...)) against a known set is a cheap defence.
f <- function(value, verbose = FALSE, ...) {
extras <- list(...)
if (length(extras)) message('extra args: ', paste(names(extras), collapse = ', '))
if (verbose) print(value)
invisible(value)
}
f(1, verb = TRUE) # partial matching resolves 'verb' -> verbose
f(1, narm = TRUE) # silently swallowed by ...
# Defaults are lazy and can reference other arguments
g <- function(x, n = length(x)) n
g(1:5) # 5
# Arguments after ... must be named in full
h <- function(x, ..., na.rm = FALSE) sum(x, ..., na.rm = na.rm)
h(c(1, NA), na.rm = TRUE) # 1
h(c(1, NA), na = TRUE) # NA: 'na' is not matched partially
Key Points
- Matching order: exact name, unique partial name, then position
- Arguments after ... must be named exactly, no partial matching
- Defaults are lazy expressions and may reference other arguments
- ... silently accepts misspelled options unless you validate names
Q16When does ifelse() give the wrong answer, and how do if_else(), case_when() and switch() differ?
BasicControl Flow
Answer
ifelse() is vectorised and returns a vector shaped like the test, but it builds the result from the mode of the first non-missing branch value it evaluates, which strips attributes. Feed it Dates and you get numbers back, feed it factors and you get integer codes, feed it a zero-length test and you get logical(0) regardless of the branches. It also evaluates both branches for the whole vector, so an expensive or error-throwing branch runs even where the test is FALSE. dplyr::if_else() fixes the ergonomics: it checks that both branches have the same type, preserves attributes such as Date and factor levels, and takes an explicit missing argument for what to return when the condition is NA rather than silently propagating. case_when() handles more than two branches, evaluates conditions top to bottom and returns the first match, with .default (dplyr 1.1 onwards, replacing the old TRUE ~ idiom) for the fallback.
It also type-checks every right-hand side, so mixing a numeric and a character branch is an error rather than a coercion. Plain if () is not vectorised at all: it needs a single TRUE or FALSE, and in recent R versions supplying a longer condition is an error rather than the old warning about only the first element being used. switch() picks a branch by a character or integer value, falls through empty cases to the next non-empty one, and returns NULL invisibly when nothing matches, so always give it a default.
d <- as.Date(c('2026-01-01', '2026-06-01'))
ifelse(d > as.Date('2026-03-01'), d, NA) # numbers! attributes stripped
dplyr::if_else(d > as.Date('2026-03-01'), d, as.Date(NA)) # stays Date
ifelse(logical(0), 1, 2) # logical(0), not numeric(0)
dplyr::case_when(
score >= 90 ~ 'A',
score >= 75 ~ 'B',
is.na(score) ~ 'missing',
.default = 'C'
)
# if() is scalar only
if (c(TRUE, FALSE)) 1 # error in recent R versions
switch(region,
north = 1.05,
south = 0.98,
stop('unknown region: ', region)
)
Key Points
- ifelse() drops attributes, so Dates and factors come back as numbers
- if_else() type-checks branches and has an explicit missing argument
- case_when() uses .default in dplyr 1.1 instead of TRUE ~ value
- if() requires a length-one condition and errors otherwise
Q17What is the difference between saveRDS/readRDS and save/load, and when do you reach for Parquet?
BasicSerialisation
Answer
saveRDS() serialises a single object to a file and readRDS() returns it, so you choose the variable name at load time: model <- readRDS('fit.rds'). save() writes one or more named objects into a .RData file and load() restores them into the calling environment under their original names, overwriting anything already there. That side effect is why saveRDS is the right default in scripts and packages: load() can silently clobber a variable you are using and there is no way to see which names it will create without loading it. The related hazard is R's default of saving and restoring .RData at session end, which makes an analysis appear reproducible when it is really depending on stale objects in the workspace; turn that off in RStudio settings or run R with --vanilla in CI.
Both formats respect a compress argument (gzip by default, xz for smaller files at much higher CPU cost). For big tabular data neither is ideal: RDS is R-only, single-threaded and reads the whole object into memory. Parquet through the arrow package is columnar, compressed, readable from Python, Spark and DuckDB, supports predicate and column pushdown so you read only the rows and columns you need, and partitions cleanly by date or region for a nightly pipeline. The qs2 package is a middle option when you need to serialise arbitrary R objects fast, including lists and fitted models that Parquet cannot represent.
# One object, name chosen at load time
saveRDS(fit, 'fit.rds', compress = 'xz')
model <- readRDS('fit.rds')
# Many objects, names forced back into the environment
save(fit, train, test, file = 'work.RData')
load('work.RData') # silently overwrites fit, train, test
# Inspect before restoring
local({ e <- new.env(); load('work.RData', envir = e); ls(e) })
# Columnar for tabular data
arrow::write_parquet(sales, 'sales.parquet')
arrow::open_dataset('sales/', partitioning = c('year', 'month')) |>
dplyr::filter(year == 2026) |>
dplyr::collect()
Key Points
- saveRDS stores one object; the name is chosen at read time
- load() injects names into the environment and can overwrite them
- Disable .RData autosave so stale objects cannot fake reproducibility
- Parquet via arrow for tabular data: columnar, cross-language, pushdown
Q18How do you handle strings in R: paste versus sprintf, raw strings, and base regex versus stringr?
BasicStrings
Answer
paste() joins with a space separator, paste0() with none, and both are vectorised. The collapse argument is the one people forget: paste(x, collapse = ', ') flattens a vector into one string, while without it you get a vector of the same length. sprintf() is the right tool for formatted output because it controls width, padding and decimal places: sprintf('%.2f%%', 12.3456) gives 12.35%. format() and formatC() handle big-number separators, and prettyNum(x, big.mark = ',') is common in Indian reporting, though the lakh and crore grouping needs a custom function since R only does thousands. Regex has two flavours: the default TRE engine and PCRE when you pass perl = TRUE, which unlocks lookarounds, lazy quantifiers and \d style shorthands more reliably. fixed = TRUE turns off regex entirely and is both faster and safer when you are matching a literal dot or bracket.
R 4.0.0 added raw strings written as r'(...)' so you no longer need to double every backslash inside a pattern, which makes regexes far easier to read and review. The stringr package wraps all of this in a consistent str_ interface where the string is always the first argument, patterns are explicit through fixed(), regex() and coll(), and vectorisation and NA handling are predictable. stringi sits underneath stringr and is what you use for locale-aware collation, transliteration and Unicode normalisation, which matters when you are cleaning Devanagari or mixed-script names.
paste0('user_', 1:3) # 'user_1' 'user_2' 'user_3'
paste(c('a','b','c'), collapse = ', ') # 'a, b, c'
sprintf('%-10s %6.2f%%', 'growth', 12.3456)
# Raw strings (R 4.0.0+) avoid backslash doubling
grepl(r'(\d{4}-\d{2}-\d{2})', '2026-08-11') # TRUE
# Literal matching beats escaping
gsub('.', '_', 'a.b', fixed = TRUE) # 'a_b'
gsub('.', '_', 'a.b') # '___' (regex dot matches all)
library(stringr)
str_detect(c('INV-1', 'x'), regex('^inv', ignore_case = TRUE))
str_extract('order 4521 shipped', '[0-9]+')
str_pad('7', width = 3, pad = '0') # '007'
Key Points
- paste(collapse = ) flattens; without it the result stays a vector
- sprintf controls width and precision for report-quality output
- R 4.0.0 raw strings r'(...)' remove backslash doubling in regex
- fixed = TRUE for literals, perl = TRUE for lookarounds
Q19Explain copy-on-modify in R and how reference counting since R 4.0.0 changed when copies happen.
IntermediateMemory Model
Answer
R has value semantics: assigning y <- x does not duplicate the data, it binds a second name to the same memory. A copy happens only when one of those names is modified, which is why the model is called copy-on-modify rather than copy-on-assign. Before R 4.0.0 the interpreter tracked bindings with a coarse NAMED field that saturated at 2 and never decreased, so an object that had briefly been passed to a function stayed marked as potentially shared forever and got duplicated on every later modification.
R 4.0.0 switched to true reference counting, so when the second binding goes away the count drops and in-place modification becomes legal again. The practical consequences are large: modifying a column of a data frame in a loop can now avoid a full copy per iteration in cases where it previously could not, and functions that mutate their local copy of a large argument are cheaper than the folklore suggests. You verify all this with tracemem(), which prints a message every time the object is duplicated, and with lobstr::obj_addr() and lobstr::ref() which show whether two names point to the same memory.
What still forces copies: adding or removing a column changes the data frame's structure, growing a vector by assigning past its length reallocates, and attaching an attribute to a shared object duplicates it. Interviewers at analytics firms ask this because a nightly job that quietly triples its peak RSS is a real incident, not a theory question.
x <- c(1, 2, 3)
tracemem(x)
y <- x # no copy, just a second binding
y[1] <- 99 # copy happens here
# tracemem[0x...] -> [0x...]
library(lobstr)
a <- 1:10
b <- a
obj_addr(a) == obj_addr(b) # TRUE, same memory
# Structural change always copies
df <- data.frame(a = 1:3)
tracemem(df)
df$b <- 4:6 # adds a column -> duplication
# Preallocate instead of growing
out <- numeric(1e5)
for (i in seq_len(1e5)) out[i] <- i^2 # in place
untracemem(x)
Key Points
- Copies occur on modification, not on assignment
- R 4.0.0 replaced the saturating NAMED counter with real reference counting
- tracemem() and lobstr::obj_addr() prove whether a copy happened
- Adding columns or growing vectors reallocates regardless
Q20How do environments and lexical scoping work in R, and what does <<- actually do?
IntermediateEnvironments
Answer
An environment is a bag of name-to-value bindings plus a pointer to a parent environment. Every function call creates a fresh execution environment whose parent is the environment where the function was defined, not where it was called from. That is lexical scoping, and it is why a function written inside a package sees the package namespace rather than the caller's variables.
Name lookup walks the chain of parents until it hits the global environment, then the attached packages on the search path, then base, then the empty environment where it errors with object not found. A closure is a function plus the environment it captured, and it is how R implements counters, memoisation and function factories: variables in the enclosing environment stay alive as long as the returned function does. Ordinary assignment with <- always writes into the current environment.
The super-assignment operator <<- walks up the parent chain looking for an existing binding with that name and modifies the first one it finds; if none exists it creates the variable in the global environment, which is the reason <<- has a bad reputation. Inside a closure it is legitimate and idiomatic for mutating captured state, and Shiny's reactiveVal exists partly to give you that behaviour safely. The related trap is lazy capture in a function factory: the captured argument is a promise, so all generated functions can see the final loop value unless you force() it.
make_counter <- function() {
n <- 0
function() {
n <<- n + 1 # mutates the captured n, not a global
n
}
}
count <- make_counter()
count(); count() # 1 then 2
# Lazy capture bug in a function factory
make_pow <- function(k) function(x) x^k
fns <- lapply(1:3, make_pow) # lapply forces k, so this is fine
make_pow2 <- function(k) { force(k); function(x) x^k } # explicit and safe
# Inspect the chain
f <- function() environment()
environment(make_counter) # R_GlobalEnv
ls(environment(count)) # 'n'
get('n', envir = environment(count))
environmentName(parent.env(globalenv()))
Key Points
- Lexical scoping: parent is where the function was defined, not called
- A closure is a function plus its captured environment
- <<- modifies the nearest existing binding up the chain, else creates a global
- force() a captured argument in function factories to avoid lazy capture
Q21Explain the data.table DT[i, j, by] syntax, := reference semantics, and what setkey() buys you.
Intermediatedata.table
Answer
data.table overloads the bracket operator into a query language: i filters or joins rows, j computes or selects columns, and by groups. Because j is evaluated inside the data.table's frame, you write DT[amount > 100, .(total = sum(amount)), by = region] with no quoting and no intermediate copies. The special symbols carry a lot of weight: .N is the group row count, .SD is the subset of data for the current group, .SDcols restricts which columns .SD contains, .I gives row indices, and .GRP is the group counter.
The walrus operator := adds or updates columns by reference, meaning no copy of the table is made and the object is modified in place. That is the source of the biggest gotcha in the package: passing a data.table into a function and using := there modifies the caller's table too, because there is no value-semantics copy. If you need isolation, call copy(DT) explicitly.
The same reference semantics is why data.table objects returned from a function sometimes need setDT() or the [] suffix to print on the first call. setkey(DT, col) physically sorts the table by that column and marks it as sorted, which turns subsequent joins and filters on the key into binary searches instead of vector scans, and lets you write DT[.('north')] for a keyed lookup. setindex() gives a secondary index without reordering. Combined with fread and in-place updates, this is why data.table remains the memory-efficient choice for tens of millions of rows on a single machine.
library(data.table)
DT <- as.data.table(sales)
# i, j, by in one pass
DT[amount > 100, .(total = sum(amount), n = .N), by = .(region, month)]
# Update by reference: no copy of the table
DT[, amount_inr := amount * 83.2]
DT[region == 'north', flag := TRUE]
# Multiple columns at once, with .SDcols
num_cols <- c('amount', 'qty')
DT[, (num_cols) := lapply(.SD, as.numeric), .SDcols = num_cols]
# Reference semantics leak into functions
f <- function(d) d[, x := 1]
f(DT) # DT now has column x
f(copy(DT)) # DT untouched
# Keyed join: binary search instead of a scan
setkey(DT, region)
DT['north']
DT[lookup, on = 'region', nomatch = 0L]
Key Points
- .N, .SD, .SDcols, .I and .GRP are the special symbols in j
- := updates in place with no copy, including inside functions
- copy(DT) is the only way to get value semantics back
- setkey() sorts and enables binary-search joins; setindex() is non-sorting
Q22How do pivot_longer() and pivot_wider() work, and what replaced gather() and spread()?
IntermediateData Reshaping
Answer
pivot_longer() collapses several columns into two, a names column holding the old column names and a values column holding the cells, which is how you get from a wide report layout (one column per month) to the tidy long layout that ggplot2 and most modelling functions expect. pivot_wider() does the reverse. They superseded gather() and spread() in tidyr 1.0 because the older pair had confusing argument order and could not handle multiple value columns or names embedded in a pattern. The arguments that matter in practice: cols selects the columns to pivot using tidyselect helpers such as starts_with() or where(is.numeric); names_to and values_to name the outputs; names_prefix strips a repeated prefix; names_pattern applies a regex with capture groups so a column called sales_2026_q1 splits into two output columns at once; names_transform coerces the extracted names, which matters because pivoted names always arrive as character even when they were years.
On the way back, values_fn resolves duplicates and is the fix for the values are not uniquely identified warning, which almost always means your identifier columns do not uniquely determine a row. In data.table the equivalents are melt() and dcast(), which are considerably faster on large tables and take a formula interface, and reshape2 is retired so no new code should use it. Interviewers usually hand you a wide monthly report and ask for a tidy summary, so being fluent here saves ten minutes of the live coding round.
library(tidyr)
wide <- tibble::tibble(id = 1:2, sales_2025 = c(10, 20), sales_2026 = c(15, 25))
long <- wide |> pivot_longer(
cols = starts_with('sales_'),
names_to = 'year',
names_prefix = 'sales_',
names_transform = list(year = as.integer),
values_to = 'sales'
)
long |> pivot_wider(names_from = year, values_from = sales,
names_prefix = 'sales_')
# Split a compound name with a regex
pivot_longer(x, cols = -id, names_to = c('metric', 'quarter'),
names_pattern = '(.*)_(q[1-4])', values_to = 'value')
# Duplicate keys: aggregate instead of getting a list-column
pivot_wider(long, names_from = year, values_from = sales, values_fn = sum)
# data.table equivalents
data.table::melt(DT, id.vars = 'id', variable.name = 'year')
data.table::dcast(DTlong, id ~ year, value.var = 'sales')
Key Points
- pivot_longer/pivot_wider replaced gather/spread in tidyr 1.0
- names_pattern with capture groups splits compound column names
- names_transform fixes the character-typed year problem
- values_fn resolves the not uniquely identified warning
Q23How do you diagnose a join that multiplies rows, and what did dplyr 1.1 add with join_by() and relationship?
IntermediateJoins
Answer
A join explodes when the key is not unique on the side you assumed was a lookup table. left_join(orders, customers, by = 'cust_id') returns more rows than orders whenever customers contains duplicate cust_id values, and because no error is raised the inflated revenue total is only noticed at reconciliation. The diagnostic sequence is short: compare nrow() before and after, then run customers |> count(cust_id) |> filter(n > 1) to see the offending keys, then decide whether to deduplicate with distinct() or slice_max() on a version column, or whether the many-to-many relationship is genuine. dplyr 1.1.0 made this explicit with the relationship argument: relationship = 'many-to-one' errors if the right side has duplicate keys, which converts a silent data bug into a failed pipeline run. The same release warns by default when a join is many-to-many and you did not say so. join_by() replaced the character vector for by and unlocked non-equi joins with comparison operators, between() for range joins, closest() for as-of style lookups, and overlaps() for interval matching, so a rate-card join where the rate depends on a validity window no longer needs a cross join and a filter. Other essentials: unmatched = 'error' catches rows that should have matched, anti_join() and semi_join() are filtering joins that never change row counts, and na_matches = 'never' stops NA keys from joining to each other, which is the SQL default and rarely what people expect from R.
library(dplyr)
# Fail loudly instead of inflating totals
orders |> left_join(customers, by = join_by(cust_id),
relationship = 'many-to-one',
unmatched = 'drop')
# Find the duplicate keys first
customers |> count(cust_id) |> filter(n > 1)
# Non-equi join: pick the rate valid on the order date
orders |> inner_join(rate_card,
join_by(product, between(order_date, valid_from, valid_to)))
# As-of join: nearest earlier price
ticks |> left_join(quotes, join_by(symbol, closest(ts >= quote_ts)))
# Filtering joins never change the row count of x
orders |> anti_join(blocked, by = 'cust_id')
# NA keys should usually not match each other
left_join(a, b, by = 'k', na_matches = 'never')
Key Points
- Row explosion means duplicate keys on the supposedly unique side
- relationship = 'many-to-one' turns the bug into an error
- join_by() supports between(), closest() and overlaps() non-equi joins
- semi_join/anti_join filter without changing column or row semantics
Q24How does S3 dispatch work, and how do you correctly register a print method for your own class?
IntermediateObject Systems
Answer
S3 is R's informal object system and it is everywhere: data.frame, factor, Date, lm and tibble are all S3. An object gets a class by setting the class attribute, and a generic function dispatches by calling UseMethod('generic'), which looks for a function named generic.class for each entry of class(x) in order, falling back to generic.default. There is no formal class definition, no validation, and no compile-time check, so structure(list(), class = 'anything') is a valid object.
The convention for a well-built S3 class is three functions: a low-level constructor new_myclass() that does no coercion and checks types, a validator validate_myclass() that raises informative errors, and a user-facing helper myclass() that is forgiving about input. Writing methods is straightforward, print.myclass <- function(x, ...) works interactively as soon as it exists in the global environment, but inside a package it must be registered in NAMESPACE with S3method(print, myclass), which roxygen2 generates from an @export tag on the method. Forgetting that registration is the classic package bug: the method works when you devtools::load_all() and stops working after install because it was never exported as a method.
NextMethod() calls the next method in the class vector, which is how a subclass adds behaviour and then delegates to the parent, and it is why subsetting a tibble still runs data.frame logic underneath. Always accept ... in a method to stay compatible with the generic's signature, otherwise R CMD check complains about a mismatched signature.
# Constructor, validator, helper
new_money <- function(x = double(), currency = 'INR') {
stopifnot(is.double(x), is.character(currency))
structure(x, class = 'money', currency = currency)
}
print.money <- function(x, ...) {
cat(attr(x, 'currency'), formatC(unclass(x), format = 'f', digits = 2,
big.mark = ','), '\n')
invisible(x)
}
format.money <- function(x, ...) paste(attr(x, 'currency'), unclass(x))
# Delegate to the parent after adding behaviour
'[.money' <- function(x, i) {
out <- NextMethod()
new_money(out, attr(x, 'currency'))
}
m <- new_money(c(125000, 98000.5))
m
m[1]
# In a package, roxygen must emit: S3method(print, money)
#' @export
Key Points
- UseMethod() searches generic.class for each class in order, then .default
- Constructor + validator + helper is the idiomatic three-function pattern
- Package methods need S3method() in NAMESPACE, not plain export
- NextMethod() delegates to the parent class implementation
Q25When would you choose S4, Reference Classes, R6 or S7 over S3?
IntermediateObject Systems
Answer
S3 is fine for the majority of R work and should be the default. S4 adds formal class definitions with typed slots, multiple inheritance, multiple dispatch on more than one argument, and validity functions that run on construction. That formality is why Bioconductor standardised on S4 and why Matrix uses it: when a generic must dispatch on the combination of two argument classes, S3 cannot express it.
The costs are verbosity (setClass, setGeneric, setValidity, setMethod), slower dispatch, and the isVirtualClass and callNextMethod machinery that new team members find opaque. Reference Classes, also called R5 and created with setRefClass, give mutable objects with methods attached, but they are built on S4 and are largely legacy. R6 (from the R6 package) is the practical choice when you genuinely need reference semantics and encapsulated mutable state: database connection pools, API clients that refresh a token, Shiny application state, and simulation objects.
R6 objects are environments, so they are not copied on assignment, they support public and private members, active bindings and clean inheritance with super. S7 is the newer class system developed jointly by the R Consortium working group to unify S3 and S4: it gives formal property definitions with types and validators while dispatching in a way that interoperates with existing S3 generics, and it is aimed squarely at package authors who found S4 too heavy. In an interview the expected answer is a decision rule, not a lecture: S3 by default, R6 when you need mutable reference objects, S4 when you must integrate with Bioconductor or need multiple dispatch, S7 when starting a new package that wants formal validation without S4's weight.
# S4: typed slots, validity, multiple dispatch
setClass('Account', representation(id = 'character', balance = 'numeric'),
validity = function(object) {
if (object@balance < 0) 'balance must be non-negative' else TRUE
})
setGeneric('deposit', function(acct, amt) standardGeneric('deposit'))
setMethod('deposit', signature('Account', 'numeric'),
function(acct, amt) { acct@balance <- acct@balance + amt; acct })
# R6: mutable reference semantics
library(R6)
ApiClient <- R6Class('ApiClient',
public = list(
initialize = function(key) private$key <- key,
get = function(path) private$call('GET', path)
),
private = list(
key = NULL,
call = function(verb, path) invisible(NULL)
)
)
cl <- ApiClient$new('secret')
cl2 <- cl # same object, no copy
Key Points
- S3 by default; it powers data.frame, lm, factor and tibble
- S4 for typed slots, validity and multiple dispatch (Bioconductor)
- R6 for mutable reference objects: connections, clients, app state
- S7 unifies S3 and S4 for new packages needing formal properties
Q26What is the difference between tryCatch(), withCallingHandlers() and try(), and where does on.exit() fit?
IntermediateError Handling
Answer
R's condition system separates signalling from handling. tryCatch() registers exiting handlers: when a matching condition is signalled, the stack unwinds to the tryCatch call, your handler runs, and the protected code does not resume. That is what you want for errors, where continuing makes no sense. withCallingHandlers() registers calling handlers that run in place and then return control to the point of signalling, so the original code continues. That is the only way to log every warning from a long loop without stopping it, and it is why suppressWarnings() is implemented with a calling handler plus invokeRestart('muffleWarning'). try() is the older wrapper that returns an object of class try-error instead of aborting, and it is mostly superseded, though try(silent = TRUE) still appears in legacy scripts. tryCatch takes a finally block that always runs, and on.exit() serves the same purpose at the function level: it registers an expression to execute when the function exits by any route, error or normal return, which is how you reliably close a database connection, restore par() settings or delete a temporary file.
Always pass add = TRUE and after = FALSE when registering more than one, otherwise the second call replaces the first. Modern package code signals classed conditions with rlang::abort('message', class = 'myapp_api_error', data = ...), so callers can catch by class rather than by matching the message text, which breaks the moment someone rewords an error or the locale changes.
# Exiting handler: stops the protected code
res <- tryCatch(
read_api(url),
error = function(e) { log_error(conditionMessage(e)); NULL },
warning = function(w) { NULL },
finally = message('attempted ', url)
)
# Calling handler: log and keep going
withCallingHandlers(
for (f in files) process(f),
warning = function(w) {
message('warn in ', f, ': ', conditionMessage(w))
invokeRestart('muffleWarning')
}
)
# Guaranteed cleanup
load_data <- function(path) {
con <- DBI::dbConnect(RSQLite::SQLite(), path)
on.exit(DBI::dbDisconnect(con), add = TRUE)
DBI::dbGetQuery(con, 'SELECT * FROM t')
}
# Classed conditions beat string matching
rlang::abort('rate limit hit', class = 'api_rate_limit', retry_after = 30)
tryCatch(call_api(), api_rate_limit = function(e) Sys.sleep(e$retry_after))
Key Points
- tryCatch unwinds the stack; withCallingHandlers resumes execution
- invokeRestart('muffleWarning') is how suppressWarnings works
- on.exit(add = TRUE) guarantees cleanup on any exit path
- Signal classed conditions so callers match on class, not message text
Q27How do you write a function that takes a column name and passes it to dplyr? Explain {{ }}, .data and := .
IntermediateTidy Evaluation
Answer
dplyr verbs use data masking: filter(df, amount > 100) evaluates amount inside the data frame, not in your workspace. That convenience breaks when you wrap the verb in a function, because the argument name gets evaluated as a column rather than the column it refers to. The rlang solution is embracing with curly braces: writing {{ col }} inside the verb tells dplyr to substitute the caller's expression before evaluating, so summarise_by(sales, region) works and so does summarise_by(sales, toupper(region)).
When the column arrives as a character string, for example from a Shiny input or a config file, use the .data pronoun with double brackets: .data[[col]] resolves a string to a column and never accidentally picks up a same-named object from the environment. On the left-hand side of an assignment you cannot use {{ }} directly, so use the walrus operator := which allows a dynamic name, usually combined with the glue syntax {{ }} or englue for the label. For selecting many columns programmatically, all_of(chr_vector) errors on a missing column while any_of() silently skips it, and across(all_of(cols), fn) applies a function to each. Two gotchas worth naming in an interview: passing a plain string where a bare column is expected filters on the literal string rather than the column, which returns zero rows with no error, and inside a package you must import the .data pronoun or add a globalVariables declaration to avoid the R CMD check note about no visible binding for a global variable.
library(dplyr)
library(rlang)
# Bare column names: embrace with {{ }}
summarise_by <- function(df, group, value) {
df |> summarise('total_{{value}}' := sum({{ value }}, na.rm = TRUE),
.by = {{ group }})
}
summarise_by(sales, region, amount)
# String column names: use the .data pronoun
summarise_str <- function(df, group, value) {
df |> summarise(total = sum(.data[[value]], na.rm = TRUE), .by = all_of(group))
}
summarise_str(sales, 'region', 'amount')
# Many columns at once
sales |> mutate(across(all_of(c('amount', 'qty')), as.numeric))
sales |> summarise(across(where(is.numeric), mean, .names = 'avg_{.col}'))
# Silent-zero-row trap
filter(sales, 'region' == 'north') # compares two strings: 0 rows
filter(sales, region == 'north') # correct
Key Points
- {{ }} forwards a bare column expression from the caller
- .data[[chr]] resolves a string to a column with no ambiguity
- := allows a computed name on the left-hand side
- all_of() errors on missing columns, any_of() ignores them
Q28How do Date, POSIXct and POSIXlt differ, and what timezone bugs bite Indian data pipelines?
IntermediateDates and Times
Answer
Date is a double counting days since 1970-01-01 with no time and no timezone. POSIXct is a double counting seconds since the same epoch in UTC, with a tzone attribute that only affects printing and formatting. POSIXlt is a list of nine components (sec, min, hour, mday, mon, year, wday, yday, isdst), which makes it convenient for extracting parts but roughly forty times larger in memory and unsuitable for a data frame column.
Store POSIXct in data, convert to POSIXlt only transiently. The bugs come from the tzone attribute being invisible. Sys.time() carries the machine's local zone, so a script that runs correctly on a developer laptop set to Asia/Kolkata produces different day boundaries on a UTC container in AWS Mumbai, and a daily aggregation silently shifts by five and a half hours, moving late-evening transactions into the next day. as.Date(x) on a POSIXct converts in UTC unless you pass tz explicitly, so 2026-08-11 01:00 IST becomes 2026-08-10.
The defences are to set TZ or options in one place, always pass tz to as.Date and format, store timestamps as UTC and convert only for display, and use lubridate's with_tz (change the display zone, same instant) versus force_tz (keep the clock time, change the instant) deliberately. India has no daylight saving, which makes IST easier than most zones, but any cross-border data brings DST gaps back, and difftime silently changes its units between secs, mins and days depending on magnitude unless you pass units explicitly.
t <- as.POSIXct('2026-08-11 01:00:00', tz = 'Asia/Kolkata')
format(t, tz = 'UTC') # '2026-08-10 19:30:00'
as.Date(t) # 2026-08-10 if the session is UTC
as.Date(t, tz = 'Asia/Kolkata') # 2026-08-11, explicit and correct
object.size(as.POSIXlt(Sys.time())) # far larger than POSIXct
library(lubridate)
with_tz(t, 'UTC') # same instant, printed in UTC
force_tz(t, 'UTC') # same wall clock, different instant
# difftime units drift unless pinned
difftime(t + 3600, t) # 'Time difference of 1 hours'
as.numeric(difftime(t + 3600, t, units = 'secs')) # 3600
# Parse Indian-format dates without guessing
as.Date('11-08-2026', format = '%d-%m-%Y')
lubridate::dmy('11-08-2026')
Key Points
- Date = days, POSIXct = seconds since epoch, POSIXlt = a heavy list
- tzone affects display only; the stored instant is always UTC-based
- as.Date on a POSIXct uses the session zone unless tz is passed
- with_tz changes the display, force_tz changes the instant
Q29What does purrr give you over the apply family, and how do list-columns work?
IntermediateFunctional Programming
Answer
purrr's map() is lapply with a consistent interface, and the typed variants map_dbl(), map_int(), map_chr(), map_lgl() and map_dfr() are the real advantage: they check the return type of every element and error with the index that failed, so you get a message naming element 37 rather than a mysterious list where you expected a numeric. map2() iterates two inputs in parallel, pmap() takes a list or data frame of arguments and is the idiomatic way to loop over rows of a parameter grid, imap() gives you the name or index alongside the value, and walk() is for side effects because it returns the input invisibly. The shorthand formulas ~ .x + 1 predate the native lambda; since R 4.1.0 you can write \(x) x + 1 in either purrr or base, and most style guides now prefer the base lambda. safely() and possibly() wrap a function so a failure returns a result-and-error pair or a default instead of aborting a long batch, which is essential when scraping or hitting an API for a thousand identifiers. List-columns are the second half of the story: because a tibble column can be a list, you can nest a data frame by group with tidyr::nest(), fit a model per group with map(), extract coefficients with map(broom::tidy) and unnest the result, all inside one pipeline with no split-apply-combine boilerplate. This nest-map-unnest pattern shows up constantly in analytics interviews because it replaces twenty lines of loop code with five readable ones.
library(purrr); library(dplyr); library(tidyr)
map_dbl(list(1:3, 4:6), mean) # 2 5, type checked
map_chr(list(1:3), \(v) paste(v, collapse = '-'))
map2_dbl(1:3, 4:6, \(a, b) a * b) # 4 10 18
pmap_dbl(list(x = 1:2, y = 3:4), \(x, y) x + y)
# Never let one failure kill a batch
safe_read <- safely(readr::read_csv)
results <- map(files, safe_read)
failed <- files[map_lgl(results, \(r) !is.null(r$error))]
# Fit one model per group with list-columns
mtcars |>
nest(.by = cyl) |>
mutate(fit = map(data, \(d) lm(mpg ~ wt, data = d)),
tidy = map(fit, broom::tidy)) |>
select(cyl, tidy) |>
unnest(tidy)
Key Points
- map_dbl/map_chr enforce return type and name the failing element
- safely() and possibly() keep long batches alive through failures
- walk() is map() for side effects and returns input invisibly
- nest + map + unnest replaces split-apply-combine loops
Q30Walk through fitting lm() in R: the formula interface, factor contrasts, and reading summary() output.
IntermediateStatistical Modelling
Answer
lm(y ~ x1 + x2, data = df) fits ordinary least squares. The formula mini-language matters: a colon x1:x2 is the interaction term alone, an asterisk x1*x2 expands to main effects plus interaction, a minus removes a term, a dot on the right side means all remaining columns, 0 or -1 drops the intercept, poly(x, 2) adds an orthogonal quadratic, and I(x^2) protects arithmetic that would otherwise be read as formula syntax. Character and factor predictors are expanded into dummy variables by model.matrix() using the contrast setting in options('contrasts'), which defaults to treatment contrasts: the first level becomes the reference and each coefficient is a difference from it.
This is why changing the factor level order changes the coefficients without changing the fit, and why an unused level after filtering produces a rank-deficient model with NA coefficients. summary() reports the estimate, standard error, t value and p-value per term, plus residual standard error, multiple and adjusted R-squared, and the overall F-test. Read adjusted R-squared rather than raw R-squared when comparing models with different term counts, and treat a very high R-squared with insignificant coefficients as a multicollinearity signal, confirmed with car::vif() above roughly 5 to 10. predict(fit, newdata) fails with a factor has new levels error when newdata contains a category unseen in training, which is a standard production failure for monthly scoring jobs. broom::tidy(), glance() and augment() turn the model, its fit statistics and its residuals into tibbles for reporting.
fit <- lm(mpg ~ wt * factor(cyl) + I(hp^2), data = mtcars)
summary(fit)
# What the formula expands to
head(model.matrix(fit), 3)
options('contrasts') # treatment contrasts by default
# Reference level drives every coefficient
mtcars$cyl_f <- relevel(factor(mtcars$cyl), ref = '8')
# Diagnostics
par(mfrow = c(2, 2)); plot(fit) # residuals, QQ, scale-location, leverage
car::vif(lm(mpg ~ wt + hp + disp, data = mtcars))
# Tidy output for reports
broom::tidy(fit, conf.int = TRUE)
broom::glance(fit)[, c('r.squared', 'adj.r.squared', 'AIC')]
# Guard scoring against unseen levels
new <- droplevels(newdata)
stopifnot(all(levels(new$cyl_f) %in% levels(mtcars$cyl_f)))
Key Points
- x1*x2 expands to main effects plus interaction; I() protects arithmetic
- Treatment contrasts make every coefficient a difference from level one
- High R-squared with weak t-values suggests multicollinearity; check vif()
- predict() errors on factor levels unseen during training
Q31How does tidymodels prevent data leakage, and how does it differ from caret?
IntermediateMachine Learning
Answer
caret was the older unified interface: one train() function, a method string for the algorithm, and trainControl() for resampling. It still works but is in maintenance, and its single-function design makes preprocessing and model specification hard to separate. tidymodels splits the workflow into composable packages: rsample for splitting and resampling (initial_split, vfold_cv, group_vfold_cv for grouped data, rolling_origin for time series), recipes for preprocessing steps, parsnip for a model specification that is engine-agnostic, workflows to bind a recipe and a model into one object, tune and dials for hyperparameter search, and yardstick for metrics. Leakage prevention is the architectural point.
A recipe is a specification, not an executed transformation: step_normalize(), step_impute_median() and step_dummy() record what to do, and prep() estimates their parameters only on the analysis part of each resample fold. So the mean used for centring is computed inside the fold rather than on the whole dataset, which is exactly the mistake people make when they scale a data frame before splitting. step_novel() and step_other() handle unseen and rare factor levels at prediction time, which is the same failure mode that breaks a raw predict() call. The engine abstraction means switching from ranger to xgboost is a one-line change to set_engine() rather than rewriting the call, and tune_grid() or tune_bayes() with a workflow reuses the identical preprocessing. Mention last_fit() as the final step: it fits on the full training split and evaluates on the held-out test set exactly once.
library(tidymodels)
split <- initial_split(churn, prop = 0.8, strata = churned)
folds <- vfold_cv(training(split), v = 5, strata = churned)
rec <- recipe(churned ~ ., data = training(split)) |>
step_novel(all_nominal_predictors()) |>
step_other(all_nominal_predictors(), threshold = 0.02) |>
step_dummy(all_nominal_predictors()) |>
step_impute_median(all_numeric_predictors()) |>
step_normalize(all_numeric_predictors()) # fitted inside each fold
spec <- rand_forest(mtry = tune(), trees = 1000) |>
set_engine('ranger') |>
set_mode('classification')
wf <- workflow() |> add_recipe(rec) |> add_model(spec)
tuned <- tune_grid(wf, resamples = folds, grid = 10,
metrics = metric_set(roc_auc, pr_auc))
final <- finalize_workflow(wf, select_best(tuned, metric = 'roc_auc'))
last_fit(final, split) |> collect_metrics()
Key Points
- recipes are specifications; prep() estimates them per resample fold
- step_novel/step_other survive unseen and rare categories at scoring time
- parsnip decouples the model from the engine (ranger, xgboost, glmnet)
- last_fit() touches the test set exactly once
Q32In Shiny, when do you use reactive(), observe(), observeEvent(), eventReactive() and isolate()?
IntermediateShiny
Answer
Shiny builds a dependency graph at runtime. reactive() creates a lazy, cached expression: it runs only when something downstream asks for its value, and it re-runs only when one of the inputs it read has invalidated. Call it like a function, dataset(), and never assign its result to a plain variable at app start. observe() and observeEvent() are eager and return nothing useful, they exist for side effects such as updating an input, writing to a database or showing a modal. eventReactive() is the lazy counterpart of observeEvent(): it produces a value but only recomputes when the specified event fires, which is how you wire a Run button so an expensive query does not re-run on every slider nudge. isolate() reads a reactive value without taking a dependency on it, so a computation can use the current filter values while only reacting to the button. req() short-circuits execution when an input is NULL or empty, which is the clean way to avoid the flash of errors on app startup before the user selects anything. The classic bugs interviewers probe: an observe() that both reads and writes the same reactiveVal creates an infinite invalidation loop, so use observeEvent with a specific trigger and isolate the write; assigning with <<- to share state across sessions leaks one user's data into another because anything defined outside server() is global to the process; and calling a reactive inside a plain for loop does not create the dependency you expect. reactiveVal() holds a single mutable value while reactiveValues() is a list-like container, and both are the right way to store state instead of globals.
server <- function(input, output, session) {
# Lazy + cached: recomputes only when input$region changes
filtered <- reactive({
req(input$region)
dplyr::filter(sales, region == input$region)
})
# Only recompute when Run is clicked, using current (isolated) inputs
model <- eventReactive(input$run, {
lm(amount ~ qty, data = filtered())
})
output$plot <- renderPlot({
ggplot2::ggplot(filtered(), ggplot2::aes(qty, amount)) +
ggplot2::geom_point()
})
# Side effect only
observeEvent(input$region, {
updateSelectInput(session, 'city',
choices = unique(filtered()$city))
})
# Mutable state without globals
clicks <- reactiveVal(0)
observeEvent(input$run, clicks(clicks() + 1))
}
Key Points
- reactive() is lazy and cached; observe() is eager and side-effect only
- eventReactive() returns a value gated on an explicit trigger
- isolate() reads without creating a dependency; req() stops early on NULL
- Never share user state with <<- to globals, use reactiveVal per session
Q33How does renv make an R project reproducible, and what does renv.lock actually record?
IntermediateReproducibility
Answer
renv gives each project its own private library instead of one shared user library, so upgrading a package for one analysis cannot break another. renv::init() creates the project library, scans your code for library() and :: calls, installs what it finds, and writes renv.lock. That lockfile records the R version, every repository URL, and for each package the exact version, the source (CRAN, Bioconductor, GitHub, or a local path) and a hash, plus the remote SHA for GitHub packages so a moving branch still resolves to one commit. renv::snapshot() updates the lockfile after you add a dependency and renv::restore() rebuilds the exact library on another machine or in CI. Packages are hard-linked from a global cache, so ten projects sharing dplyr 1.1.4 store it once, which keeps container images and laptops from filling up.
Two limits are worth naming in an interview. First, renv pins R packages but not R itself, nor system libraries such as libcurl or a specific BLAS, which is why serious reproducibility pairs renv with a Docker image from the Rocker project pinned to an R version. Second, restoring old CRAN versions can fail because CRAN keeps only current binaries, so teams point repos at a dated snapshot from Posit Public Package Manager, which serves both source and binaries as of a fixed date and cuts install times dramatically on Linux. In regulated pharma work at IQVIA or Novartis style teams, that dated snapshot plus a lockfile is usually the documented environment evidence.
renv::init() # private library + renv.lock
renv::status() # drift between code, library and lockfile
renv::snapshot() # write current state to renv.lock
renv::restore() # rebuild exactly, e.g. in CI or a container
# Pin to a dated binary snapshot for fast, stable installs
options(repos = c(P3M = 'https://packagemanager.posit.co/cran/2026-06-01'))
renv::snapshot()
# Install a package at an exact source
renv::install('dplyr@1.1.4')
renv::install('tidyverse/glue@v1.7.0')
# Dockerfile fragment
# FROM rocker/r-ver:4.5.0
# COPY renv.lock renv.lock
# RUN R -e "install.packages('renv'); renv::restore()"
Key Points
- Per-project library plus a global hard-linked cache
- renv.lock stores version, source, hash and R version, not just names
- renv does not pin R itself or system libraries, pair it with Docker
- Dated Posit Package Manager snapshots make restores fast and stable
Q34What goes into an R package: Imports versus Depends versus Suggests, NAMESPACE, and what R CMD check enforces.
IntermediatePackage Development
Answer
A package is a directory with DESCRIPTION, NAMESPACE, an R/ folder, and optionally man/, tests/, vignettes/, data/ and src/. In DESCRIPTION, Imports means the package is required and will be installed, but it is not attached to the user's search path, so you must call functions as pkg::fun() or import them in NAMESPACE. Depends attaches the package for the user, which pollutes their search path and is now reserved for cases where your package is meaningless without it or for declaring a minimum R version with Depends: R (>= 4.1).
Suggests means optional, used in examples, tests or a vignette, and your code must degrade gracefully behind requireNamespace(). NAMESPACE controls what leaves and what enters: export() and exportPattern() for your API, importFrom() for the functions you use, and S3method() to register methods. Writing it by hand is a mistake, roxygen2 generates it from @export, @importFrom and @examples comments above each function, and the same comments generate the man/ Rd files.
R CMD check is the gate. It runs the examples, the tests and the vignettes, and it fails or notes on undocumented arguments, mismatched usage sections, non-ASCII characters in code, files left behind in the working directory, examples running longer than a few seconds, missing global variable bindings from tidy evaluation, and a package size over five megabytes for CRAN. Run devtools::check() locally and treat every NOTE as work to do, because CRAN reviewers do the same and Indian pharma validation teams usually require a clean check log as evidence.
# DESCRIPTION
# Package: salesreport
# Version: 0.2.0
# Depends: R (>= 4.1)
# Imports: dplyr (>= 1.1.0), ggplot2, rlang
# Suggests: testthat (>= 3.0.0), arrow, knitr
# Config/testthat/edition: 3
#' Summarise sales by region
#'
#' @param df A data frame with region and amount columns.
#' @return A tibble with one row per region.
#' @importFrom rlang .data
#' @export
#' @examples
#' summarise_sales(data.frame(region = 'north', amount = 1))
summarise_sales <- function(df) {
dplyr::summarise(df, total = sum(.data$amount), .by = .data$region)
}
# Optional dependency behind a guard
read_fast <- function(p) {
if (!requireNamespace('arrow', quietly = TRUE)) stop('install arrow')
arrow::read_parquet(p)
}
# devtools::document(); devtools::check()
Key Points
- Imports = required but not attached; Depends attaches and is discouraged
- Suggests must be guarded with requireNamespace()
- roxygen2 generates NAMESPACE and man/, never hand-edit them
- R CMD check runs examples, tests and vignettes and notes undeclared globals
Q35How do you test R code with testthat 3rd edition, and what is a snapshot test good for?
IntermediateTesting
Answer
testthat 3rd edition is enabled by Config/testthat/edition: 3 in DESCRIPTION and changes several defaults. expect_equal() now uses the waldo package for diffs, which prints a readable structural comparison instead of a one-line mismatch, and it compares with a small numeric tolerance by default. expect_identical() is strict about type, so it distinguishes 1L from 1, which is what you want for anything that will be joined on a key. Warnings and messages are no longer silently caught, you must assert them with expect_warning() or expect_message() or the test fails as unexpected output, which surfaces sloppy code. Snapshot tests, expect_snapshot() and expect_snapshot_value(), record the output of an expression into a _snaps/ markdown file on first run and then compare on every later run, so a change in printed output, an error message or a console layout becomes a reviewable diff instead of a hand-written string comparison.
That is ideal for print methods, report text and error messages, and snapshot_accept() records the new baseline once you have reviewed it. Test isolation matters more in R than people expect because options, environment variables and the working directory are global: use withr::local_options(), local_envvar() and local_tempdir(), or testthat's local_ helpers, so a test cannot leak state into the next one. skip_on_cran() and skip_if_offline() keep network-dependent tests from failing someone else's check run. For Shiny, shinytest2 drives a headless Chrome session and snapshots both the exported reactive values and the rendered screenshots, and covr::package_coverage() reports line coverage for the CI badge.
# tests/testthat/test-summarise.R
test_that('totals are computed per region', {
df <- data.frame(region = c('n', 'n', 's'), amount = c(1, 2, 4))
out <- summarise_sales(df)
expect_s3_class(out, 'data.frame')
expect_equal(nrow(out), 2L)
expect_equal(out$total[out$region == 'n'], 3)
expect_identical(typeof(out$region), 'character')
})
test_that('missing amount column errors clearly', {
expect_error(summarise_sales(data.frame(region = 'n')),
class = 'salesreport_missing_column')
})
test_that('print output stays stable', {
expect_snapshot(print(new_money(1250)))
})
test_that('option changes do not leak', {
withr::local_options(digits = 3)
expect_equal(getOption('digits'), 3)
})
Key Points
- Edition 3 uses waldo diffs and stops swallowing warnings
- expect_equal has numeric tolerance; expect_identical is type-strict
- Snapshot tests turn printed output and error text into reviewable diffs
- withr local_ helpers stop options and env vars leaking between tests
Q36A nightly R script takes four hours. How do you profile it and what are the usual culprits?
IntermediatePerformance
Answer
Measure before changing anything. Rprof() is the built-in sampling profiler: it writes the call stack at a fixed interval and summaryRprof() reports self time versus total time per function, with memory.profiling = TRUE adding allocation figures. profvis::profvis() wraps that in an interactive flame graph aligned to your source lines, which is the fastest way to find the one line consuming ninety percent of the run. For micro-comparisons use bench::mark(), which checks that the alternatives return equal results, reports median time and allocated memory per expression, and shows garbage collection counts; microbenchmark is the older equivalent without the memory column.
The recurring culprits in real R scripts are predictable. Growing an object inside a loop with rbind(), c() or append() is quadratic because every iteration reallocates and copies, so the fix is to collect into a preallocated list and call do.call(rbind, lst) or dplyr::bind_rows() once at the end. Indexing a data frame row by row with df[i, ] copies the whole frame each time; convert to a matrix or data.table first.
Repeated string coercion inside a loop, and calling a vectorised function once per row instead of once per column, are both common. Reading the same CSV repeatedly instead of caching to Parquet or RDS is another. Unnecessary as.data.frame() conversions inside a hot function force copies. Finally, check whether the four hours are actually CPU: if the profiler shows time in a database driver or in file I/O, the fix is a smarter SQL query or column pruning at read time, not faster R.
# Interactive flame graph over your own source lines
profvis::profvis({
res <- run_nightly(input_path)
})
# Built-in sampling profiler
Rprof('prof.out', memory.profiling = TRUE, interval = 0.01)
run_nightly(input_path)
Rprof(NULL)
head(summaryRprof('prof.out', memory = 'both')$by.self, 10)
# Compare alternatives, with correctness checked
bench::mark(
loop = { out <- NULL; for (i in 1:1000) out <- rbind(out, df[i, ]); out },
lst = do.call(rbind, lapply(1:1000, \(i) df[i, ])),
dt = as.data.frame(data.table::rbindlist(lapply(1:1000, \(i) df[i, ]))),
iterations = 5, check = TRUE
)
# Prove where copies happen
lobstr::obj_size(df)
tracemem(df)
Key Points
- profvis for line-level flame graphs, Rprof plus summaryRprof for raw sampling
- bench::mark verifies equality and reports memory, not just time
- rbind or c() inside a loop is quadratic, collect and bind once
- Row-wise data frame indexing copies the frame; use matrix or data.table
Q37Explain R's garbage collector and ALTREP. Why does calling gc() rarely give memory back to the OS?
AdvancedMemory Management
Answer
R uses a generational, non-moving mark-and-sweep collector over two allocation areas: small vectors come from fixed-size cons cells organised into pages, and large vectors are malloc'd individually. The collector runs automatically when an allocation cannot be satisfied, and because it is generational it usually only scans young objects. Calling gc() manually forces a full collection and prints Ncells and Vcells usage, but it almost never shrinks the process resident size, for two reasons.
Free pages are returned to R's internal pool rather than to the operating system, and the allocator is non-moving so the heap fragments: one live object in a page keeps the whole page reserved. The practical consequence is that a long-running Shiny or plumber process shows a ratcheting RSS that never comes down, and the operational answer is to recycle worker processes rather than to sprinkle gc() calls. ALTREP, the alternative representation framework introduced in R 3.5.0, is the other half of the memory story.
It lets a vector be described rather than materialised: 1:1e9 is stored as a compact sequence with a start, length and step, so it costs a few bytes until something forces it into a real vector. Deferred string conversion and memory-mapped vectors use the same mechanism, and readr and arrow return ALTREP-backed columns. The gotcha is that any operation not aware of ALTREP expands it, so a compact sequence that you subset, name or modify suddenly allocates gigabytes. Diagnose with lobstr::obj_size(), which reports the compact size, and .Internal(inspect(x)) which prints the ALTREP class.
x <- 1:1e8
lobstr::obj_size(x) # about 680 bytes, ALTREP compact sequence
y <- x + 0L # materialised: about 400 MB
lobstr::obj_size(y)
.Internal(inspect(1:10)) # shows the compact_intseq ALTREP class
gc() # full collection; prints Ncells / Vcells
gc(reset = TRUE) # also resets the max-used counters
# Track peak usage inside a job
invisible(gc(reset = TRUE))
run_step()
sum(gc()[, 'max used'])
# Free a big object explicitly, then let the collector run
rm(y); invisible(gc())
Key Points
- Generational, non-moving mark-and-sweep with separate small/large pools
- Freed pages return to R's pool, not to the OS, so RSS ratchets upward
- ALTREP describes vectors compactly until an operation materialises them
- Recycle worker processes instead of relying on gc() in long-running apps
Q38R is single-threaded. How do you actually parallelise work, and what breaks when you do?
AdvancedParallel Computing
Answer
The R interpreter evaluates one expression at a time, so parallelism means multiple processes, not threads. parallel::mclapply() forks the current process on Linux and macOS: the children share memory copy-on-write, startup is nearly free, and every loaded package and object is already there. Forking does not exist on Windows and is unsafe inside RStudio, in a Shiny or plumber worker, and alongside multi-threaded BLAS or Java-backed packages, because forking a process that holds a lock or an open connection can deadlock the child. The portable alternative is a PSOCK cluster from makeCluster(), where each worker is a fresh R session: you must explicitly ship objects with clusterExport(), load packages with clusterEvalQ(), and pay serialisation cost on every input and output.
The future package unifies both behind plan(multisession) or plan(multicore), with furrr::future_map() giving purrr semantics and future.apply::future_lapply() giving base semantics, and it detects and errors on globals that cannot be exported. Three things break in practice. Random number generation is not reproducible unless you set RNGkind to the L'Ecuyer-CMRG stream generator or pass future.seed = TRUE, otherwise workers can draw correlated streams.
Memory multiplies: eight PSOCK workers each holding a copy of a 4 GB frame will exhaust the box, and copy-on-write savings from forking vanish as soon as a child modifies the object. And thread oversubscription is the most common silent killer: OpenBLAS or MKL already run matrix maths on every core, so eight R workers times eight BLAS threads means sixty-four threads fighting for cores. Cap it with RhpcBLASctl::blas_set_num_threads(1) inside each worker.
library(future); library(furrr)
plan(multisession, workers = 4) # portable: separate R sessions
res <- future_map(files, process_file, .options = furrr_options(seed = TRUE))
plan(sequential)
# Fork-based: cheap on Linux, unavailable on Windows
res <- parallel::mclapply(files, process_file, mc.cores = 4)
# Explicit PSOCK cluster: nothing is inherited
cl <- parallel::makeCluster(4)
parallel::clusterEvalQ(cl, library(dplyr))
parallel::clusterExport(cl, c('lookup', 'threshold'))
parallel::clusterSetRNGStream(cl, 20260811)
out <- parallel::parLapply(cl, files, process_file)
parallel::stopCluster(cl)
# Stop BLAS threads fighting the workers
RhpcBLASctl::blas_set_num_threads(1)
Key Points
- mclapply forks (Unix only, unsafe inside Shiny/RStudio); PSOCK is portable
- PSOCK workers inherit nothing: clusterExport and clusterEvalQ are mandatory
- Use L'Ecuyer-CMRG or future.seed = TRUE for reproducible parallel RNG
- Pin BLAS threads to 1 per worker to avoid core oversubscription
Q39When is Rcpp the right answer, and what are the pitfalls of dropping into C++?
AdvancedRcpp
Answer
Rcpp is warranted when the algorithm is genuinely iterative and cannot be vectorised: recursive relationships where each step depends on the previous one, custom rolling-window statistics, tree or graph traversal, Monte Carlo simulations with per-step branching, and dynamic programming. For those, a hundred-fold speedup over an R loop is routine. It is the wrong answer when the R version is slow for a fixable reason, so vectorise, preallocate, switch to data.table or matrix maths, and profile first.
The workflow is cppFunction() for a one-liner during exploration, sourceCpp() for a standalone .cpp file, and for a package put the file under src/ with LinkingTo: Rcpp in DESCRIPTION and Rcpp::compileAttributes() generating the glue. Pitfalls that come up in senior interviews: C++ indexes from zero while R indexes from one, so an off-by-one silently reads garbage rather than erroring. Passing a NumericVector gives you a view over R's memory, so modifying it modifies the caller's vector unless you clone() it, which is the reverse of R's value semantics.
The R API is not thread-safe, so you cannot call Rcpp objects from OpenMP threads or from RcppParallel workers, you must copy into plain C++ containers first. Long-running loops must call Rcpp::checkUserInterrupt() periodically or the session cannot be stopped. Missing values need care because NA_REAL is a NaN with a payload and integer NA is INT_MIN. And a compiled package needs a toolchain on every target machine, which is why Rtools on Windows and a build stage in your Docker image become part of the deployment story.
// [[Rcpp::export]]
NumericVector ewma_cpp(NumericVector x, double alpha) {
int n = x.size();
NumericVector out(n);
if (n == 0) return out;
out[0] = x[0];
for (int i = 1; i < n; ++i) { // zero-based indexing
if (i % 10000 == 0) Rcpp::checkUserInterrupt();
if (NumericVector::is_na(x[i])) { out[i] = out[i - 1]; continue; }
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1];
}
return out;
}
// In R:
// Rcpp::sourceCpp('ewma.cpp')
// bench::mark(cpp = ewma_cpp(x, 0.3), r = ewma_r(x, 0.3))
// Modifying an argument mutates the caller's vector
// NumericVector safe = clone(x);
Key Points
- Use it for genuinely iterative algorithms, not for unvectorised R code
- Zero-based indexing and clone() semantics are the two classic bugs
- The R API is not thread-safe, copy to std containers before OpenMP
- checkUserInterrupt() keeps long loops cancellable
Q40The dataset is 200 GB and the box has 32 GB of RAM. How do you work with it in R?
AdvancedBig Data
Answer
The answer is to stop pulling data into R's memory and push the work down to an engine that streams. The arrow package reads Parquet and Feather as a Dataset: open_dataset() scans metadata only, dplyr verbs against it build a query plan, and collect() is the single point where rows materialise. Because Parquet is columnar with row-group statistics, filtering on a partition column or a sorted column prunes entire files and row groups before reading, so a filter plus a group-by summary over 200 GB can complete in seconds while touching a few gigabytes. duckdb is the other workhorse: it is an in-process analytical database with a dbplyr backend, it can query Parquet and CSV files directly with duckdb_read_csv or read_parquet in SQL, and it spills to disk when a join or sort exceeds memory, which arrow will not always do.
For clusters, sparklyr connects to Spark and translates dplyr into Spark SQL, appropriate when the data already lives in a lake and the cluster exists, and overkill otherwise. Whichever backend you choose, the discipline is the same: keep the pipeline lazy, aggregate before collect(), never call collect() then filter, and inspect the generated query with show_query() or explain() before running it. Convert CSV to partitioned Parquet once, partitioned on the column you filter by most often such as date or region. If none of that fits, chunk deliberately: process one partition at a time and write intermediate results, which is exactly what the targets package orchestrates for a nightly pipeline.
library(arrow); library(dplyr)
ds <- open_dataset('s3://bucket/sales/', partitioning = c('year', 'month'))
summary_tbl <- ds |>
filter(year == 2026, region == 'north') |> # pushed down, prunes files
group_by(product) |>
summarise(revenue = sum(amount), n = n()) |>
collect() # only the small result lands
# DuckDB: spills to disk, full SQL, dbplyr backend
con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbExecute(con, "SET memory_limit='24GB'; SET threads=8;")
big <- tbl(con, "read_parquet('sales/**/*.parquet')")
big |> filter(amount > 1000) |> count(region) |> show_query()
big |> filter(amount > 1000) |> count(region) |> collect()
# Write partitioned Parquet once
write_dataset(ds, 'sales_part/', partitioning = c('year', 'month'),
format = 'parquet')
Key Points
- arrow open_dataset scans metadata; collect() is the only materialisation point
- Partitioned Parquet enables file and row-group pruning at read time
- duckdb spills to disk and handles joins that exceed RAM
- Always aggregate before collect() and check show_query() first
Q41How do you make a Shiny app survive a hundred concurrent users?
AdvancedShiny in Production
Answer
One R process serves one request at a time, so a single long computation blocks every other user connected to that process. Scaling therefore starts with process count: Posit Connect, ShinyProxy or shiny-server-pro run multiple R processes and route sessions across them, and in Kubernetes you run replicas behind a load balancer with sticky sessions because Shiny holds websocket state per session. Within a process, move slow work off the event loop with promises and future: wrapping a query in future_promise() lets the process serve other sessions while the query runs, but only if the future runs in a separate worker, and you must not touch reactive values inside the future, only in the then() continuation.
Cache aggressively: bindCache() on a render function keys the result on its inputs and shares it across sessions when scope = 'app', which turns a repeated dashboard query into a memory or disk hit. Structure the app with modules, NS() for namespaced ids and moduleServer() for the logic, so ids cannot collide and each screen can be tested in isolation. Precompute what you can into Parquet or a database at build time rather than at page load, and push heavy filtering into duckdb or Postgres instead of loading full tables into memory per session.
Watch two failure modes specifically: anything assigned outside server() is shared by every session in that process, which leaks one user's filtered data to another, and a reactive chain that re-reads a large file on every input change will pin memory per session. Load test before launch with shinyloadtest, which records a real session and replays it at concurrency, and profile with profvis attached to the running app.
library(shiny); library(promises); library(future)
plan(multisession, workers = 4)
# Module: namespaced UI + server
filterUI <- function(id) {
ns <- NS(id)
tagList(selectInput(ns('region'), 'Region', choices = NULL))
}
filterServer <- function(id, data) {
moduleServer(id, function(input, output, session) {
reactive({ req(input$region); dplyr::filter(data, region == input$region) })
})
}
server <- function(input, output, session) {
# Non-blocking: other sessions keep being served
output$tbl <- renderTable({
future_promise({ slow_query(Sys.Date()) }, seed = TRUE) %...>% head(20)
})
# Shared cache across sessions in this process
output$plot <- renderPlot({ make_plot(input$region) }) |>
bindCache(input$region, cache = 'app')
}
# shinyloadtest::record_session('http://localhost:3838'); then replay
Key Points
- One R process handles one request at a time, so scale by processes
- future_promise offloads slow work; never touch reactives inside the future
- bindCache with scope = 'app' shares results across sessions
- Objects outside server() are shared per process and leak between users
Q42How do you deploy an R model so another system can call it, and how do you keep the environment stable?
AdvancedDeployment
Answer
The standard route is plumber, which turns annotated R functions into a REST API: comments beginning with #* declare the endpoint, method, parameters and serialiser, and plumber handles JSON encoding both ways. A production plumber service needs a few things the tutorials skip. Load the model once at file scope, not inside the handler, or every request pays the deserialisation cost.
Validate inputs explicitly and return proper status codes with res$status, because a bad payload otherwise surfaces as a 500 with an R traceback. Add a /health endpoint for the load balancer and a request-id filter for logging. Since a plumber process is single-threaded, run several behind a reverse proxy or use a supervisor that recycles workers on a memory ceiling. vetiver sits a layer above: it packages the model with its metadata and required package versions, writes the plumber endpoints and the OpenAPI spec for you, stores versioned artefacts with pins to S3, a board directory or Posit Connect, and gives you a monitoring helper that compares live metrics against the training baseline over time.
For the environment, the reliable pattern in 2026 is a Docker image built from a pinned rocker/r-ver tag, with renv::restore() from a lockfile that points at a dated Posit Package Manager snapshot, and the model artefact copied in or pulled from a pin at start-up. Never install packages at container start, that turns a deploy into a lottery. Save the model with saveRDS and record the R version, the package versions and a hash of the training data alongside it, because six months later somebody will ask why the score for the same customer changed.
# plumber.R
library(plumber)
model <- readRDS('model.rds') # loaded once, at file scope
#* @filter logger
function(req) { req$id <- uuid::UUIDgenerate(); plumber::forward() }
#* Health check
#* @get /health
function() list(status = 'ok', r = as.character(getRversion()))
#* Score one applicant
#* @param income:double
#* @param tenure:int
#* @post /score
#* @serializer unboxedJSON
function(req, res, income, tenure) {
income <- suppressWarnings(as.numeric(income))
if (is.na(income)) { res$status <- 400; return(list(error = 'income invalid')) }
newdata <- data.frame(income = income, tenure = as.integer(tenure))
list(score = unname(predict(model, newdata, type = 'response')))
}
# pr('plumber.R') |> pr_run(host = '0.0.0.0', port = 8000)
# vetiver::vetiver_pin_write(board, vetiver_model(model, 'credit-score'))
Key Points
- plumber #* annotations define routes, params and serialisers
- Load the model at file scope and validate inputs with explicit status codes
- vetiver adds versioned pins, an OpenAPI spec and monitoring helpers
- Pin rocker image + renv.lock + dated repo snapshot; never install at start-up
Q43What does a validated R environment look like for a regulated clinical submission?
AdvancedRegulated Analytics
Answer
This decides senior statistical programming interviews at IQVIA, Novartis, Parexel and the Indian CROs, and it is where R has changed most in recent years. Historically SAS owned clinical reporting because regulators recognised its validated status. That changed once the R Consortium's R Validation Hub published a risk-based framework: instead of validating the language, you assess each package for risk using criteria such as maintenance activity, test coverage, documentation and community usage, with the riskmetric package scoring them automatically, and you document the assessment.
The pharmaverse ecosystem supplies the domain packages: admiral for deriving ADaM analysis datasets from SDTM, xportr for writing the SAS transport v5 files that submissions still require with correct labels, lengths and types, rtables and tfrmt for regulatory-grade tables, and datasetjson for the newer CDISC Dataset-JSON exchange format. The environment itself must be reproducible and evidenced: a pinned R version, an renv.lock resolved against a dated repository snapshot, a container image with a recorded digest, and a clean R CMD check log for any internal package. Every derivation is written as a documented function with unit tests, double programming or independent review is still standard practice for primary endpoints, and outputs carry traceability back to the source variables. The other practical point is that R and SAS coexist rather than compete in most Indian CROs today: R does exploration, graphics and increasingly ADaM derivation, while the final submission package may still be assembled in whichever system the sponsor's SOP mandates.
library(admiral); library(dplyr)
# Derive a study-day variable on an ADaM dataset (traceable derivation)
adae <- ae |>
derive_vars_merged(dataset_add = adsl,
by_vars = exprs(STUDYID, USUBJID),
new_vars = exprs(TRTSDT, TRTEDT)) |>
derive_vars_dt(new_vars_prefix = 'AST', dtc = AESTDTC) |>
derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(ASTDT))
# Apply CDISC metadata and write SAS transport v5
adae |>
xportr::xportr_type(spec) |>
xportr::xportr_length(spec) |>
xportr::xportr_label(spec) |>
xportr::xportr_write('adae.xpt', label = 'Adverse Events Analysis')
# Package risk assessment evidence
riskmetric::pkg_ref('admiral') |>
riskmetric::pkg_assess() |>
riskmetric::pkg_score()
Key Points
- Risk-based package assessment (R Validation Hub, riskmetric) replaced language validation
- pharmaverse: admiral for ADaM, xportr for XPT v5, rtables for TLFs
- Evidence = pinned R version, renv.lock, container digest, clean check logs
- Double programming and traceability remain SOP for primary endpoints
Q44How does the targets package change how you structure a long analysis pipeline?
AdvancedPipelines
Answer
A monolithic script re-runs everything after any edit, which is why analysts end up commenting out sections and manually caching intermediate RDS files. targets replaces that with a declarative dependency graph. You write _targets.R listing each step as tar_target(name, command), and targets inspects the command's code to discover which other targets it references, builds the graph, and on tar_make() runs only the targets whose code, upstream data or file inputs have changed, using a hash rather than a timestamp. Results are stored in _targets/objects and returned by tar_read() or loaded by tar_load().
File inputs are declared with format = 'file' so that changing the CSV invalidates everything downstream, and format = 'parquet' or 'qs' avoids RDS overhead for large tables. Dynamic branching with tar_target(pattern = map(x)) creates one target per element at runtime, which is how you fit a model per region without writing the loop, and the branches parallelise across workers through the crew backend. tar_visnetwork() draws the graph coloured by which nodes are outdated, which is the single most useful artefact for a code review or a regulatory audit trail. The discipline it enforces matters as much as the caching: every step becomes a pure function in R/, side effects and file paths become explicit, and the pipeline becomes runnable end to end in CI. Combined with renv for the package set and set.seed inside each target (or tar_option_set(seed) for deterministic per-target seeds), you get a run that reproduces months later, which is exactly what an auditor or a reviewer asks for.
# _targets.R
library(targets); library(tarchetypes)
tar_option_set(packages = c('dplyr', 'ggplot2'), format = 'qs', seed = 20260811)
tar_source() # loads every function in R/
list(
tar_target(raw_file, 'data/sales.csv', format = 'file'),
tar_target(sales, read_sales(raw_file)),
tar_target(regions, unique(sales$region)),
tar_target(fits, fit_region(sales, regions),
pattern = map(regions)), # one branch per region
tar_target(coefs, broom::tidy(fits), pattern = map(fits)),
tar_target(report, render_report(coefs), format = 'file')
)
# Shell / console
# tar_make() run only what is outdated
# tar_make(callr_function = NULL) debug in the current session
# tar_visnetwork() graph coloured by outdated nodes
# tar_read(coefs) pull a result without re-running
Key Points
- Dependencies are inferred from code, and invalidation uses hashes not timestamps
- format = 'file' tracks external inputs and outputs correctly
- Dynamic branching with pattern = map() fans out and parallelises via crew
- tar_visnetwork() gives an auditable picture of what is stale and why
Q45Explain R's restart mechanism. How would you build a batch job that recovers from a failed record without restarting?
AdvancedCondition System
Answer
R inherited a Common Lisp style condition system, which is more powerful than the try/catch model most languages provide. A condition is an object with a class vector and a message; signalling it does not by itself unwind the stack. Restarts are named recovery strategies that the signalling code establishes with withRestarts(), and a handler higher up can select one by calling invokeRestart('name'), at which point control returns to the point where the restart was established rather than to the handler.
That is what makes recovery possible without abandoning the loop. The built-in example is muffleWarning: warning() establishes it, and suppressWarnings() installs a calling handler that invokes it, so the warning is silenced and the original code continues. For a batch job, you establish a skip_record restart around each record's processing, then install a calling handler for errors that logs the failure, records the identifier and invokes the restart, so a thousand-row file with three bad rows produces 997 results and three logged failures in one pass rather than three sequential crashes.
Contrast that with tryCatch, which unwinds and would need the loop restructured around it, and with purrr::safely, which is a convenient special case of the same idea but gives the handler no choice of recovery strategy. Two implementation notes: use rlang::abort() with a class so handlers match on class rather than message text, and attach the failing identifier to the condition object so the handler can log it without closing over loop state. computeRestarts() lists what is available at any point, which is useful when debugging someone else's handler.
process_all <- function(records) {
failures <- list()
out <- withCallingHandlers(
lapply(records, function(rec) {
withRestarts(
process_one(rec),
skip_record = function() NULL # recovery strategy
)
}),
error = function(e) {
failures[[length(failures) + 1]] <<- list(id = e$id, msg = conditionMessage(e))
r <- computeRestarts('skip_record')
if (length(r)) invokeRestart(r[[1]]) # resume the loop
}
)
list(results = Filter(Negate(is.null), out), failures = failures)
}
process_one <- function(rec) {
if (is.na(rec$amount))
rlang::abort('amount is missing', class = 'batch_bad_record', id = rec$id)
rec$amount * 1.18
}
Key Points
- Signalling a condition does not unwind; restarts choose how to resume
- invokeRestart returns control to where withRestarts was established
- suppressWarnings is just a calling handler invoking muffleWarning
- Attach an id and a class to the condition so handlers log and match cleanly
Frequently Asked Questions
What does an R developer earn in India in 2026?
Roughly ₹6-18 LPA depending on domain. Entry-level analyst roles using R for reporting start near ₹4-7 LPA, mid-level data scientists and statistical programmers sit around ₹10-16 LPA, and clinical or biostatistics programmers with CDISC and ADaM experience at IQVIA, Novartis or Parexel command the top of the band and beyond, because that skill set is scarce and directly tied to regulated submissions. Analytics consultancies such as ZS Associates, Mu Sigma, Fractal Analytics and Tiger Analytics pay comparably for R plus SQL plus domain knowledge. Pure R without SQL, cloud or a statistics specialisation caps out lower, closer to ₹8-10 LPA.
How long does it take to prepare for an R interview?
If you already write R daily, three to four weeks of focused revision is enough: one week on the language internals (copy-on-modify, environments, closures, S3), one week on data manipulation with both dplyr and data.table, one week on modelling plus ggplot2 and Shiny, and a final week on production topics such as renv, testthat, profiling and deployment. Coming from Python, budget eight to ten weeks, mostly because R's vectorisation idioms, factors and non-standard evaluation have no direct equivalent. Practising on real messy CSVs beats working through tutorials, since interview tasks are almost always a cleaning problem followed by a summary and a chart.
How do R interviews differ for freshers versus experienced candidates?
Freshers get language mechanics and statistics: coercion rules, apply family, factors, subsetting, a live dplyr or ggplot2 task, and questions on hypothesis testing, p-values and regression assumptions. A clean portfolio project with a Shiny app or an R Markdown or Quarto report carries real weight because most fresher CVs look identical. Experienced candidates get architecture and failure modes: how you kept a nightly job inside its memory budget, why you chose data.table over dplyr on a specific dataset, how you handled reproducibility across environments, how you tested and deployed the code, and in pharma how you evidenced a validated package set. Expect at least one question about a production incident you actually debugged.
Is R still worth learning in 2026, or should I just learn Python?
Learn Python if you want the broadest job market. Learn R if you want the roles where statistics is the product: clinical trial reporting, biostatistics, pharmacovigilance, econometrics, actuarial work, survey and market research, and academic research. Those roles are well paid, less crowded, and R's modelling stack (lme4, survival, brms, the pharmaverse packages) has no equivalent elsewhere. The strongest position is both: R for analysis and reporting, Python for engineering and deployment, SQL underneath. Many Indian analytics teams hire specifically for that combination, and the arrow and duckdb ecosystems now make moving data between the two nearly free.
Should I learn dplyr or data.table first?
Learn dplyr first because it reads closer to English, most tutorials and colleagues use it, and it composes with the rest of the tidyverse for reshaping, string handling and plotting. Add data.table once you hit datasets in the tens of millions of rows or a memory-constrained job, since its update-by-reference and keyed joins are measurably faster and lighter. Knowing both is a genuine differentiator in interviews, especially at firms processing large transaction or claims data, and dtplyr lets you write dplyr syntax that executes on a data.table backend if you want the performance without relearning the grammar.
Do I need to know Shiny to get an R job?
Not for every role, but it is the single most visible skill on an R CV. Shiny turns an analysis into something a business user can click, which is why analytics consultancies and internal data teams ask for it so often, and a deployed app is far more persuasive in an interview than a notebook. Learn reactivity properly (reactive versus observe, eventReactive, req) and modules, then one deployment path such as shinyapps.io, Posit Connect or a Docker container. Roles in pure biostatistics and statistical programming need it less, since their output is tables, listings and figures rather than dashboards.
Introduction
R is the language that wins wherever the statistics is the deliverable rather than a feature bolted onto an app. Clinical trial reporting, pharmacovigilance signal detection, credit risk scorecards, market research, econometrics, actuarial work and academic research all still run on R in 2026, backed by more than twenty thousand packages on CRAN and the Bioconductor repository for genomics. Python took the general machine-learning market, but the statistical modelling stack (lme4 for mixed models, survival for time-to-event, rstan and brms for Bayesian work, the pharmaverse packages for CDISC datasets) has no equivalent elsewhere, which is exactly why R roles keep appearing.
In India the demand concentrates in pharma and CRO analytics (Novartis, IQVIA, Parexel), analytics consultancies (ZS Associates, Mu Sigma, Fractal Analytics, Tiger Analytics) and market research (Nielsen), plus risk and actuarial teams inside banks and insurers. Interviewers there rarely ask trivia. They ask whether you understand copy-on-modify semantics well enough to stop a nightly job from tripling its memory, whether you can write a dplyr helper that accepts a column name, whether you know when data.table beats the tidyverse, and whether you can explain what a Shiny reactive actually recomputes. A statistics question is almost always folded in alongside the code.
This guide covers 45 R interview questions asked in 2026, ordered basic to advanced, with runnable code on most of them. The basic section locks down atomic vectors, coercion, factors, subsetting and the apply family. The intermediate section is where offers are usually decided: copy-on-modify, environments and closures, data.table reference semantics, tidy evaluation, condition handling, renv and testthat. The advanced section covers ALTREP and garbage collection, parallel backends, Rcpp, out-of-memory data with arrow and duckdb, production Shiny, model deployment and validated R for regulatory submissions.
Ready to practice R interviews?
Don't just read, practice these R questions live with an AI interviewer that asks follow-ups and scores your answers.