SECTION 1
Historical Context & Motivation
R was conceived in the early 1990s as a language for interactive statistical computing, inheriting its semantics from S, a language designed at Bell Labs in the 1970s. S prioritized expressive, vectorized operations over raw loop performance, and R inherited this philosophy wholesale. In S and early R, users typically worked with small datasets—hundreds or thousands of rows—where the cost of memory management in interpreted loops was negligible. As data volumes grew exponentially through the 2000s and 2010s, a class of performance anti-patterns that had been harmless at small scale became crippling bottlenecks, and the most notorious among them was the practice of growing vectors inside loops.
The fundamental issue traces back to how R manages memory. Unlike C++ STL containers such as std::vector, which use amortized doubling strategies, R's basic vector type historically performed a full copy-on-modify when its length changed. Each call to c(vec, new_element) or vec <- append(vec, new_element) allocated a fresh block of memory, copied every existing element, and then added the new one. The community eventually codified this as one of the cardinal sins of R programming, but the path to widespread awareness was gradual.
1976 • S Language at Bell Labs
1993 • R Created by Ihaka & Gentleman
2000 • R 1.0 & CRAN Growth
2008–2012 • Big Data Awareness
2016–Present • Modern Idioms & Tooling
Understanding this pitfall is not merely about R trivia—it crystallizes a broader principle in algorithms and complexity: the difference between O(n) and O(n²) can mean the difference between code that finishes in seconds and code that takes hours. The central question this lesson addresses is: why does a seemingly innocent one-line append inside a loop transform a linear process into a quadratic one, and what idiomatic alternatives does R provide?
SECTION 2
Core Principles & Definitions
Before dissecting the mechanics of the pitfall, it is essential to establish several foundational concepts that govern how R manages data in memory and how these management decisions interact with algorithmic complexity.
Copy-on-Modify Semantics
Contiguous Memory Layout
Amortized Complexity
std::vector or Python's list use geometric over-allocation (typically doubling capacity), yielding amortized O(1) appends. R does not employ this strategy for its atomic vectors.Pre-allocation
vector("numeric", n) or numeric(n) to create a vector of the required size before the loop, then assign into indexed positions. This reduces total allocation to O(n).Vectorization
sapply, vapply, or direct arithmetic). Vectorized code delegates iteration to compiled C internals, avoiding both the loop overhead and the allocation pitfall.KEY TAKEAWAY
SECTION 3
Visual Explanation — Memory Allocation Over Iterations
The following diagram illustrates what happens in memory when a vector is grown element-by-element inside a loop. Each iteration triggers a new allocation and a full copy of all previously stored elements, resulting in a triangular pattern of total work that characterizes O(n²) behavior.
Notice the critical structural feature of this diagram: the width of the pink (copied) region increases by one unit with each row, producing a triangular shape. This triangle is the geometric manifestation of the arithmetic series 1 + 2 + … + (n − 1), which sums to n(n − 1)/2. In contrast, a pre-allocated approach touches each position exactly once, performing exactly n writes with zero copies—a flat O(n) profile.
SECTION 4
Mathematical Framework — Quantifying the Cost
Let us formalize the computational cost of the grow-in-loop pattern versus the pre-allocated pattern. Suppose we wish to construct a numeric vector of length n where each element is computed by some function f(i). We compare two approaches: naïve appending and pre-allocation.
Naïve Append Cost
TOTAL ELEMENT COPIES (APPEND)
At iteration i, the existing vector of length i − 1 must be copied into a new allocation of size i. Summing over all iterations gives a Θ(n²) total cost in element-copy operations.
MEMORY ALLOCATIONS (APPEND)
Each iteration triggers exactly one call to the memory allocator. The i-th allocation requests a contiguous block for i doubles (8 bytes each for numeric). Total memory allocated across all calls: 8 × Σ i = 4n(n + 1) bytes.
Pre-allocation Cost
TOTAL ELEMENT WRITES (PRE-ALLOCATED)
A single allocation of size n is made before the loop. Each iteration writes to a pre-existing index—no copies, no reallocation. The total work is Θ(n).
SPEEDUP RATIO
For n = 100,000, the append approach performs roughly 50,000× more element-level work than the pre-allocated version. In practice, the constant factors (allocator overhead, cache effects, garbage collection) make the real-world slowdown even worse.
Why Doesn't R Use Geometric Over-Allocation?
c() function semantically creates a new vector—it has no concept of 'unused capacity'. This is a deliberate language design trade-off favoring functional purity over mutability performance.SECTION 5
Detailed Breakdown — Anti-Pattern vs. Idiomatic Patterns
The grow-in-loop anti-pattern is the most common performance pitfall in R, but it is part of a family of related mistakes. This section catalogs the anti-pattern alongside three idiomatic alternatives, comparing them in terms of time complexity, memory behavior, and readability.
| Pattern | R Code Sketch | Time Complexity | Memory Allocations |
|---|---|---|---|
| Grow in loop (anti-pattern) | v <- c(); for(i in 1:n) v <- c(v, f(i)) | O(n²) | n allocations, each larger |
| Pre-allocate + index | v <- numeric(n); for(i in 1:n) v[i] <- f(i) | O(n) | 1 allocation |
| vapply / sapply | v <- vapply(1:n, f, numeric(1)) | O(n) | 1 allocation (internal) |
| Vectorized operation | v <- f(1:n) # if f is vectorized | O(n) | 1 allocation, C-level loop |
Comparison of vector construction patterns in R
A subtler variant of the anti-pattern uses append() or vec[length(vec) + 1] <- val. The latter may sometimes avoid a full copy if R detects a single reference (an optimization in recent R versions), but this behavior is not guaranteed and should not be relied upon for correctness in performance-critical code. Another related pitfall is growing data frames row-by-row using rbind() in a loop, which is even more expensive because each column of the data frame is a separate vector that must be individually copied and rebound.
SECTION 6
Worked Example — Benchmarking Append vs. Pre-Allocate
Consider the task of computing the first n cumulative sums of random standard normal draws. We compare the naïve growing approach with the pre-allocated approach and analyze the timing results.
Cumulative Sum of Random Normals (n = 50,000)
- 1
Step 1 — Write the Anti-Pattern Code
The naïve approach initializes an empty vector and appends each cumulative sum:slow_cumsum <- function(n) { result <- c() running <- 0 for (i in 1:n) { running <- running + rnorm(1) result <- c(result, running) } result }At iteration i, R allocates a new vector of length i and copies all i − 1 existing elements. - 2
Step 2 — Write the Pre-Allocated Code
The idiomatic approach pre-allocates the result vector:fast_cumsum <- function(n) { result <- numeric(n) running <- 0 for (i in 1:n) { running <- running + rnorm(1) result[i] <- running } result }The single call tonumeric(n)allocates all memory up front. Each loop iteration writes to positionresult[i]without reallocation. - 3
Step 3 — Predict the Theoretical Cost Ratio
For n = 50,000, the append approach performs approximately n(n − 1)/2 = 50,000 × 49,999 / 2 ≈ 1.25 × 10⁹ element copies. The pre-allocated approach performs exactly 50,000 writes.Predicted speedup ≈ 25,000× fewer operations. - 4
Step 4 — Benchmark with system.time()
Runningsystem.time(slow_cumsum(50000))on a typical modern machine yields approximately 8–12 seconds of elapsed time, whilesystem.time(fast_cumsum(50000))completes in approximately 0.02–0.05 seconds. The observed speedup is on the order of 200–500× in wall-clock time, lower than the theoretical element-copy ratio because the pre-allocated version still incurs per-iteration interpreter overhead.Empirical speedup: ~200–500× - 5
Step 5 — Apply the Vectorized Alternative
The most idiomatic R approach avoids the explicit loop entirely:vectorized_cumsum <- function(n) cumsum(rnorm(n))This runs in approximately 0.001–0.005 seconds becausecumsum()is implemented in C and processes the entire vector in a single pass.Vectorized: ~2,000–10,000× faster than the anti-pattern
SECTION 7
Strengths, Limitations, and Trade-offs of Each Approach
While the advice to 'never grow a vector in a loop' is sound as a general rule, each alternative comes with its own set of trade-offs. Understanding these nuances is critical for making informed decisions in real-world codebases where the problem structure may not always permit clean vectorization.
| Approach | Strengths | Limitations |
|---|---|---|
| Grow in loop | Simple to write; no need to know n in advance; mirrors pseudocode directly | O(n²) time; O(n²) total allocated memory (most immediately freed); triggers frequent garbage collection |
| Pre-allocate + index | O(n) time; O(n) memory; works when loop body has side effects or complex dependencies | Requires knowing n in advance (or a reasonable upper bound); slightly more boilerplate; still incurs R interpreter overhead per iteration |
| vapply / sapply | O(n) time; pre-allocates internally; type-safe with vapply; functional style | Cannot express iterative dependencies (result[i] depending on result[i−1]) unless using Reduce(); sapply can return unexpected types |
| Full vectorization | Fastest; delegates to compiled C; most idiomatic R; concise | Not always possible—requires that the function be expressible as a whole-vector operation; may use more peak memory |
| List + do.call(c, ...) | Lists don't copy on append (linked-list semantics); useful when n is unknown; single final concatenation | Higher per-element overhead (each element is a SEXP); final concatenation is O(n); less common idiom |
Trade-off analysis of vector construction patterns in R
KEY TAKEAWAY
SECTION 8
Connection to Advanced Theory — Amortized Analysis and ALTREP
The grow-in-loop pitfall in R is fundamentally a consequence of the language lacking amortized constant-time append semantics for atomic vectors. This concept is well-studied in the algorithms literature and is worth examining more formally, as it illuminates why some languages escape this trap and what R's future might hold.
| Concept | This Lesson's Scope | Advanced Extension |
|---|---|---|
| Cost Model | Worst-case per-operation: each append costs O(i) copies at iteration i | Amortized analysis via the potential method or aggregate method shows that geometric doubling yields O(1) amortized cost per append |
| Memory Model | R's copy-on-modify allocates a new contiguous block for every modification | ALTREP (Alternative Representations, R ≥ 3.5) allows custom compact or deferred-evaluation vector backends, potentially enabling smarter growth strategies |
| Data Structures | Flat atomic vectors and lists; environments as mutable containers | Persistent data structures (e.g., finger trees) offer O(1) amortized append with immutability, used in Clojure and Haskell; R's environments can simulate mutable arrays but sacrifice functional guarantees |
| Profiling | system.time() and microbenchmark for wall-clock measurement | Rprof(), profvis, and tracemem() for fine-grained memory allocation tracking and call-stack profiling to identify hidden O(n²) patterns in complex codebases |
From basic pitfall recognition to advanced algorithmic and systems concepts
An important forward-looking topic is R's ALTREP framework, introduced in R 3.5.0 (2018). ALTREP allows package authors to define alternative internal representations for vectors—for instance, a compact sequence 1:n can be stored as just two integers (start and length) rather than materializing all n elements. While ALTREP does not currently solve the grow-in-loop problem for general vectors, it opens the door to future implementations that could maintain surplus capacity internally, much like std::vector in C++. In the meantime, the three idiomatic patterns—pre-allocation, apply-family functions, and vectorization—remain the practitioner's essential toolkit.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
v <- c(v, x) inside a loop of n iterations results in O(n²) total work rather than O(n). What specific memory operation is responsible for the quadratic scaling?Show answer
c(v, x) allocates a new contiguous memory block of size i (where i is the current iteration) and copies all i − 1 existing elements into it before writing the new element. The total number of element copies across all iterations sums to 0 + 1 + 2 + … + (n − 1) = n(n − 1)/2, which is Θ(n²). The quadratic scaling arises specifically from the copy-on-modify behavior: R treats vectors as immutable values, so concatenation always produces a new vector rather than appending in place.Question 2: BASIC CALCULATION
Show answer
Question 3: INTERMEDIATE
results <- data.frame()
for (i in 1:10000) {
row <- data.frame(x = rnorm(1), y = runif(1))
results <- rbind(results, row)
}
Identify the performance pitfall, explain why it is even worse than growing a single vector, and rewrite the code using an efficient idiomatic pattern.Show answer
x_vals <- numeric(10000)
y_vals <- numeric(10000)
for (i in 1:10000) {
x_vals[i] <- rnorm(1)
y_vals[i] <- runif(1)
}
results <- data.frame(x = x_vals, y = y_vals)
Or, even more idiomatically: results <- data.frame(x = rnorm(10000), y = runif(10000)). Both alternatives run in O(n) time.Question 4: APPLIED
Show answer
result <- unlist(matches_list) for O(n) concatenation. This handles unknown n naturally but has higher per-element overhead than atomic vectors. Strategy 2: Pre-allocate with a generous upper bound (e.g., the total line count), fill by index, then truncate with result <- result[1:actual_count]. This is faster per element because atomic vector indexing is cheaper than list assignment, but it wastes memory proportional to the over-allocation. A hybrid approach—doubling a pre-allocated buffer manually—mimics C++ std::vector behavior and gives amortized O(1) appends with minimal waste, though it requires more code.Question 5: CRITICAL THINKING
Show answer
SUMMARY
Lesson Summary
Summary
The most pervasive performance pitfall in R is growing vectors inside loops using patterns like v <- c(v, x). Because R employs copy-on-modify semantics and stores vectors as contiguous memory blocks, each append triggers a complete reallocation and copy. Over n iterations, the cumulative cost is O(n²)—a quadratic blowup that makes even moderately sized loops (n ≈ 10,000–100,000) painfully slow.
The remedy comes in three forms: pre-allocation (create a vector of the correct size before the loop and assign by index), apply-family functions like vapply() (which pre-allocate internally), and full vectorization (delegating iteration entirely to compiled C code). Each successive approach eliminates more overhead, reducing total work from O(n²) to O(n). Recognizing and avoiding this anti-pattern is one of the most impactful skills an R programmer can develop—it is often the difference between code that runs in milliseconds and code that runs in hours.