R Programming • Syntax and Core Types

Immutability vs. Reassignment — Understand immutability vs reassignment conceptually

Distinguish between changing a binding and changing the underlying data to write predictable, bug-resistant R code.

A statistical scatter plot with code braces and a variable symbol for R programming.
Inside this lesson 10 sections 5 questions 7 figures

SECTION 1

Historical Context & Motivation

The tension between immutability and reassignment is one of the oldest conceptual fault lines in programming language design. When John Backus delivered his 1977 Turing Award lecture criticizing the von Neumann bottleneck — the endless cycle of fetch-modify-store that dominates imperative code — he was articulating a dissatisfaction that would fuel the functional programming movement for decades. R, designed in the early 1990s by Ross Ihaka and Robert Gentleman at the University of Auckland, inherited ideas from both the imperative S language and the functional tradition of Scheme. This dual lineage is precisely why understanding immutability versus reassignment in R requires more than a surface-level look at syntax: it demands understanding the semantic model that governs how R manages bindings, values, and memory.

1958 • LISP Introduces Immutable Lists

John McCarthy's LISP introduces cons cells — persistent, immutable list structures. Functions return new lists rather than modifying existing ones, establishing the functional programming paradigm's core principle.

1976 • S Language at Bell Labs

John Chambers and colleagues create the S language for statistical computing. S uses copy-on-modify semantics, an early attempt to give users value semantics while retaining some efficiency behind the scenes.

1993 • R Is Born

Ross Ihaka and Robert Gentleman begin developing R, blending S's statistical syntax with Scheme's lexical scoping and functional ethos. R inherits S's copy-on-modify behavior, making most user-visible data effectively immutable unless explicitly reassigned.

2000s • Reference Classes & Environments

R introduces reference classes (R5) and formalizes environments as mutable reference objects. This gives R programmers an explicit opt-in path to mutable state, sharpening the distinction between value-semantic objects and reference-semantic objects.

2019 • ALTREP and Modern Memory Management

The ALTREP (Alternative Representations) framework in R 3.5+ optimizes how R handles large objects internally, reducing unnecessary copies. Understanding copy-on-modify versus true mutation becomes essential for writing performant R code at scale.

The central question this lesson addresses is deceptively simple: when you write x <- x + 1 in R, are you modifying the value that x points to, or are you creating an entirely new value and rebinding the name? The answer — and the conceptual machinery behind it — has profound implications for how you reason about state, side effects, and program correctness in R.

SECTION 2

Core Principles & Definitions

Before diving into R-specific behavior, we need to establish precise definitions that distinguish the name (the variable identifier), the binding (the association between a name and a value), and the value (the actual data stored in memory). These three concepts form the conceptual triangle that underpins every discussion of immutability and reassignment in any language, but they are especially critical in R because R's default semantics blur the line between value modification and binding modification in subtle ways.

1

Immutability

A value is immutable if it cannot be altered after creation. In R, most atomic vectors and lists behave as immutable values from the user's perspective: operations appear to produce new objects rather than modifying existing ones, enforced by copy-on-modify semantics.
2

Reassignment (Rebinding)

Reassignment changes which value a name points to, without necessarily changing any existing value. Writing x <- 10 followed by x <- 20 does not mutate the integer 10; it rebinds x to a new value.
3

Copy-on-Modify

Copy-on-modify is R's strategy for deferring expensive copies. Two names can share the same underlying data until one of them is 'modified,' at which point R creates a fresh copy for the modifier. The original data remains intact, preserving immutability for the other binding.
4

Mutation (In-Place Modification)

True mutation alters the data at its current memory address so that every name bound to that address observes the change. In R, only environments and reference class objects exhibit this behavior by default.
5

Value Semantics vs. Reference Semantics

R's standard objects (vectors, lists, data frames) follow value semantics: assignment conceptually copies the value so that each name is independent. Environments and R6 objects follow reference semantics: assignment shares the object, and mutations propagate.
✦

KEY TAKEAWAY

Think of a variable name as a sticky note attached to a whiteboard drawing. Reassignment peels the sticky note off one drawing and sticks it on a different drawing — neither drawing changes. Mutation takes an eraser to the existing drawing itself, so every sticky note pointing to that drawing now reflects the altered picture. R's copy-on-modify strategy is like a photocopier positioned between you and the whiteboard: if you try to erase something, R quietly makes a copy of the drawing first, gives you the copy to erase, and leaves the original untouched for anyone else still referencing it.

SECTION 3

Visual Explanation — Bindings, Values, and Copies

Reassignment vs. Copy-on-Modify in RPhase 1: Initial Bindingxc(1, 2, 3)binds toaddr: 0x7a1Phase 2: y <- x (shared binding)xyc(1, 2, 3)same 0x7a1Phase 3: x[1] <- 99 (copy-on-modify)xyc(99, 2, 3)c(1, 2, 3)new 0x8b2old 0x7a1Phase 4: x <- "hello" (reassignment)xy"hello"c(1, 2, 3)entirely new 0xc03LegendName binding (x)Name binding (y)Original valueCopy (new)Phase 3 triggers copy-on-modify: R copies the vector before changing x[1]. Phase 4 is pure reassignment — no data is copied or mutated; x simply points elsewhere.
The diagram traces four phases of R's memory behavior. In Phase 1, x binds to the vector. In Phase 2, y <- x creates a second binding to the same underlying data (no copy yet). Phase 3 demonstrates copy-on-modify: modifying x[1] forces R to allocate a new vector at a different address, leaving y's data untouched. Phase 4 shows pure reassignment: x simply repoints to a completely new value without any copy of the old value being made.

The crucial insight from this diagram is the distinction between what the name points to and what the data contains. Reassignment (Phase 4) is always a lightweight operation — it merely updates the name's pointer in the current environment. Copy-on-modify (Phase 3), on the other hand, has a performance cost proportional to the size of the data, but it preserves the invariant that no other binding observes a surprise change. This is R's approach to giving you the appearance of immutable values while deferring copies until they are actually needed.

SECTION 4

How R Manages Copy-on-Modify Internally

Internally, R tracks every object's reference count — a counter indicating how many names are currently bound to that object. When the reference count is exactly one, R knows that no other binding can observe a change, so it is safe to modify the object in place without violating value semantics. When the reference count is two or more, R must create a copy before modifying, ensuring the other bindings continue to see the original data. This mechanism is the engine behind R's copy-on-modify guarantee, and it has measurable implications for algorithmic performance.

COPY DECISION RULE

modify(x)→ifrefcount(obj(x))=1thenmutate−in−placeelsecopy−then−mutatemodify(x) → if refcount(obj(x)) = 1 then mutate-in-place else copy-then-mutate

Here obj(x) denotes the underlying SEXP (R's internal pointer) that the name x is bound to. The refcount function returns the number of names currently sharing that SEXP. R's actual implementation uses a simplified counter that saturates at 2 (prior to R 4.0, it was a simple NAMED flag with values 0, 1, or 2).

You can observe this behavior using the tracemem() function, which prints a message to the console whenever R internally copies a traced object. Consider the following session:

💡

tracemem() Demo

x <- c(1, 2, 3) tracemem(x) → prints address, e.g., <0x7fa3b1e08c48> y <- x → no copy (shared binding) x[1] <- 99 → tracemem prints: "tracemem: 0x7fa3b1e08c48 -> 0x7fa3b1f12a00" The copy event only fires when the shared object is about to be modified, confirming the copy-on-modify trigger.

PERFORMANCE IMPLICATION

T(modifysharedvectoroflengthn)=O(n)vs.T(reassignname)=O(1)T(modify shared vector of length n) = O(n) vs. T(reassign name) = O(1)

Reassigning a name is a constant-time pointer update regardless of data size. Triggering a copy-on-modify of a shared vector of length n requires allocating and copying n elements. This distinction matters enormously in tight loops operating on large datasets.

Understanding this mechanism clarifies a common misconception: R is not 'slow because it copies everything.' It only copies when it must — specifically, when a shared object would otherwise be mutated in a way that violates value semantics. Efficient R code minimizes unnecessary sharing before modification, allowing the runtime to take the in-place mutation fast path.

SECTION 5

Value Semantics vs. Reference Semantics in R

Not all R objects follow the same rules. The language offers two distinct semantic regimes, and knowing which regime an object belongs to is essential for predicting whether your code will trigger copies or produce visible side effects. Standard R objects — atomic vectors, lists, data frames, matrices, and factors — obey value semantics. A smaller but important set of objects — environments, R6 objects, reference class (R5) objects, and external pointers — obey reference semantics and support genuine in-place mutation.

R Object Semantics TaxonomyR ObjectsValue Semantics (Copy-on-Modify)Reference Semantics (Mutable)vectorlistdata.framematrixfactorenvironmentR6 classref class (R5)externalptrBehavior• Assignment creates independent copy*• Modification never affects other bindings*deferred via copy-on-modifyBehavior• Assignment shares the same object• Mutation is visible through all bindingsTrue in-place modification
R's type system divides objects into two semantic families. Objects on the left (amber) follow value semantics: assignment conceptually creates an independent copy (though the actual copy is deferred). Objects on the right (red) follow reference semantics: assignment shares the underlying object, and mutations propagate to all bindings.
FeatureValue SemanticsReference Semantics
Assignment behaviorConceptual copy (deferred)Shared identity
Side effects of modificationNone — only the modifier's binding is affectedAll bindings observe the change
Equality checkidentical() checks structural equalityidentical() checks identity (same object)
Common typesvector, list, data.frame, matrixenvironment, R6, R5 ref class
tracemem() behaviorReports copies on shared modificationNo copies reported — mutation is direct

Comparison of R's two semantic families

SECTION 6

Worked Example — Tracing Copies and Bindings

Let's work through a concrete R session, predicting at each step whether a copy will occur, whether mutation happens in place, or whether we are merely rebinding a name. This exercise integrates every concept from the preceding sections and demonstrates how to use tracemem() and lobstr::obj_addr() to verify our reasoning.

Predicting Copy-on-Modify vs. In-Place Modification

  1. 1

    Step 1 — Create and trace a vector

    We create a numeric vector and begin tracing it: a <- c(10, 20, 30) tracemem(a) At this point, a is the sole binding to the vector. The reference count of the underlying SEXP is 1.
    refcount = 1, address = 0x7f1
  2. 2

    Step 2 — Create a shared binding

    b <- a No copy occurs. Both a and b now point to the same SEXP at 0x7f1. The reference count increments to 2. We can verify with lobstr::obj_addr(a) == lobstr::obj_addr(b) which returns TRUE.
    refcount = 2, both point to 0x7f1, no copy
  3. 3

    Step 3 — Modify a shared vector (triggers copy)

    a[2] <- 99 Because a's SEXP has refcount = 2, R cannot mutate in place — doing so would change b's visible value, violating value semantics. R allocates a new vector at 0x8a2, copies c(10, 20, 30) into it, then sets position 2 to 99, yielding c(10, 99, 30). The tracemem output confirms the copy.
    Copy triggered! a → 0x8a2, b → 0x7f1 (unchanged)
  4. 4

    Step 4 — Modify with single binding (in-place)

    Now a is the only name pointing to 0x8a2 (refcount = 1). We modify again: a[3] <- 0 No copy message from tracemem. R modifies 0x8a2 in place. The address remains the same, and the operation completes in O(1).
    No copy — in-place mutation. a = c(10, 99, 0) at 0x8a2
  5. 5

    Step 5 — Pure reassignment

    a <- "completely new" This is pure reassignment. The name a is unbound from 0x8a2 and rebound to a new character SEXP at a different address. The old numeric vector at 0x8a2 now has refcount 0 and becomes eligible for garbage collection. No data was copied — we merely updated a pointer.
    Rebinding only — O(1), no copy. b still holds c(10, 20, 30) at 0x7f1

SECTION 7

Trade-offs — Immutability, Reassignment, and Mutation

Choosing between immutable values, reassignment, and explicit mutation is not merely a matter of style in R — it has consequences for correctness, readability, and performance. Each approach occupies a different point in a multi-dimensional trade-off space that every R programmer should understand before making design decisions, particularly in the context of packages, Shiny applications, or parallel computing.

CriterionValue Semantics (default R)Explicit Mutation (environments / R6)
CorrectnessEasier to reason about — no aliasing bugs. Functions are side-effect free by default.Risk of aliasing bugs: one caller's modification is visible to all others holding a reference.
MemoryPotentially high: large object copies can spike peak memory usage.Low: no copies needed. Single canonical copy of the data.
SpeedO(n) per copy event; fast when refcount = 1 (in-place path).O(1) for any modification — always in-place.
Concurrency safetyInherently safe: each worker has its own copy. No race conditions.Requires explicit locking or careful design to avoid data races.
DebuggingStraightforward: state at any point depends only on local bindings.Harder: must trace all references to understand state changes.
Use casesData analysis scripts, pure functions, functional pipelines (dplyr, tidyr).Stateful applications (Shiny reactive values), caches, large shared datasets.

Trade-offs between value semantics and reference semantics in R

✦

KEY TAKEAWAY

In software engineering, choosing between immutability and mutation is analogous to choosing between a version-controlled document and a shared Google Doc. With version control (value semantics), each collaborator works on their own snapshot — merges are explicit and safe, but storage grows. With a shared doc (reference semantics), everyone sees changes instantly — efficient but prone to conflicts. R defaults to version control for safety, offering shared-doc mode only when you explicitly opt into environments or R6.

SECTION 8

Connection to Advanced Theory — Persistent Data Structures and Beyond

R's copy-on-modify mechanism is a specific instance of a broader concept in computer science known as persistent data structures. Languages like Clojure and Haskell build their entire standard libraries around persistent collections that preserve previous versions of themselves after modifications. While R's implementation is less sophisticated — it performs full shallow copies rather than structural sharing — the conceptual foundation is the same: treat data as immutable values and create new versions when changes are needed.

ConceptR ImplementationAdvanced / Other Languages
Immutable valuesCopy-on-modify for vectors, lists, data framesPersistent hash array mapped tries (Clojure); purely functional data structures (Haskell)
Controlled mutationEnvironments, R6 classes, external pointersSTRef / IORef (Haskell); atoms and refs (Clojure); Cell / RefCell (Rust)
Copy avoidanceReference counting + ALTREP (R ≥ 3.5)Structural sharing (Clojure); borrow checker (Rust); linear types (Haskell)
Freeze / locklockEnvironment(), lockBinding()Object.freeze() (JavaScript); const (Rust, C++)

R's immutability mechanisms in the broader PL landscape

As you advance into package development, Rcpp integration, or high-performance computing with R, you will encounter situations where understanding these trade-offs becomes critical. The data.table package, for instance, deliberately subverts R's copy-on-modify defaults by modifying data frames in place using C-level reference semantics — a choice that yields dramatic speed gains at the cost of surprising behavior for users who expect standard R value semantics. Similarly, the vctrs package and the tidyverse design philosophy lean heavily into value semantics and functional purity, trading raw speed for predictability and composability. Recognizing these design philosophies as deliberate positions on the immutability-mutation spectrum will make you a more effective and intentional R programmer.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain in your own words the difference between reassigning a variable (rebinding) and mutating the value it points to. Use the R statement x <- x + 1 as your example. Does this statement mutate the value that x was originally bound to, or does it create a new value and rebind x?
Show answer
The statement x <- x + 1 performs reassignment (rebinding), not mutation. R evaluates the right-hand side x + 1, which produces a brand-new numeric value, and then binds the name x to that new value. The original value that x pointed to is not altered in memory — if any other name was also bound to it, that name would still see the original value. This is the fundamental distinction: rebinding changes what the name points to, while mutation would change the data at the current memory address.

Question 2: BASIC CALCULATION

Consider the following R code: a <- c(1, 2, 3) b <- a a[1] <- 99 After execution, what are the values of a and b? How many times does R copy the vector, and at which line does the copy occur?
Show answer
After execution, a is c(99, 2, 3) and b is c(1, 2, 3). R copies the vector exactly once, triggered by line 3 (a[1] <- 99). At line 2, b <- a merely creates a second binding to the same SEXP (no copy). When line 3 attempts to modify a shared object (refcount = 2), copy-on-modify fires: R allocates a fresh vector, copies the data, applies the modification to the copy, and rebinds a to the new vector. The original vector that b references remains untouched.

Question 3: INTERMEDIATE

Now consider code using an environment: e <- new.env(parent = emptyenv()) e$val <- c(1, 2, 3) f <- e f$val[1] <- 99 What is e$val after this code runs? Explain why the behavior differs from the vector example in Problem 2.
Show answer
After execution, e$val is c(99, 2, 3). This is because environments in R follow reference semantics, not value semantics. The assignment f <- e does not create a copy of the environment — f and e are now two names for the exact same environment object. Modifying f$val directly affects the shared environment, so e$val observes the change. There is no copy-on-modify protection for environments; this is the defining characteristic of reference semantics in R.

Question 4: APPLIED

You are writing a function process_data() that receives a data frame with 10 million rows. Inside the function, you need to update one column. A colleague suggests using data.table::set() for in-place modification instead of standard df$col <- new_values. Under what conditions would the standard approach already avoid a copy? Under what conditions would data.table::set() be necessary for acceptable performance?
Show answer
The standard approach df$col <- new_values would avoid a full copy if the data frame's reference count is 1, meaning no other name is bound to the same SEXP — R would take the in-place modification path. This commonly occurs when the data frame is created inside the function or when R's optimizer can prove the binding is unique. However, if the data frame was passed as a function argument, R's promise evaluation often bumps the reference count to 2 (one for the caller's binding, one for the function's local binding), triggering a full copy of the modified column's parent structure. In these shared-binding scenarios, data.table::set() bypasses R's copy-on-modify entirely by operating at the C level, making the modification O(n) only for the single column rather than O(n) for a full data frame shallow copy. For 10 million rows, this difference can be the gap between milliseconds and seconds.

Question 5: CRITICAL THINKING

R's copy-on-modify gives users the illusion of immutable values while internally deferring copies for efficiency. Haskell, by contrast, enforces true immutability with persistent data structures using structural sharing. Rust takes a third approach: move semantics with a borrow checker that statically guarantees at most one mutable reference at compile time. Compare and contrast these three strategies along the dimensions of (a) runtime overhead, (b) programmer reasoning burden, and (c) safety guarantees. Under what circumstances might R's approach be preferable to the other two for a statistical computing context?
Show answer
R's copy-on-modify has O(n) worst-case runtime overhead when copies are triggered, but O(1) best-case when the reference count is 1; the programmer's reasoning burden is low because the language silently handles sharing, but unexpected copies can degrade performance in ways that are hard to diagnose without tools like tracemem(). Haskell's persistent data structures achieve O(log n) overhead per 'modification' through structural sharing and have zero mutation bugs, but the programmer must learn to think functionally and manage lazy evaluation, which can cause space leaks. Rust's borrow checker imposes zero runtime overhead — all invariants are enforced at compile time — but the programmer bears a significant upfront burden learning ownership rules and satisfying the borrow checker. For statistical computing, R's approach is often preferable because interactive data analysis involves many short-lived scripts where programmer velocity matters more than optimal performance; the copy-on-modify illusion lets analysts write code that reads like mathematical notation (value-oriented) without needing to understand ownership systems or persistent data structure internals. When performance does matter for production R code, the escape hatches (environments, data.table, Rcpp) are available.

SUMMARY

Summary — Immutability vs. Reassignment in R

Summary

This lesson distinguished three closely related but fundamentally different operations: reassignment (rebinding a name to a new value), copy-on-modify (R's mechanism for preserving value semantics by deferring copies until a shared object is modified), and true mutation (in-place modification of reference-semantic objects like environments and R6 instances). R's standard objects — vectors, lists, data frames, matrices, and factors — follow value semantics, meaning that assignment creates a conceptually independent copy (deferred via copy-on-modify), and modifications to one binding never surprise another. Environments and R6 objects follow reference semantics, where assignment shares identity and mutations propagate to all bindings.

Internally, R uses reference counting to decide whether modification can proceed in place (refcount = 1) or requires a copy (refcount ≥ 2). This optimization means that well-structured R code — where bindings are not unnecessarily shared before modification — can approach the performance of explicit mutation without sacrificing the safety and referential transparency that make functional-style R code easier to reason about, debug, and parallelize. Tools like tracemem() and lobstr::obj_addr() allow you to observe these mechanics directly, turning abstract concepts into observable runtime behavior.

Varsity Tutors • R Programming • Immutability vs. Reassignment