R's Copy-on-Modify in Practice: Reference Counting, ALTREP, and Avoiding Accidental Copies
R 3.5+ uses reference counting (REFCNT) and ALTREP to enable in-place mutation, but accidental copies still plague data-intensive workflows. Learn to detect copies with tracemem, exploit data.table and vctrs reference semantics, and avoid forced materialization of compact vectors.
16 Feb 2026, 06:44 UTC

The Problem: Silent Memory Bloat in Data-Intensive R Code
You’re processing a 10-million-row dataset. A simple df$new_col <- df$x * 2 runs, but memory usage doubles. A loop that updates list elements one by one slows to a crawl because each iteration copies the entire list. These symptoms stem from R’s copy-on-modify semantics — but since R 3.5 the rules have changed in ways that most users never learn.
This post explains how reference counting and ALTREP work together, how to detect when a copy actually happens, and practical patterns to keep mutations in-place.
Reference Counting Since R 3.5: The NAMED → REFCNT Shift
Before R 3.5, the interpreter used a NAMED counter (0, 1, 2+) to guess whether an object had multiple bindings. If NAMED > 1, any modification triggered a full duplicate. That heuristic was conservative and often copied unnecessarily.
Since R 3.5.0, a true reference count (REFCNT) tracks the exact number of bindings pointing to an object. When REFCNT == 1, the interpreter knows it owns the sole reference and can mutate in place. Operations like x[[i]] <- v, data.table::set(), and vctrs::vec_assign() now run in O(1) time instead of allocating a new vector.
Version assumption: All behavior described requires R ≥ 3.5.0. Verify with R --version in your terminal.
ALTREP: Compact Representations Until Forced
ALTREP (Alternative Representation) lets package authors define vector types that store metadata instead of full data. The classic example is 1:1e9 — it stores only start, step, and length, consuming a few dozen bytes. compactString (used by readr and vctrs) shares a global CHARSXP pool.
ALTREP vectors behave identically to materialized vectors at the R level. The catch: any C-level code that doesn’t use the ALTREP macros (R_ALTREP_DATA1, R_ALTREP_DATA2, Rf_xlength_get_true_length()) forces immediate materialization. Printing, as.vector(), or passing to a legacy .Call expands the full allocation instantly.
Detecting Copies: tracemem() and lobstr::ref()
Two reproducible tools let you observe copy events:
tracemem(x)/untracemem(x)— prints a message to the console each time the C-level objectxis duplicated.lobstr::ref(x)— shows the memory address and reference count ofx.
Worked example — run in an R console (no special permissions needed):
# Install if missing
install.packages(c("lobstr", "bench", "data.table", "dplyr", "tibble"))
# 1. ALTREP vs materialized integer vector
x_altrep <- 1:1e6
x_mat <- as.integer(1:1e6)
lobstr::ref(x_altrep) # single reference, ALTREP flag
lobstr::ref(x_mat) # standard integer vector
# 2. In-place mutation detection
y <- 1:1e7
tracemem(y)
y[1] <- 0L # no message → in-place mutation worked
untracemem(y)
# 3. Forced copy by creating a second binding
z <- 1:1e7
tracemem(z)
w <- z # second binding
z[1] <- 0L # tracemem prints: tracemem[0x... -> 0x...]
untracemem(z)
Expected check: The first tracemem block should produce no output (in-place). The second prints a tracemem line showing the duplicate address. If you see a message in the first block, your R build may have a different threshold — treat it as a signal to profile further.
Practical Patterns That Avoid Quadratic Allocation
1. Pre-allocate and fill by reference
Instead of growing a list in a loop:
# Slow: copies whole list each iteration
res <- list()
for (i in 1:1e5) res[[i]] <- compute(i)
# Fast: pre-allocate, then assign by reference
res <- vector("list", 1e5)
for (i in 1:1e5) res[[i]] <- compute(i) # still copies list? see below
Even pre-allocation copies the list if you use res[[i]] <- because the list itself has REFCNT > 1 after the first assignment. Use data.table::set(res, i, value = compute(i)) or vctrs::vec_assign(res, i, compute(i)) to mutate the list in place.
2. data.table’s reference semantics for joins and updates
data.table exploits REFCNT == 1 for its hash table during joins and for := updates. A benchmark on 10M rows:
bench::mark(
dt = {DT <- data.table::data.table(x = 1:1e7); DT[, y := x * 2]},
dp = {df <- tibble::tibble(x = 1:1e7); dplyr::mutate(df, y = x * 2)},
iterations = 3
)
Typical result: data.table runs 3–5× faster and allocates far less memory because it never materializes a copy of the 10M-row column for the := assignment. dplyr’s safety-first approach often copies.
3. vctrs for type-stable, reference-aware operations
The vctrs package builds on REFCNT and ALTREP. vec_slice() and vec_assign() are reference-aware and used internally by dplyr/tidyr to keep mutations in-place where possible. If you write low-level data manipulation, prefer vctrs primitives over base subsetting.
Trade-offs and Limitations
- Reference counting is an implementation detail, not a language guarantee. Future R versions may change heuristics (e.g., lazy copying thresholds) without notice. Do not build business logic on the assumption that a specific operation will never copy.
- ALTREP savings vanish on materialization. Printing a large ALTREP vector, calling
as.vector(), or passing it to a non-ALTREP-aware C extension expands the full allocation instantly. Check withis.altrep(x)(R) orRf_isAltrep()(C). - Explicit copy is still required for logical independence.
data.table::copy()or baseduplicate()must be used when you need a truly separate object. Relying on implicit copy-on-write in recursive functions can cause subtle bugs. tracememshows C-level duplicates, not logical copies. A singletracememevent may correspond to multiple R-level assignments if the optimizer coalesces them.
Actionable Checklist for Your Next Data-Intensive Project
- Confirm R ≥ 3.5.0:
R --versionin terminal. - Profile memory allocations:
Rprofmem('mem.log'); <your code>; Rprofmem(NULL); readLines('mem.log')— look for "duplicate" events. - Verify in-place mutation: Wrap critical sections with
tracemem()/untracemem(). - Audit C/C++ extensions:
grep -r "R_ALTREP_DATA1\|Rf_xlength_get_true_length" src/in any package you compile. Absence suggests forced materialization. - Benchmark alternatives: Use
bench::mark()to comparedata.tablevsdplyrvs base for your actual workload.
Understanding these internals turns mysterious memory spikes into predictable, controllable behavior. The tools are in base R and a few well-maintained packages — no magic required.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.