The 60 R questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
R is a language and free software environment for statistical computing and graphics, created by Ross Ihaka and Robert Gentleman in the early 1990s as an open implementation of the S language. It's dynamically typed, vector-first, and functional at heart, which is why a whole analysis often indicates a few vectorized expressions instead of loops. The base install already covers linear models, hypothesis tests, and publication-quality plots, and the CRAN archive adds thousands of packages on top. In interviews, R questions probe understanding of vectors, data frames, the apply family, factors, and copy-on-modify behavior, not memorized trivia. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're still building fundamentals, An Introduction to R from the R Core Team is the canonical starting text; increasingly the first R round runs as a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Python Full Course for Beginners
Video: Python Full Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your R certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
R is a language and free software environment built for statistical computing and graphics. It's open source, dynamically typed, and vector-first, which means most operations naturally work on a whole vector at once rather than element by element, so a lot of analysis indicates a few short expressions.
It's used for data analysis, statistics, machine learning, and visualization. The base install covers models and plots, and the CRAN archive adds thousands of packages for almost any analysis task.
Key point: A one-line definition plus a concrete use (data analysis and stats) beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: R Programming Crash Course (Traversy Media, YouTube)
A vector is R's core data structure: an ordered collection of values that all share one type. R has no separate scalar type, so even a single number like 5 is really a length-one vector, which is why so many operations naturally work on whole vectors at once.
It matters because R is built for vectorized operations. Adding two vectors, filtering with a logical vector, or applying a function across one is idiomatic and fast, while element-by-element loops are usually the slower, less R-like choice.
nums <- c(10, 20, 30)
nums + 1 # 11 21 31, vectorized
length(nums) # 3
nums[2] # 20, 1-based indexingKey point: Saying 'R has no scalars, just length-one vectors' early signals you understand the model, not just the syntax.
R has six atomic vector types: logical, integer, double (the default for numbers), character, complex, and raw for bytes. The first four are the ones you use daily. Every element of an atomic vector shares a single type, which is what makes them different from lists.
If you combine types with c(), R coerces to the most general one: logical to integer to double to character. Knowing this coercion order explains many surprising results.
c(TRUE, 1L) # 1 1 (coerced to integer)
c(1L, 2.5) # 1.0 2.5 (coerced to double)
c(1, "a") # "1" "a" (coerced to character)
typeof(c(1, "a")) # "character"Key point: The follow-up is usually 'what happens with c(1, "a")?'. Have the coercion hierarchy ready.
An atomic vector holds elements of a single type, all numbers or all characters. A list is the flexible container: it can hold elements of different types, including other lists, so one list can mix a number, a string, and a whole vector as its members.
Lists are how R holds mixed or hierarchical data. A data frame is actually a list of columns under the hood, which is why it can mix numeric and character columns.
v <- c(1, 2, 3) # atomic vector, all numeric
l <- list(1, "a", TRUE, c(4, 5)) # list, mixed types
l[[2]] # "a", double brackets extract
l[2] # a list of length 1| Vector | List | |
|---|---|---|
| Element types | One type only | Any mix, including nesting |
| Created with | c() | list() |
| Single element | x[i] | x[[i]] |
| Typical use | Numeric or character series | Mixed records, model output |
R has three main assignment operators: the leftward <- , the plain = , and the rightward -> . The community standard is <- for creating and updating variables, while = is conventionally reserved for passing named arguments inside a function call, so the two roles stay visually distinct.
There's also <<- , which assigns in an enclosing or global scope. Use it rarely and deliberately, because reaching outside the current scope makes code hard to reason about.
x <- 5 # idiomatic assignment
y = 5 # works, but reserve = for arguments
10 -> z # rightward assignment, valid but unusual
mean(x = c(1, 2)) # here = names an argumentWhen two vectors of different lengths meet in an operation, R recycles the shorter one, repeating it until it matches the longer vector's length. This is why adding a length-one vector to a long one adds it to every element.
If the longer length isn't a multiple of the shorter, R still recycles but warns you. Silent recycling is a common source of bugs, so watch for length mismatches.
c(1, 2, 3, 4) + c(10, 20) # 11 22 13 24, clean recycle
c(1, 2, 3) + c(10, 20) # 11 22 13, with a warning
c(1, 2, 3, 4) * 2 # 2 4 6 8, length-1 recycledKey point: a non-multiple length triggers a warning.
Missing values are NA, short for Not Available. They're contagious: most operations involving an NA return NA, so mean(c(1, NA, 3)) comes back as NA unless you explicitly tell the function to ignore the missing value first.
Test for them with is.na(), never with == , because NA == NA is itself NA. Many functions take na.rm = TRUE to drop missing values before computing.
x <- c(1, NA, 3)
mean(x) # NA
mean(x, na.rm = TRUE) # 2
is.na(x) # FALSE TRUE FALSE
x[!is.na(x)] # 1 3, drop the NAsKey point: The trap is writing x == NA. Saying 'you must use is.na because NA == NA is NA' is exactly what this screens for.
NA is a missing value that still occupies a slot. NULL is the absence of a value entirely, a zero-length object, often used to drop things. NaN is 'not a number', from undefined math like 0/0. Inf and -Inf are infinity, from things like 1/0.
The key distinction: NA keeps its place in a vector, while NULL disappears. is.na() is TRUE for both NA and NaN, so use is.nan() when you need to separate them.
A data frame is R's table structure: a list of equal-length columns where each column can carry its own type. Rows are observations and columns are variables, which matches how real tabular data looks. Because it's a list of columns underneath, one column can be numeric while the next is character.
Because it's a list of columns under the hood, one column can be numeric and another character. It's the object almost every analysis and modeling function expects.
df <- data.frame(
name = c("Asha", "Ben"),
age = c(31, 26),
stringsAsFactors = FALSE
)
nrow(df) # 2
df$age # 31 26
df[df$age > 30, ] # rows where age > 30Watch a deeper explanation
Video: R Tutorial - Using the Data Frame in R (DataCamp, YouTube)
R indexing is 1-based and works four ways. Positive integers keep the positions you name, negative integers drop them, a logical vector of the same length keeps the TRUE positions, and character names pull elements from a named vector. Logical indexing is the one you'll reach for most when filtering data.
Logical indexing is the workhorse for filtering data. Index 0 returns an empty vector rather than the first element, which surprises people coming from other languages.
x <- c(10, 20, 30, 40)
x[2] # 20, keep position 2
x[-1] # 20 30 40, drop position 1
x[c(TRUE, FALSE, TRUE, FALSE)] # 10 30, logical
x[x > 20] # 30 40, filterKey point: The interviewer often follows with 'how do you drop an element?'. Negative indexing (x[-1]) is the answer they want.
A factor is R's type for categorical data. It stores values as integer codes behind a set of labelled levels, so 'Low', 'Medium', 'High' become 1, 2, 3 with those labels attached. That representation is compact and tells modeling functions to treat the column as distinct groups.
Use factors for categories that feed into models or need a defined order, since a model treats a factor as a set of groups. Convert to an ordered factor when the categories have a natural ranking.
sizes <- factor(c("S", "L", "M", "S"),
levels = c("S", "M", "L"))
levels(sizes) # "S" "M" "L"
as.integer(sizes) # 1 3 2 1, the codes
table(sizes) # counts per levelBase R uses read.csv(), which reads a comma-separated file into a data frame. The tidyverse readr package offers read_csv(), which is faster on big files, guesses column types more carefully, never converts strings to factors, and returns a tibble instead of a plain data frame.
Common arguments handle real files: header, sep for the delimiter, stringsAsFactors to control factor conversion, and na.strings to mark missing codes.
df <- read.csv("data.csv", stringsAsFactors = FALSE)
head(df)
str(df) # structure: types and first values
# tidyverse alternative
# library(readr)
# df <- read_csv("data.csv")install.packages("name") downloads a package from CRAN and installs it to disk, which you do once per machine. library(name) then loads that installed package into your current session, and you run library() again at the start of every new session because loading isn't permanent.
A detail interviewers like: install.packages takes the name as a string, while library takes it unquoted. You can also call a function without loading with the package::function() form.
install.packages("dplyr") # once, name as a string
library(dplyr) # each session, unquoted
dplyr::filter(mtcars, mpg > 25) # use without library()You create a function with the function() keyword and assign it to a name. Arguments can carry default values, and the last expression the body evaluates is returned automatically, so you rarely write return() except for an early exit partway through. Braces are optional for a one-line body.
Functions in R are first-class objects: you can pass them to other functions, return them, and store them in lists. That's what makes the apply family work.
add <- function(x, y = 1) {
x + y # last expression is returned
}
add(5) # 6, default y
add(5, 10) # 15
square <- function(x) x^2 # one-liner, no braces neededR has if / else, for, while, and repeat, with much the same shape as other languages. A for loop walks over the elements of a vector or list, while break exits a loop early and next skips to the following iteration. The condition in an if must be a single logical value.
The R-specific advice: prefer vectorized operations and the apply family over explicit loops when you can, because they read better and are usually faster. Loops aren't wrong, they're just often not the R-like choice.
total <- 0
for (n in 1:5) {
if (n %% 2 == 0) next # skip evens
total <- total + n
}
total # 9 (1 + 3 + 5)
# vectorized equivalent
sum((1:5)[1:5 %% 2 != 0]) # 9Key point: Volunteering the vectorized version after the loop shows you know the idiomatic R way, which is what separates users from beginners.
Watch a deeper explanation
Video: R 2.4 - for() Loops and Handling Missing Observations (Google for Developers, YouTube)
Arithmetic: + , - , * , / , ^ for power, %% for modulo, and %/% for integer division. Comparisons: == , != , < , > , <= , >= , all returning logical vectors.
Because operators are vectorized, comparing a vector to a value returns a logical vector you can use for filtering. Logical operators come in two forms, which is the next thing interviewers ask about.
7 %% 3 # 1, modulo
7 %/% 3 # 2, integer division
2 ^ 10 # 1024
c(1, 5, 9) > 4 # FALSE TRUE TRUE& and | are vectorized: they compare element by element and return a logical vector. && and || are scalar: they look only at the first element and short-circuit, which is what you want inside an if condition.
Using & where you meant && (or the reverse) is a classic bug. From R 4.3, && and || actually error on inputs longer than one, which catches the mistake early.
c(TRUE, FALSE) & c(TRUE, TRUE) # TRUE FALSE, elementwise
TRUE && FALSE # FALSE, single result
if (length(x) > 0 && x[1] > 0) { # && short-circuits safely
# ...
}Key point: Say '&& for if conditions, & for filtering vectors'. That one line answers the follow-up before it's asked.
The colon operator makes simple integer sequences, so 1:10 counts up by one. seq() is the flexible version that takes a step with by or a target count with length.out. rep() repeats values, either repeating each element in place or tiling the whole vector a number of times.
These show up constantly for building index vectors, test data, and grouping labels, so knowing the argument names saves time in live coding.
1:5 # 1 2 3 4 5
seq(0, 1, by = 0.25) # 0.00 0.25 0.50 0.75 1.00
seq(1, 10, length.out = 5) # 1.00 3.25 5.50 7.75 10.00
rep(c(1, 2), times = 3) # 1 2 1 2 1 2
rep(c(1, 2), each = 3) # 1 1 1 2 2 2str() shows the structure, listing each column's type and first few values, and it's the fastest way to understand an unfamiliar data frame. summary() gives per-column statistics like min, median, and max. head() and tail() print the first or last rows so you can eyeball the actual data.
dim(), nrow(), ncol(), and names() answer the basic shape questions. Reaching for str() first is a small habit that indicates real experience.
str(mtcars) # types and sample values per column
summary(mtcars) # min, median, mean, max per column
head(mtcars, 3) # first 3 rows
dim(mtcars) # rows and columnsA matrix is a two-dimensional atomic structure where every element shares one type, most often numeric. You build one with matrix(), which fills column by column by default, and you index it with two positions in [row, column] form. Leaving one blank keeps that whole dimension.
Matrices support real linear algebra: %*% is matrix multiplication, t() transposes, and solve() inverts. For mixed-type tabular data you want a data frame instead.
m <- matrix(1:6, nrow = 2) # fills by column
m[1, ] # first row: 1 3 5
m[, 2] # second column: 3 4
t(m) # transpose
m %*% t(m) # matrix multiplyString work in base R centers on a handful of functions. paste() and paste0() join strings, with paste0() using no separator between pieces. nchar() counts characters, toupper() and tolower() change case, and substr() extracts a range by position.
For pattern work, grepl() tests for a match, gsub() replaces, and sub() replaces the first hit. The stringr package wraps these in a consistent, easier-to-remember interface.
paste("Hello", "World") # "Hello World"
paste0("file", 1:3, ".csv") # "file1.csv" "file2.csv" "file3.csv"
toupper("abc") # "ABC"
grepl("cat", c("cats", "dog")) # TRUE FALSE
gsub("a", "@", "banana") # "b@n@n@"R has strong in-session help. ?function or help(function) opens a function's documentation, and example(function) runs the examples from that page. When you don't know the exact name, ??term does a fuzzy search across every installed package's help files.
ls() lists objects in your environment, rm() removes them, and getwd() / setwd() manage the working directory. Knowing you can read the docs in-session is exactly what an interviewer hopes to see.
?mean # open the help page for mean
??"linear model" # fuzzy search across packages
ls() # objects in the workspace
rm(x) # remove object xFor candidates with working experience: the apply family, tidyverse judgment, reshaping, and the questions that separate users from understanders.
The apply family runs a function repeatedly without writing an explicit loop. apply() works over matrix rows or columns, lapply() over a list returning a list, sapply() simplifies the result, vapply() adds a safety type check, and mapply() iterates over several inputs at once.
They read as 'run this function across this structure', which is closer to how R thinks than a for loop. Picking the right one signals fluency.
m <- matrix(1:6, nrow = 2)
apply(m, 1, sum) # row sums: 9 12
apply(m, 2, max) # column maxes: 2 4 6
sapply(1:3, function(x) x^2) # 1 4 9
lapply(1:2, function(x) x * 10) # list(10, 20)Key point: The follow-up is 'sapply vs lapply?'. Lead with 'sapply simplifies, lapply always returns a list' and you've nailed it.
Not dramatically, and that's the honest answer the question needs. The apply family is still a loop running at the R level under the hood, so the big speedups come from true vectorization, where the work drops into compiled C, not from swapping a for loop for sapply.
The real reasons to use apply functions are readability and intent: they say 'map this function over this structure' clearly, and they avoid the classic bug of growing a vector inside a loop. For raw speed, vectorize or pre-allocate.
Key point: Interviewers plant this to see if you'll parrot 'apply is always faster'. The correct answer is that vectorization is the real speedup.
dplyr gives a small set of verbs that cover most data wrangling: filter() keeps rows, select() keeps columns, mutate() adds or changes columns, arrange() sorts rows, group_by() sets grouping, and summarise() collapses each group to a single summary row. Each verb takes a data frame and returns one.
Chained with the pipe, they read top to bottom like a recipe, which is why teams reach for dplyr over base R subsetting for anything beyond one step.
library(dplyr)
mtcars %>%
filter(mpg > 20) %>%
group_by(cyl) %>%
summarise(avg_hp = mean(hp), n = n()) %>%
arrange(desc(avg_hp))Watch a deeper explanation
Video: Hands-on dplyr tutorial for faster data manipulation in R (Data School, YouTube)
The pipe passes the value on its left into the function on its right as the first argument, so x %>% f(y) is the same as f(x, y). Chaining several pipes turns a nest of inside-out calls into a readable top-to-bottom sequence that reads like the steps of a recipe.
There are two: the magrittr %>% from the tidyverse and the native |> added in R 4.1. The native pipe is built in and slightly stricter; %>% supports the . placeholder for arguments other than the first.
# nested, read inside-out
round(mean(c(1, 2, 3, 4)), 1)
# piped, read left to right
c(1, 2, 3, 4) |> mean() |> round(1) # native pipe
c(1, 2, 3, 4) %>% mean() %>% round(1) # magrittr pipeKey point: Knowing both pipes, and that |> is native since R 4.1, indicates someone who keeps up with the language.
Wide format spreads a variable across several columns, like one column per month. Long format stacks those into key-value rows, with a column naming the month and a column holding the value. Most tidyverse tools and ggplot2 want long, tidy data with one observation per row.
tidyr does the reshaping: pivot_longer() goes wide to long, pivot_wider() goes long to wide. In base R, reshape() does the same job with a clunkier interface.
library(tidyr)
wide <- data.frame(id = 1:2, jan = c(10, 20), feb = c(15, 25))
long <- pivot_longer(wide, cols = c(jan, feb),
names_to = "month", values_to = "sales")
# back to wide
pivot_wider(long, names_from = month, values_from = sales)Base R uses merge(), where you control the join type with the all, all.x, and all.y arguments to get inner, full, left, or right behavior. dplyr offers clearer named verbs, inner_join(), left_join(), right_join(), and full_join(), that map one to one onto the SQL joins you already know.
The dplyr names map directly to SQL joins, which makes the intent obvious. Watch for the join key: mismatched types or duplicate keys quietly change your row count.
library(dplyr)
users <- data.frame(id = 1:3, name = c("A", "B", "C"))
orders <- data.frame(id = c(1, 1, 2), amt = c(10, 20, 30))
left_join(users, orders, by = "id") # keep all users
inner_join(users, orders, by = "id") # only matching ids| Join | dplyr | base merge() |
|---|---|---|
| Inner | inner_join() | merge(x, y) |
| Left | left_join() | merge(x, y, all.x = TRUE) |
| Right | right_join() | merge(x, y, all.y = TRUE) |
| Full | full_join() | merge(x, y, all = TRUE) |
There are three common paths. Base R has aggregate() and tapply() for split-apply-combine. The tidyverse pairs group_by() with summarise() for readable, chainable code. data.table uses its dt[i, j, by] syntax and runs fastest on large data. All three split by a group, compute per group, then combine.
All three answer the same question: split the data by a group, compute something per group, and combine. Knowing more than one path shows range.
# base R
aggregate(mpg ~ cyl, data = mtcars, FUN = mean)
# tapply
tapply(mtcars$mpg, mtcars$cyl, mean)
# dplyr
library(dplyr)
mtcars %>% group_by(cyl) %>% summarise(avg = mean(mpg))ggplot2 builds a plot in layers, starting from a data frame. You map columns to visual aesthetics like x, y, and color with aes(), then add one or more geometric layers such as geom_point or geom_bar that decide how those mappings are drawn. Each layer stacks on with a plus sign.
The grammar means you compose a plot from parts instead of picking a canned chart type: data, aesthetics, geoms, scales, and facets add up with +. That composability is why it's the default plotting choice for most R users.
library(ggplot2)
ggplot(mtcars, aes(x = wt, y = mpg, color = factor(cyl))) +
geom_point(size = 3) +
geom_smooth(method = "lm", se = FALSE) +
labs(title = "Weight vs MPG", color = "Cylinders")Watch a deeper explanation
Video: ggplot for plots and graphs. An introduction to data visualization using R programming (R Programming 101, YouTube)
R uses copy-on-modify: when you assign y <- x, both names point to the same underlying object in memory until one of them is actually changed. Only at that moment does R make a real copy, which means plain assignment is cheap and no data is duplicated until you modify it.
The practical effect is that functions can't accidentally mutate their arguments; changing a passed-in vector inside a function copies it first. That safety costs memory when you modify large objects in a loop, which is why growing objects iteratively is slow.
x <- c(1, 2, 3)
y <- x # no copy yet, shared
y[1] <- 99 # now y is copied, x untouched
x # 1 2 3
y # 99 2 3Key point: Linking copy-on-modify to 'why is growing a vector in a loop slow?' shows you understand the memory model, not just the rule.
An environment is a container of name-to-value bindings, and R uses lexical scoping to resolve names. A function looks for a name first in its own environment, then in the environment where the function was defined, and on up the chain to the global environment and the loaded packages beyond it.
This is what makes closures work: a function returned from another function remembers the variables from its defining scope. Unlike most R objects, environments are mutable and passed by reference.
make_counter <- function() {
count <- 0
function() {
count <<- count + 1 # modify the enclosing binding
count
}
}
next_id <- make_counter()
next_id() # 1
next_id() # 2A closure is a function bundled with the environment it was created in, so it keeps access to variables from that enclosing scope even after the outer function returns. In R, most functions you write are technically closures.
They power counters, function factories, and callbacks that carry state. The <<- operator lets the inner function update a captured variable, which is the mechanism behind a stateful closure.
power_of <- function(exp) {
function(x) x ^ exp # captures exp
}
square <- power_of(2)
cube <- power_of(3)
square(5) # 25
cube(2) # 8S3 is R's simplest and most common object system. An object gets a class attribute, and generic functions like print() or summary() dispatch to a method named generic.class, such as print.lm for a linear model. There's no formal class definition; you just set the class and write the matching methods.
It's informal by design: there's no formal class definition, you just set a class and write methods. That flexibility is why base R and most packages use S3, and why it's the system the question expects you to know first.
dog <- list(name = "Rex")
class(dog) <- "animal"
print.animal <- function(x, ...) {
cat("Animal named", x$name, "\n")
}
print(dog) # dispatches to print.animalS3 is informal: class is just an attribute and dispatch is by naming convention. S4 is formal, with defined slots, types, and validity checks, used where structure matters, like Bioconductor. R5, or Reference Classes, adds mutable, reference-based objects that behave like classes in Java or Python.
Most everyday R uses S3. Reach for S4 when you need enforced structure and multiple dispatch, and R5 (or the R6 package) when you genuinely need mutable objects with methods.
| System | Formality | Mutability | Typical use |
|---|---|---|---|
| S3 | Informal, attribute-based | Copy-on-modify | Base R, most packages |
| S4 | Formal slots and validity | Copy-on-modify | Bioconductor, strict structure |
| R5 / R6 | Formal, reference-based | Mutable in place | Stateful objects, GUIs |
Vectorizing means expressing an operation over a whole vector at once instead of looping element by element. x + y adds two vectors in one step, with the loop running in fast compiled C rather than interpreted R.
It matters for both speed and readability. A vectorized expression is shorter and often many times faster than the equivalent for loop, because it skips R's per-iteration overhead.
# loop version
out <- numeric(length(x))
for (i in seq_along(x)) out[i] <- x[i] * 2
# vectorized: same result, faster and clearer
out <- x * 2
# ifelse is a vectorized conditional
ifelse(x > 0, "pos", "neg")Key point: Mentioning that the speedup comes from the loop running in C, not from magic, is the depth the question needs here.
match.arg() validates a string argument against a set of allowed values and lets callers use partial matches, which is how base functions accept type = "pearson" or just "p". do.call() calls a function with its arguments supplied as a list, useful when you build an argument list at runtime.
Both show up when you write flexible functions. do.call() in particular is the clean way to call a function whose arguments you have assembled programmatically.
summarize <- function(x, stat = c("mean", "median")) {
stat <- match.arg(stat) # validates and completes
do.call(stat, list(x)) # calls mean(x) or median(x)
}
summarize(c(1, 2, 3, 4), "median") # 2.5
summarize(c(1, 2, 3, 4)) # 2.5? no: mean = 2.5A tibble is the tidyverse's modern take on the data frame. It prints only the first rows with column types, never converts strings to factors on its own, and doesn't do partial column-name matching, so it surprises you less.
It's still a data frame underneath and works with base functions. The differences are about safer defaults and cleaner printing, not a different structure.
The base functions are grep() and grepl() for matching, sub() and gsub() for replacing the first or all matches, and regmatches() with regexpr() for pulling out the matched text. By default they use POSIX-style regex, and passing perl = TRUE switches them to the richer Perl-compatible engine.
One R-specific trap: backslashes are doubled in regular strings, so a digit class is "\\d". Raw strings (r"(...)") since R 4.0 remove that pain, and stringr wraps the base functions in a friendlier interface.
x <- c("order-123", "ref-456", "none")
grepl("[0-9]+", x) # TRUE TRUE FALSE
gsub("[^0-9]", "", x) # "123" "456" ""
regmatches(x, regexpr("[0-9]+", x)) # "123" "456"R has two core classes. Date holds a calendar date as the number of days since 1970-01-01. POSIXct holds a full date-time as seconds since that same epoch, along with a time zone. as.Date() and as.POSIXct() parse strings into these classes, and format() turns them back into readable text.
Date math just works: subtracting two Dates gives a difftime in days. The lubridate package makes parsing and arithmetic far easier with functions like ymd() and months().
d1 <- as.Date("2026-07-04")
d2 <- as.Date("2026-01-01")
as.numeric(d1 - d2) # 184 days
format(d1, "%B %d, %Y") # "July 04, 2026"
weekdays(d1) # day of the weektryCatch() is the main tool: it runs an expression and lets you supply handlers for error, warning, and message conditions, plus a finally block that always runs. try() is a lighter option that just keeps going after an error.
You raise conditions with stop() for errors and warning() for warnings. R's condition system is more general than a simple try/catch, but tryCatch covers the everyday cases.
safe_log <- function(x) {
tryCatch(
log(x),
warning = function(w) NA, # log(-1) warns
error = function(e) NA
)
}
safe_log(10) # 2.302585
safe_log(-1) # NAadvanced rounds probe internals, performance, and production judgment. Expect every answer here to draw a follow-up.
R evaluates function arguments lazily, meaning an argument isn't computed until the function actually uses it. Each argument is wrapped in a promise that holds the unevaluated expression along with the environment it came from, and that promise is forced the first time the value is needed inside the body.
This is why a function can accept an argument it never touches without erroring, why default arguments can refer to other arguments, and how non-standard evaluation packages like dplyr capture and rewrite the expressions you pass.
f <- function(a, b) {
a * 2 # b is never evaluated
}
f(5, stop("boom")) # returns 10, no error
g <- function(x, y = x * 2) y # default uses another arg
g(4) # 8Key point: Connecting lazy evaluation to how dplyr's non-standard evaluation works signals you understand why the tidyverse feels different from base R.
Non-standard evaluation means a function captures the expression you pass rather than its value, then evaluates it in a context it chooses. That's why filter(df, mpg > 20) works even though there's no object called mpg in your session; dplyr evaluates mpg inside the data frame.
The tidyverse builds on this with tidy evaluation: quo(), enquo(), and the {{ }} embrace let you write functions that take bare column names. It's the same mechanism behind subset() and with() in base R.
library(dplyr)
top_by <- function(data, col) {
data %>% arrange(desc({{ col }})) %>% head(3)
}
top_by(mtcars, mpg) # col is captured, not evaluated earlyR stores objects on a heap and uses a generational, mark-and-sweep garbage collector that reclaims memory automatically once objects become unreachable, so you don't free anything by hand. gc() forces a collection and prints current usage, though you rarely need to call it since the collector runs on its own.
The practical issues are copy-on-modify creating extra copies, growing objects in loops reallocating repeatedly, and reference cycles being handled fine by the collector. Tools like lobstr::obj_size() and pryr help you see what's actually using memory.
Each time you extend a vector with c() or result[i+1] beyond its length, R allocates a new, larger block and copies the old contents over. Do that N times and you've done work proportional to N squared, plus repeated garbage collection.
The fix is to pre-allocate the result to its final size, then fill it by index. Better still, vectorize the operation or use an apply function, which handles allocation for you.
# slow: reallocates every iteration
out <- c()
for (i in 1:n) out <- c(out, i^2)
# fast: pre-allocate, then fill
out <- numeric(n)
for (i in 1:n) out[i] <- i^2
# fastest: vectorize
out <- (1:n)^2Key point: This is a favorite because the wrong version looks innocent. Naming the reallocation-and-copy cost is what earns the point.
Watch a deeper explanation
Video: R Programming Tutorial - Learn the Basics of Statistical Computing (freeCodeCamp.org, YouTube)
Measure first, because intuition about bottlenecks is usually wrong. Rprof() or the profvis package shows where the time actually goes across your call stack, and microbenchmark or bench::mark() fairly compares two implementations of the same task so you optimize the line that matters.
Then fix in order of impact: vectorize hot loops, pre-allocate, use data.table for large aggregations, and only after that reach for parallelism or Rcpp to rewrite a genuine hot spot in C++.
An R optimization pass, in order
Work top to bottom and re-measure after each step. Most code never needs the last one.
data.table is built for speed and memory efficiency on large data. It modifies in place with := to avoid copies, has fast grouped operations, and reads and writes files quickly with fread and fwrite. dplyr trades some of that for readability and a gentler syntax.
The honest split: dplyr for most analysis where clarity matters, data.table when the dataset is large enough that copy-on-modify and speed become the bottleneck. Both are excellent; the choice is about scale and team familiarity.
| dplyr | data.table | |
|---|---|---|
| Priority | Readable, chainable code | Speed and low memory |
| Modify in place | No (copies) | Yes (:= operator) |
| Syntax style | Verbs + pipe | dt[i, j, by] |
| Best at | Everyday analysis | Large-data aggregation |
Rcpp lets you write C++ functions that R calls directly, with automatic conversion between R and C++ types. You write the function, compile it with sourceCpp() or inside a package, and call it like any R function.
Drop down to Rcpp when a profiled hot spot is an inherently iterative algorithm that can't be vectorized, recursion or element-by-element logic in a tight loop being the classic case. If the work can be vectorized in R, that's usually simpler and fast enough.
// Rcpp: an iterative sum that beats an R loop
#include <Rcpp.h>
using namespace Rcpp;
// [[Rcpp::export]]
double cumSumCpp(NumericVector x) {
double total = 0;
for (int i = 0; i < x.size(); i++) total += x[i];
return total;
}The parallel package (built in) offers mclapply() for forking on Unix and makeCluster() with parLapply() for a portable, socket-based approach. The furrr and future packages give a modern, apply-style interface that runs sequentially or in parallel by swapping the plan.
Parallelism helps CPU-bound, independent tasks: cross-validation folds, simulations, per-group models. Watch the overhead, since spinning up workers and copying data can cost more than it saves for quick jobs.
library(parallel)
n_cores <- detectCores() - 1
cl <- makeCluster(n_cores)
results <- parLapply(cl, 1:100, function(i) mean(rnorm(1e5)))
stopCluster(cl) # always release the workerslm() fits a linear model from a formula like y ~ x1 + x2, where the tilde separates the response on the left from the predictors on the right. Calling summary() on the fitted model reports the coefficient estimates, their standard errors, p-values, and the R-squared that measures fit.
The output is what interviewers probe: a coefficient is the change in the response per unit of that predictor holding others fixed, and R-squared is the share of variance explained. predict() applies the model to new data, and plot(model) checks assumptions.
model <- lm(mpg ~ wt + hp, data = mtcars)
summary(model)$coefficients # estimates, SEs, p-values
coef(model) # just the coefficients
predict(model, newdata = data.frame(wt = 3, hp = 150))A formula, written with ~ , is an unevaluated object that captures a modeling relationship: response on the left, predictors on the right. y ~ x + z means model y using x and z. Modeling functions interpret the symbols in operators specially: + adds terms, : is an interaction, * is main effects plus interaction, and I() protects arithmetic.
Because a formula is just an object, it isn't evaluated where you write it; the modeling function decides what the symbols mean. That indirection is a form of non-standard evaluation.
y ~ x # y explained by x
y ~ x + z # two predictors
y ~ x * z # x + z + x:z (interaction)
y ~ I(x^2) # protect arithmetic: use x squared
class(y ~ x) # "formula"A package is a directory with a DESCRIPTION file (metadata and dependencies), a NAMESPACE (what's exported), an R/ folder for code, and man/ for documentation. devtools and roxygen2 automate most of it: roxygen comments above a function generate the help file and namespace entries.
The workflow is load_all() to test in place, document() to regenerate docs, check() to run R CMD check, and test() for the testthat suite. Wrapping analysis in a package is what makes it reusable and shareable.
testthat is the standard framework. You group related checks inside test_that() blocks and assert with expectation functions like expect_equal(), expect_error(), and expect_true(). Test files live in tests/testthat/, are named test-*.R so they're found automatically, and run together with test() or during R CMD check.
Good practice mirrors any language: test behavior not implementation, cover edge cases like empty inputs and NAs, and keep tests fast so they run often. Test files are named test-*.R to be discovered automatically.
library(testthat)
test_that("add handles defaults and vectors", {
expect_equal(add(2, 3), 5)
expect_equal(add(5), 6) # default y = 1
expect_equal(add(c(1, 2), 1), c(2, 3))
})Pin package versions with renv, which records exact versions in a lockfile and restores them on another machine. Set a seed with set.seed() before any random step so results repeat. Keep the analysis in scripts or R Markdown / Quarto rather than console history.
The failure mode is an analysis that runs today and breaks in six months because a package updated. renv plus scripted, seeded code is the standard defense, and mentioning it indicates production experience.
set.seed(42) # reproducible randomness
sample(1:100, 3) # same result every run
# renv::init() # start tracking package versions
# renv::snapshot() # save the lockfile
# renv::restore() # rebuild the same environment elsewheresapply() guesses how to simplify its result: sometimes a vector, sometimes a matrix, and sometimes a list when the outputs differ in length. That inconsistency turns an edge case into a silent type change downstream, so a function that worked in testing returns a different shape in production and breaks quietly.
vapply() requires you to declare the expected output type and length with FUN.VALUE, so it fails loudly when a result doesn't match. In code others depend on, that predictability is worth the extra argument.
lengths <- vapply(
list(1:3, 1:5, 1:2),
FUN = length,
FUN.VALUE = integer(1) # each result must be one integer
)
lengths # 3 5 2, and errors early if a result is off-typeKey point: Preferring vapply for its type safety is a small answer that indicates 'this person writes code other people rely on'.
Attributes are metadata attached to any object: names, dimensions (dim), class, levels for factors, and custom tags you add. They're what turn a plain vector into something with structure; a matrix is a vector with a dim attribute, a factor is an integer vector with class and levels.
Access them with attr() or the bulk attributes(). Understanding attributes demystifies a lot of R: the difference between a list and a data frame, or a vector and a matrix, is largely which attributes are set.
v <- 1:6
attr(v, "dim") <- c(2, 3) # now it's a 2x3 matrix
class(v) # "matrix" "array"
x <- c(a = 1, b = 2)
attributes(x) # $names: "a" "b"Shiny is R's framework for interactive web apps. An app has a UI that describes inputs and outputs, and a server function that reacts to inputs and renders outputs. Reactivity is the core idea: when an input changes, only the outputs that depend on it recompute.
It lets analysts ship dashboards and tools without writing JavaScript. The mental model to explain is the reactive graph: inputs feed reactive expressions feed outputs, and Shiny tracks the dependencies for you.
library(shiny)
ui <- fluidPage(
sliderInput("n", "Points", 10, 100, 50),
plotOutput("plot")
)
server <- function(input, output) {
output$plot <- renderPlot(hist(rnorm(input$n)))
}
shinyApp(ui, server)Both weave narrative text, R code, and its output (tables, plots, results) into one document that renders to HTML, PDF, or Word. Change the data, re-render, and the whole report updates, which is the core of reproducible reporting.
Quarto is the newer, language-agnostic successor: same idea, works with R, Python, and Julia, and handles books, sites, and slides. Reaching for either instead of copying numbers into a slide deck signals reproducible-analysis habits.
browser() drops you into an interactive session at that line so you can inspect variables. debug(fn) steps through a function on its next call, and traceback() after an error shows the call stack that led to it.
options(error = recover) is the power tool: on any error it lets you jump into any frame of the call stack to see local state. In RStudio, breakpoints in the margin wrap the same machinery in a UI.
A debugging pass on failing R code
options(error = recover) short-circuits this when you want to jump straight into the failing frame.
When x is empty, length(x) is 0, and 1:0 in R is the vector c(1, 0), not an empty sequence. A loop written as for (i in 1:length(x)) then runs with i = 1 and i = 0 on empty input, causing a subtle bug.
seq_along(x) and seq_len(n) return an empty sequence when there's nothing to iterate, so the loop correctly does nothing. It's a small habit that prevents a whole class of edge-case failures.
x <- c()
1:length(x) # 1 0 <- bug: loops twice on empty input
seq_along(x) # integer(0) <- loops zero times, correct
seq_len(0) # integer(0)Key point: This is a favorite gotcha because 1:length(x) looks harmless. Knowing why it breaks on empty input is a clear production signal.
R wins when statistics and visualization are the job: modeling, exploratory analysis, and reproducible reports where a mature stats ecosystem matters more than general-purpose engineering. It trades some breadth and production tooling for depth in analysis. Python is the closer competitor and often the better pick for machine learning pipelines, web services, and glue code. SAS still holds ground in regulated industries with validated procedures, and Excel is fine for small ad hoc work but breaks down on reproducibility and scale. Saying these trade-offs out loud is itself a signal: it shows you pick tools on merit, not habit.
| Tool | Best at | Cost | Watch out for |
|---|---|---|---|
| R | Statistics, visualization, reproducible reports | Free, open source | Fewer production/deployment paths than Python |
| Python | ML pipelines, general engineering, APIs | Free, open source | Stats and plotting depth trails R in places |
| SAS | Validated procedures in regulated fields | Commercial license | Closed, expensive, smaller talent pool |
| Excel | Small ad hoc analysis, quick sharing | Bundled with Office | Poor reproducibility, breaks at scale |
Prepare in layers, and practice out loud. Most R rounds move from concept questions to live coding on a data frame to a modeling or debugging discussion, so rehearse each stage rather than only reading answers.
The typical R interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These R questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works