Learn r, with practice after every lesson
8 lessons, about 135 minutes of reading, and 24 multiple-choice questions. Each lesson names the mistake that most often costs people the interview, because that is where the hours actually go. Part of the free coding course.
Vectors, data frames and why R feels different
BasicsR was written by statisticians for statistical work, and that shows in every design decision. The basic unit is not a single value but a vector, and operations apply to the whole vector at once β add 5 to a column of a thousand numbers and you write one expression with no loop. Coming from Python this feels strange for about a day and then becomes the thing you miss when you go back.
A data frame is a table where each column is a vector and every column can hold a different type. It is the same idea as a pandas DataFrame, which is not a coincidence β pandas was built after R and borrowed the concept. If you already know one, most of your knowledge transfers and what remains is syntax rather than concepts.
The honest positioning matters when you are deciding whether to learn it. R is narrower than Python across the job market as a whole, but in pharmaceutical research, biostatistics, epidemiology, academic work and a good deal of survey and marketing analysis it is the default and Python is the outsider. If your target roles are in those areas, R is the qualification, not the nice-to-have β check the postings you actually want before choosing.
Syntax
# Everything is a vector. Operations apply element-wise, no loop needed.
scores <- c(82, 45, 91, 67)
scores * 1.1 # 90.2 49.5 100.1 73.7 β all four at once
mean(scores) # 71.25
scores[scores >= 60] # 82 91 67 β logical filtering, no if needed
# A data frame: columns are vectors, types can differ per column
staff <- data.frame(
name = c("Priya", "Ravi", "Ana"),
dept = c("data", "eng", "data"),
salary = c(1200000, 1800000, 1500000)
)
str(staff) # structure: the first thing to run on any new data
summary(staff$salary) # min, quartiles, median, mean, max in one call
staff[staff$dept == "data", ] # filter rows, keep all columns
Key points
- R is vectorised by default. If you are writing a for loop over a column, there is almost always a one-line expression that does it faster and reads better.
- str() and summary() on a new data frame answer most first questions β types, missing values, ranges β before you write any analysis.
- The $ operator selects a column by name. It is the notation you will see most in older R code and in every tutorial written before the tidyverse.
Practice challenge
You are handed sales.csv and have never seen it. Write the four lines that read it in and tell you its shape, its column types and whether it has missing values, before you touch any analysis.
sales <- read.csv("sales.csv")
dim(sales)
str(sales)
colSums(is.na(sales))
sales <- ______
______
______
______
Show a hint
- str() gives you the type of every column in one go
- Missing values are counted per column, not for the whole frame
Check yourself
1. What is the basic unit of data in R?
Show answer
B. A vector β operations apply to the whole thing at once
2. Which two functions best orient you on a new data frame?
Show answer
B. str() and summary()
3. Where is R the default rather than the alternative?
Show answer
B. Pharmaceutical research, biostatistics and academic analysis
dplyr and the tidyverse pipeline
Working levelThe tidyverse is a collection of packages that changed how most R is written, and dplyr is the part you will use daily. It provides a small set of verbs β filter, select, mutate, group_by, summarise, arrange β that compose into a readable pipeline. Almost every analysis is those six in some order, which makes unfamiliar code far easier to read than base R.
The pipe operator is what makes it read as a sequence. Written with |> or the older %>%, it passes the result of one step into the next, so you read left to right and top to bottom rather than unpicking nested function calls from the inside out. data |> filter(...) |> group_by(...) |> summarise(...) says what it does in the order it does it.
The concept behind all of it is tidy data: one row per observation, one column per variable. Most real spreadsheets are not tidy β they have months as columns, or two variables crammed into one field β and the first real step in any analysis is reshaping into that form with pivot_longer. Once data is tidy, every tidyverse function works on it without special handling, and that consistency is the actual payoff.
Syntax
library(dplyr)
library(tidyr)
# Six verbs, one pipeline, read top to bottom
applications |>
filter(applied_on >= as.Date("2026-01-01")) |>
mutate(got_interview = as.integer(stage != "rejected")) |>
group_by(source) |>
summarise(
applications = n(),
interviews = sum(got_interview),
rate = round(100 * interviews / applications, 1)
) |>
arrange(desc(rate))
# Wide -> tidy. Months as columns is the most common untidy shape.
sales_wide |>
pivot_longer(cols = Jan:Dec, names_to = "month", values_to = "revenue")
Key points
- Six verbs cover most analysis: filter (rows), select (columns), mutate (new columns), group_by, summarise, arrange.
- The pipe makes the code read in execution order. Nested calls read inside-out, which is why long base-R expressions are harder to follow.
- Tidy data means one row per observation and one column per variable. Reshaping first with pivot_longer is what lets every other function work without special cases.
Practice challenge
Using dplyr, get the average order value and the order count for each region, keeping only regions with more than 50 orders, sorted by average value highest first. Chain it in one pipeline.
sales %>%
group_by(region) %>%
summarise(avg = mean(amount, na.rm = TRUE), n = n()) %>%
filter(n > 50) %>%
arrange(desc(avg))
sales %>%
______ %>%
______ %>%
______ %>%
______
Show a hint
- The filter on order count has to come AFTER the summarise, because n() does not exist before it
- mean() returns NA if a single value is missing unless you tell it otherwise
Check yourself
1. What does the pipe operator improve?
Show answer
B. Readability β code reads in the order it executes rather than inside-out
2. What is tidy data?
Show answer
B. One row per observation, one column per variable
3. What happens if you forget to ungroup()?
Show answer
B. Later steps silently compute per group rather than overall
Statistical testing without fooling yourself
AdvancedR's real advantage is that serious statistics is built in rather than bolted on. A t-test, a regression, a confidence interval are each one function with sensible output. That accessibility is also the danger: it is trivially easy to run a test you do not understand and report a p-value that means nothing about the question you were asked.
A p-value is the probability of seeing data this extreme if there were no real effect. It is not the probability that your hypothesis is true, and it says nothing about whether the effect is large enough to matter. With a big enough sample, a difference of 0.3% becomes statistically significant while remaining commercially irrelevant β which is why reporting the confidence interval and the effect size alongside it is what separates useful analysis from ritual.
The failure that damages careers is multiple testing. Run twenty comparisons at the conventional 5% threshold and you should expect one to look significant by chance alone. If you slice a result by region, then by age, then by channel until something is significant, you have not found an effect β you have found noise and given it a name. Say how many tests you ran, and correct for it, or do not report a p-value at all.
Syntax
# Is the difference real, and is it big enough to care about?
t.test(revenue ~ variant, data = ab_test)
# t = 2.31, df = 1984, p-value = 0.021
# 95 percent confidence interval: 12.4 148.9 <- report THIS, not just p
# The interval spans 12 to 149 rupees. Significant, but is 12 worth shipping?
# Regression: the coefficient IS the effect size, in real units
model <- lm(salary ~ years_experience + city + role, data = staff)
summary(model)
# years_experience 88400 <- each extra year is worth ~88k, on average
# Twenty tests at 5% means roughly one false positive by chance.
# If you ran twenty, say so and correct:
p.adjust(p_values, method = "holm")
Key points
- Report the confidence interval and effect size, not the p-value alone. Significant and meaningful are different claims.
- A p-value is the probability of the data given no effect β not the probability that your hypothesis is true. The distinction is asked about in analyst interviews.
- Correct for multiple comparisons, or state how many you ran. Slicing until something is significant finds noise, reliably.
Practice challenge
A t.test comparing conversion between two page designs returns p = 0.03 with a mean difference of 0.2%. State what the p-value does and does not tell you, and say what you would report to the business instead of the p-value alone.
Means: if there were truly no difference, data this extreme would appear about 3% of the time
Does not mean: that the effect is large, important, or 97% likely to be real
Report: the effect size and confidence interval - a 0.2% lift may be statistically detectable and commercially worthless
p = 0.03 means: ______
It does NOT mean: ______
Report instead: ______
Show a hint
- Significance is about whether you can detect a difference, not about how big it is
- With a large enough sample almost anything becomes significant
Check yourself
1. What does a p-value actually measure?
Show answer
B. The probability of data this extreme if there were no real effect
2. Why report a confidence interval alongside it?
Show answer
B. It shows the size of the effect, which decides whether it matters
3. What is the risk of running twenty tests at 5%?
Show answer
B. About one will look significant purely by chance
Make an analysis somebody else can rerun
Job-readyThe analysis that matters is the one someone reruns in six months and gets the same answer. That is a higher bar than getting the right answer once, and most R work fails it β for the same three reasons every time: the working directory was personal, the packages have moved on, and a step was done by hand and never written down.
The fixes are unglamorous and take an afternoon. Use a project with relative paths so the code runs on someone else's machine. Pin your package versions with renv so an update to dplyr in November does not change March's numbers. And never edit the source data β read it, transform it in code, write the output somewhere else, so the transformation is always visible and always repeatable.
Then write the interpretation next to the code. R Markdown or Quarto exists so the number and the sentence explaining it cannot drift apart; a chart pasted into a slide loses the fact that it excluded returns. Include what you excluded and why β that decision is usually the one that moves the result most, and it is the first thing a reviewer will want to check.
Syntax
# analysis.Rmd β code and conclusion in one file, so they cannot drift
```{r setup}
library(dplyr); library(readr)
# renv::snapshot() pins versions: March's numbers stay March's numbers
raw <- read_csv("data/orders_2026.csv") # relative path: runs anywhere
```
```{r clean}
# Every exclusion is CODE, never a manual edit to the source file
orders <- raw |>
filter(!is_test_account) |> # excluded: 412 internal orders
filter(status != "returned") |> # excluded: 1,908 returns
mutate(month = floor_date(order_date, "month"))
```
We excluded returns (1,908 rows, 3.1%). That is the decision that moves
this result most β including them lowers Q3 revenue by about 4%.
```{r result}
orders |> group_by(month) |> summarise(rev = sum(amount), n = n())
```
Key points
- Use a project and relative paths. An absolute path to your Desktop is the most common reason an analysis will not run for anyone else.
- Pin package versions with renv. Otherwise the same script gives different numbers next year and nobody knows which was right.
- Never edit the raw file. Every exclusion belongs in code where it is visible, reviewable and reversible.
Practice challenge
A colleague cannot run your analysis, and when they eventually do the revenue figure differs from yours. Name the three most likely causes and the fix for each.
Cause 1: absolute paths pointing at your own machine. Fix: use an RStudio project and relative paths.
Cause 2: different package versions - dplyr or readr changed behaviour between your run and theirs. Fix: pin versions with renv and commit the lockfile.
Cause 3: a manual cleaning step done in Excel and never written down, so their data differs from yours. Fix: every exclusion and edit belongs in code, with the reason stated.
Cause 1: ______ Fix: ______
Cause 2: ______ Fix: ______
Cause 3: ______ Fix: ______
Show a hint
- Two of the three stop it running at all; one lets it run and give a different answer, which is worse
- The dangerous one leaves no trace in the repository
Check yourself
1. Why use relative paths in a project?
Show answer
B. The code then runs on someone else's machine
2. What does renv solve?
Show answer
B. A package update silently changing your results
3. Where should a row exclusion be recorded?
Show answer
B. In code, with the reason stated
Getting real data in, and the types that go wrong
BasicsReading data into R is where a surprising share of analysis bugs begin, because the reader guesses types from a sample and the guesses are sometimes wrong in ways that do not announce themselves. readr's read_csv prints the column specification it inferred, and reading that output is a habit worth forming: a column you expect to be numeric shown as character means something non-numeric is in it, usually 'N/A', a footnote row, or a thousands separator.
The specific traps recur across every dataset. Numbers stored with commas or currency symbols parse as text. Dates in ambiguous formats are the worst, because 03/04/2026 is either March or April depending on locale and both parse without error β so specifying the format explicitly is the only safe approach. Leading zeros in identifiers such as postcodes and phone numbers are destroyed by numeric parsing, so those columns should be read as character deliberately. And Excel files carry merged cells, footnotes and multiple header rows that arrive as data.
The corresponding habit is to look at what you actually got rather than what you expected. glimpse shows every column with its type and first values in one screen. summary reveals impossible minimums and maximums β a negative age, a date in 2087. And counting the distinct values of any column you intend to group by exposes the trailing whitespace and inconsistent capitalisation that silently split one category into three, which is the error most likely to survive into a finished report.
Syntax
library(readr); library(dplyr)
# read_csv PRINTS the column spec it guessed -- read it
sales <- read_csv("sales.csv")
# amount = col_character() <- expected numeric: something is in there
# be explicit rather than hopeful
sales <- read_csv("sales.csv", col_types = cols(
order_id = col_character(), # keep leading zeros
amount = col_double(),
postcode = col_character(), # "01234" must not become 1234
order_date = col_date(format = "%d/%m/%Y") # NEVER let it guess
))
# money stored as text
sales$amount <- as.numeric(gsub("[^0-9.-]", "", sales$amount_raw))
# LOOK at what you got
glimpse(sales)
summary(sales) # negative age? a date in 2087?
# the error that survives into finished reports
sales |> count(region, sort = TRUE)
# Mumbai 4021 | mumbai 88 | "Mumbai " 12 <- three "categories"
sales <- sales |> mutate(region = stringr::str_squish(region))
# NA handling is explicit in R, by design
mean(sales$amount) # NA if any value is NA
mean(sales$amount, na.rm = TRUE) # say so deliberately
sum(is.na(sales$amount)) # how many are missing?
Key points
- Read the column specification read_csv prints. A numeric column parsed as character is telling you something non-numeric is hiding in it.
- Always specify date formats. 03/04/2026 parses successfully as either March or April depending on locale, and no error is raised either way.
- count() the columns you will group by. Trailing spaces and inconsistent capitalisation split one category into several, and the split survives into the finished report.
Practice challenge
read_csv reports amount as col_character(), postcodes such as 01234 have become 1234, and order_date parsed as 3 April when the file means 4 March. Write the col_types specification that fixes all three and say what the character amount column is telling you.
col_types = cols(
amount = col_double(),
postcode = col_character(),
order_date = col_date(format = "%d/%m/%Y")
)
What it means: something non-numeric is present in the column - typically 'N/A', a footnote row, or a thousands separator. It parsed as text because at least one value could not be a number.
(Note: amount may need the separator stripped before col_double will work.)
col_types = cols(
______
)
What col_character on amount means: ______
Show a hint
- Leading zeros only survive as character
- Never let a date format be guessed - both readings parse without error
Check yourself
1. A column you expect to be numeric is read as character. What does that indicate?
Show answer
B. Something non-numeric is present β 'N/A', a footnote, or a separator
2. Why specify the date format explicitly?
Show answer
B. 03/04/2026 is valid as both March and April, and neither raises an error
3. Why read a postcode column as character?
Show answer
B. Numeric parsing destroys leading zeros
Reshaping, joining and grouped summaries
Working levelMost real analysis is reshaping rather than statistics. Data arrives wide, with one column per month, because that is how people build spreadsheets β and nearly every tool in R wants it long, with one row per observation and a column naming the variable. pivot_longer converts wide to long and is the step that makes grouping, plotting and modelling possible; pivot_wider goes back, which is usually only for final presentation.
Joins combine tables and the failure mode is always row count. An inner join keeps only matches and silently drops rows, which is how a total shrinks between two steps with nothing to indicate it. A left join keeps everything on the left and fills the rest with NA. The one that catches people is the many-to-many join, where duplicate keys on both sides multiply rows β join two tables with three duplicates each and one row becomes nine. dplyr warns about this now, and the warning should be treated as an error rather than noise.
Grouped summaries are where the answers come from, and there are two details worth internalising. group_by followed by summarise collapses each group to one row, and by default drops the last grouping level afterwards β which produces surprising results when summarising twice in a row, so stating .groups = 'drop' makes the intent explicit. And any summary function meeting an NA returns NA unless told otherwise, which is R being deliberately unhelpful so that missing data cannot pass unnoticed.
Syntax
library(dplyr); library(tidyr)
# WIDE -> LONG: one row per observation
wide # region | jan | feb | mar
long <- wide |> pivot_longer(jan:mar, names_to = "month", values_to = "sales")
# region | month | sales <- now groupable, plottable, modellable
# back again, usually only for presentation
long |> pivot_wider(names_from = month, values_from = sales)
# JOINS -- always check the row count
nrow(orders) # 10,000
result <- orders |> inner_join(customers, by = "customer_id")
nrow(result) # 9,400 -> 600 dropped silently
result <- orders |> left_join(customers, by = "customer_id") # keeps all
result |> filter(is.na(customer_name)) |> nrow() # 600 unmatched
# the multiplying join: 3 duplicates x 3 duplicates = 9 rows
customers |> count(customer_id) |> filter(n > 1) # check BEFORE joining
# GROUPED SUMMARIES
long |>
group_by(region, month) |>
summarise(total = sum(sales, na.rm = TRUE), # NA rule is explicit
n = n(),
.groups = "drop") |> # state it; do not inherit
arrange(desc(total))
# per-group calculation WITHOUT collapsing rows
long |> group_by(region) |> mutate(share = sales / sum(sales)) |> ungroup()
Key points
- Check the row count before and after every join. An inner join dropping unmatched rows is silent, and it is the most common cause of a total that shrinks between steps.
- Duplicate keys on both sides multiply rows. Count distinct keys before joining rather than discovering the multiplication in a total that looks merely surprising.
- Set .groups = 'drop' in summarise. Inherited grouping changes the behaviour of the next verb, and the resulting numbers are wrong in a way that reads as plausible.
Practice challenge
orders has 10,000 rows. After inner_join with customers it has 9,400; after left_join it has 12,600. Explain both numbers, write the check that should have run before joining, and write the dplyr code to count the unmatched rows.
9,400: an inner join dropped 600 orders whose customer_id has no match - silently
12,600: customers contains duplicate customer_id values, so matching orders multiplied; the left join keeps all 10,000 and duplicates some
Check before joining: customers |> count(customer_id) |> filter(n > 1)
Count unmatched: orders |> left_join(customers, by = "customer_id") |> filter(is.na(customer_name)) |> nrow()
9,400 because: ______
12,600 because: ______
Check before joining: ______
Count unmatched: ______
Show a hint
- One number fell and one rose - they are two different problems
- Always compare nrow before and after
Check yourself
1. Row count fell from 10,000 to 9,400 after a join. Why?
Show answer
B. An inner join dropped rows with no match
2. Both tables have three duplicate rows for a key. How many rows result?
Show answer
C. 9
3. Why pass .groups = 'drop' to summarise?
Show answer
B. Inherited grouping silently changes what the next verb does
ggplot2: plots that answer a question
Advancedggplot2 is built on a grammar rather than a list of chart types, and once that clicks it is faster than any menu. A plot is data, plus a mapping from variables to visual properties, plus at least one geometry that draws something. Because the pieces are independent you can change how the data is drawn without touching the mapping, add a second geometry over the same mapping, or split into small multiples by a variable β and all of it composes with a plus sign.
The distinction that trips people is between setting an aesthetic and mapping one. Inside aes() you are saying 'let this variable determine the colour', so ggplot assigns colours and draws a legend. Outside aes() you are saying 'make everything this colour'. Writing colour = 'blue' inside aes() produces the surprising result of every point being red with a legend labelled 'blue', because you have created a one-level variable β an error that makes complete sense once the grammar is clear.
For the plot to be useful rather than decorative, a few things matter more than styling. Choose the geometry from the question: a bar for comparing categories, a line for change over time, a scatter for relationship, a boxplot or histogram for distribution. Overplotting hides the data when points overlap, and alpha or jitter recovers it. Faceting beats colouring once you pass about five groups, because a legend with twelve entries is a lookup table nobody uses. And always label β a title stating the finding rather than the variable names is what makes a plot readable by someone who was not in the analysis.
Syntax
library(ggplot2)
# data + mapping + geometry, composed with +
ggplot(sales, aes(x = month, y = revenue)) +
geom_line() +
geom_point() # second geom, same mapping
# MAPPED vs SET -- the classic confusion
ggplot(sales, aes(x = month, y = revenue, colour = region)) + geom_line()
# colour INSIDE aes() -> region decides colour, legend appears
ggplot(sales, aes(x = month, y = revenue)) + geom_line(colour = "steelblue")
# colour OUTSIDE aes() -> everything is that colour, no legend
# aes(colour = "blue") makes a one-level VARIABLE named "blue"
# -> every line red, legend titled "blue". Not a bug.
# GEOMETRY FOLLOWS THE QUESTION
# compare categories -> geom_col
# change over time -> geom_line
# relationship -> geom_point
# distribution -> geom_histogram / geom_boxplot
# overplotting hides the data
ggplot(df, aes(x, y)) + geom_point(alpha = 0.2)
# more than ~5 groups: facet instead of colour
ggplot(sales, aes(month, revenue)) +
geom_line() +
facet_wrap(~ region, scales = "free_y") +
labs(title = "West revenue fell 22% after the March price change",
x = NULL, y = "Revenue") + # a FINDING, not "Revenue by month"
theme_minimal()
Key points
- Inside aes() maps a variable to a visual property; outside aes() sets a fixed value. colour = 'blue' inside aes() creates a variable named blue and draws a legend for it.
- Beyond about five groups, facet instead of colouring. A twelve-entry legend is a lookup table that readers stop using.
- Title the plot with the finding, not the axes. 'West revenue fell 22% after the March price change' is read; 'Revenue by month' is skipped.
Practice challenge
A plot uses aes(colour = "blue") and every line is red with a legend titled blue; a scatter of 50,000 points is a solid block; and twelve regions are shown as twelve coloured lines. Explain the first, and give the ggplot fix for each of the three.
Why: inside aes() you map a VARIABLE. "blue" became a one-level variable named blue, so ggplot assigned it the first default colour and drew a legend for it.
Fix 1: move it outside aes() - geom_line(colour = "blue")
Fix 2: geom_point(alpha = 0.2) or geom_jitter to reveal density
Fix 3: facet_wrap(~ region) instead of colouring - beyond about five groups a legend is a lookup table nobody uses
Why every line is red: ______
Fix 1: ______
Fix 2: ______
Fix 3: ______
Show a hint
- Inside aes maps, outside aes sets
- The third is about how many things a reader can track at once
Check yourself
1. What does aes(colour = "blue") do?
Show answer
B. Creates a one-level variable named 'blue' and draws a legend
2. You have twelve groups to compare over time. Best approach?
Show answer
B. facet_wrap into small multiples
3. Fifty thousand points overlap into a solid block. What helps?
Show answer
B. alpha or jitter to reveal density
Functions, vectorisation and the loop that took an hour
Job-readyR is vectorised, which means its operators work on whole vectors at once in compiled code. Writing a for loop to add two columns element by element asks the interpreter to do a hundred thousand round trips for something that is one instruction on the vector, and that is where the dramatic slowdowns come from. The single worst pattern is growing an object inside a loop β appending to a vector or a data frame reallocates and copies everything on each iteration, so the cost grows quadratically and a job that should take a second takes an hour.
When something genuinely must be repeated, the tools that replace loops are the apply family and purrr's map. They are not always faster than a well-written loop, but they force you to pre-allocate and they state the shape of the result, which is where their real value lies: map_dbl returns a numeric vector or fails immediately, rather than silently producing a list you discover is wrong three steps later. And when the repetition is over groups of a data frame, dplyr's group_by is both clearer and faster than any manual iteration.
Beyond speed, wrapping repeated logic in a function is what makes analysis maintainable. The rule that matters in R specifically is that a function should take everything it needs as arguments and return its result, without reaching outside itself for a variable in the global environment β because that is what makes a script work in your session and fail in a fresh one. And before optimising anything, profile it: profvis shows where the time actually goes, and the answer is regularly a line nobody suspected while the loop everyone was worried about turns out to be trivial.
Syntax
# THE WORST PATTERN IN R: growing an object in a loop
result <- c()
for (i in 1:100000) result <- c(result, i * 2) # reallocates EVERY time
# quadratic cost. Minutes.
# vectorised -- one operation on the whole vector
result <- (1:100000) * 2 # milliseconds
# if you must loop, PRE-ALLOCATE
result <- numeric(100000)
for (i in 1:100000) result[i] <- i * 2
# apply/map: states the result shape, so it fails fast
library(purrr)
map_dbl(files, ~ nrow(read_csv(.x))) # numeric vector, or an error
map(files, read_csv) |> list_rbind() # list -> one data frame
# repetition over groups: dplyr, not a loop
df |> group_by(region) |> summarise(m = mean(sales, na.rm = TRUE))
# FUNCTIONS: take arguments, return a value, touch nothing outside
summarise_region <- function(data, region_name, min_date) {
data |>
filter(region == region_name, order_date >= min_date) |>
summarise(total = sum(amount, na.rm = TRUE), n = n())
}
# NOT this -- works in your session, fails in a fresh one:
# bad <- function() { filter(sales, region == target_region) }
# PROFILE before optimising. The slow line is rarely the suspected one.
library(profvis)
profvis({ result <- expensive_analysis(df) })
Key points
- Growing a vector or data frame inside a loop reallocates and copies on every iteration. Pre-allocate, or vectorise and remove the loop entirely.
- map_dbl and friends declare the result type, so a wrong shape fails immediately instead of becoming a list that breaks three steps later.
- A function must take what it needs as arguments. Reaching into the global environment produces a script that works in your session and fails in a clean one.
Practice challenge
This loop takes minutes: result <- c(); for (i in 1:100000) result <- c(result, i * 2). Explain the cost, give two faster versions, and say what you would run before optimising anything else in the script.
Why slow: c(result, ...) reallocates and copies the whole vector on every iteration, so the cost grows quadratically
Version A: result <- (1:100000) * 2 - vectorised, one operation on the whole vector
Version B: result <- numeric(100000); for (i in 1:100000) result[i] <- i * 2 - pre-allocated, no reallocation
Run first: profvis - profile before optimising, because the slow line is regularly not the one anyone suspects
Why it is slow: ______
Version A (best): ______
Version B (if a loop is required): ______
Run first: ______
Show a hint
- The problem is not the loop itself, it is growing the object inside it
- Guessing which line is slow is usually wrong
Check yourself
1. Why is result <- c(result, i) inside a loop so slow?
Show answer
B. It reallocates and copies the whole vector each iteration β quadratic cost
2. What is the main advantage of map_dbl over a loop?
Show answer
B. It declares the result type, so a wrong shape fails immediately
3. What should you do before optimising a slow script?
Show answer
B. Profile it β the slow line is rarely the suspected one
Common questions
Do I need any background to start R?
No. This track begins at its own beginning and assumes nothing, and the first lesson explains what the thing is before showing you any syntax.
How long does the R track take?
About 135 minutes of reading across 8 lessons, plus the practice challenges and 24 multiple-choice questions, which is where the time actually goes.
Is it free?
Yes, and there is no account. Everything runs in your browser.
More: all 15 tracks · what employers actually ask for · the full syllabus