Passing By Value Vs Passing By Reference

12 min read

Understanding how data moves between functions is a foundational concept that separates novice coders from experienced engineers. The distinction between passing by value and passing by reference dictates how memory is managed, how variables behave inside functions, and ultimately, whether your program runs predictably or produces subtle, hard-to-find bugs. Because of that, while the syntax differs across languages like C++, Java, Python, JavaScript, and Go, the underlying mechanics remain consistent. Mastering this concept allows you to write more efficient, secure, and maintainable code The details matter here..

The Core Difference: Copying Data vs. Sharing Addresses

At the highest level, the difference comes down to what actually gets placed on the stack when a function is called.

Passing by value creates a distinct copy of the argument’s data. The function operates on this clone, leaving the original variable in the calling scope completely untouched. Think of it like photocopying a document: you can scribble all over the copy, but the original stays pristine.

Passing by reference (or call by reference) passes the memory address of the argument. The function parameter becomes an alias for the original variable. Any modification inside the function directly affects the caller’s variable. This is akin to handing someone the original document; any edits they make are permanent.

Deep Dive: Passing by Value

When a language uses pass-by-value semantics, the evaluation strategy is straightforward. The argument expression is evaluated, and the resulting value is copied into a new memory slot reserved for the function’s parameter Not complicated — just consistent..

How It Works in Memory

Imagine a simple integer variable x holding the value 10. When you call modify(x), the runtime allocates a new stack frame. Inside that frame, a new variable param is created. The bits representing 10 are duplicated from x’s memory address to param’s memory address. They are two distinct integers living in two different locations.

// C++ Example: Pass by Value
void modify(int num) {
    num = 20; // Modifies the local copy only
    std::cout << "Inside function: " << num; // Output: 20
}

int main() {
    int value = 10;
    modify(value);
    std::cout << "Outside function: " << value; // Output: 10 (Unchanged)
    return 0;
}

Advantages of Pass by Value

  1. Safety and Predictability: Functions become "pure" in terms of side effects on arguments. You can call a function without fearing it will mutate your state. This simplifies reasoning about code flow, especially in concurrent or multi-threaded environments.
  2. Immutability Guarantees: It enforces a design where data flows in one direction (into the function), aligning well with functional programming paradigms.
  3. Simplicity for Small Types: For primitive types (integers, floats, booleans, small structs), the overhead of copying is negligible—often faster than the indirection required for a reference.

The Performance Cost

The downside appears when passing large data structures. Copying a 10MB array or a complex object with deep nested structures consumes CPU cycles and memory bandwidth. If the function only needs to read the data, this copy is pure waste. This is why high-performance languages often provide mechanisms to simulate pass-by-reference for large objects (like const & in C++ or pointers in Go).

Deep Dive: Passing by Reference

Pass by reference avoids the copy. Instead of duplicating the value, the mechanism passes the address (pointer) of the variable. The formal parameter binds to the same memory location as the actual argument.

How It Works in Memory

Using the previous example, x lives at memory address 0x7FFC. It is an alias bound to 0x7FFC. Here's the thing — when modify_ref(x) is called, the parameter param is not a new integer. When the function executes param = 20, the CPU writes directly to address 0x7FFC. The original x sees the change immediately.

// C++ Example: Pass by Reference
void modify_ref(int &num) {
    num = 20; // Modifies the actual argument
    std::cout << "Inside function: " << num; // Output: 20
}

int main() {
    int value = 10;
    modify_ref(value);
    std::cout << "Outside function: " << value; // Output: 20 (Changed!)
    return 0;
}

Advantages of Pass by Reference

  1. Performance Efficiency: No data copying occurs. Passing a massive std::vector or a struct containing kilobytes of data takes the same time as passing an integer (the size of a pointer). This is critical for high-frequency trading systems, game engines, and data processing pipelines.
  2. Output Parameters: It allows functions to return multiple values by modifying arguments passed in. This is a classic pattern in C (using pointers) and C++ (using references) for functions that need to signal success/failure and return computed data.
  3. Polymorphism and Identity: In object-oriented programming, passing objects by reference (or pointer) preserves the object's identity and dynamic type. Passing a derived class object by value to a base class parameter causes object slicing—the derived portion is chopped off, destroying polymorphic behavior.

Risks and Side Effects

The power to mutate comes with responsibility. Day to day, * Unintended Mutations: A function deep in the call stack might modify a variable owned by main(), creating spaghetti dependencies. In real terms, * Aliasing Issues: If two references point to the same memory, the compiler cannot optimize as aggressively (it must assume writes through one reference affect reads through the other). * Thread Safety: Shared mutable state is the enemy of concurrency. Passing references across threads without synchronization primitives (mutexes, atomics) leads to data races.

The "Call by Sharing" Nuance: Reference Types in Modern Languages

This is where most confusion arises. Languages like Java, Python, JavaScript, C#, Ruby, and Go are strictly pass-by-value. Even so, their "values" for objects are references (pointers) Simple, but easy to overlook..

This mechanism is technically called Call by Sharing (coined by Barbara Liskov).

The Mechanism

  1. The variable holds a reference (address) to an object on the heap.
  2. When passed to a function, the reference is copied (pass-by-value).
  3. The parameter now holds a copy of the address. Both the caller's variable and the parameter point to the exact same object on the heap.

Mutation vs. Reassignment

This distinction is the key to mastering these languages Not complicated — just consistent. Nothing fancy..

  • Mutation (Modifying Internals): If you modify the contents of the object (e.g., list.append(1), obj.field = 5), the change is visible to the caller. You are following the shared address to the heap and editing the data there.
  • Reassignment (Changing the Pointer): If you assign a new object to the parameter (e.g., param = new List(), param = null), you are overwriting the local copy of the address. The caller’s variable still points to the original object. The link is severed.

Python Example

def modify_list(my_list):
    # MUTATION: Affects the original object
    my_list.append(99)
    print(f"Inside (after append): {my_list}") 

    # REASSIGNMENT: Creates a new local binding
    my_list = [1, 2, 3] 
    print(f"Inside (after reassign): {my_list}")

original = [10, 20]
print(f"Before: {original}")        # Output: [10

```python
def modify_list(my_list):
    # MUTATION: Affects the original object
    my_list.append(99)
    print(f"Inside (after append): {my_list}") 

    # REASSIGNMENT: Creates a new local binding
    my_list = [1, 2, 3] 
    print(f"Inside (after reassign): {my_list}")

original = [10, 20]
print(f"Before: {original}")        # Output: [10, 20]
modify_list(original)
print(f"After: {original}")         # Output: [10, 20, 99]

The output confirms the theory: the append (mutation) persisted, but the reassignment my_list = [1, 2, 3] only redirected the local parameter variable, leaving original untouched That's the whole idea..

The Immutable Trap

This mechanism creates a subtle "gotcha" with immutable types (strings, tuples, integers, frozen dataclasses). Since you cannot mutate them, every "modification" is effectively a reassignment.

def try_change_string(s):
    s += " world"  # Strings are immutable; this creates a NEW string object
    print(f"Inside: {s}")

text = "Hello"
try_change_string(text)
print(f"Outside: {text}")  # Output: "Hello" (Unchanged)

To the caller, immutable objects passed via Call by Sharing behave exactly like Pass by Value. You cannot change the original object, and you cannot make the caller's variable point to a new object. This is why "Pass by Value" is often used as a sufficient mental model for immutable data in these languages, even if the implementation passes a reference But it adds up..

Defensive Copying: Protecting Your Invariants

Because Call by Sharing grants the callee mutation access to the caller's heap objects, encapsulation is fragile. If a class exposes a mutable internal field (like a List, Map, Date, or custom object) via a getter or constructor argument, the caller retains a reference to that internal state and can mutate it behind the class's back Less friction, more output..

The Fix: Defensive Copying (Defensive Cloning).

  • On Input (Constructors/Setters): Never store the reference passed in directly. Copy it.
  • On Output (Getters): Never return the reference to your internal field. Return a copy (or an unmodifiable view).

Java Example

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        // DEFENSIVE COPY ON INPUT
        this.start = new Date(start.end = new Date(end.Because of that, getTime());
        this. getTime());
        
        if (this.So naturally, start. compareTo(this.

    public Date getStart() {
        // DEFENSIVE COPY ON OUTPUT
        return new Date(start.getTime());
    }
    // ...
}

Without these copies, a caller could do period.Think about it: getStart(). setYear(2050) and corrupt the Period instance's invariants. Modern languages offer alternatives: Immutable Collections (Java List.of, C# ImmutableList, Python tuple/frozenset) or Unmodifiable Views (Java Collections.unmodifiableList) which are cheaper than full copies but prevent mutation That's the part that actually makes a difference..

Performance Considerations: The Hidden Costs

1. Indirection and Cache Misses

Pass by Reference/Sharing requires a pointer dereference (an extra memory hop) to access data. For small, contiguous data (structs, int, float, Vec3), Pass by Value is often faster. It keeps data in registers or L1 cache (locality of reference), avoiding pointer chasing and enabling SIMD vectorization. C++ std::string (Small String Optimization) and Rust's ownership model exploit this aggressively.

2. Escape Analysis & Scalar Replacement

Modern JIT compilers (JVM HotSpot, V8, Go compiler, .NET RyuJIT) perform Escape Analysis. If an object allocated inside a method doesn't "escape" (isn't stored in a field, returned, or passed to another thread), the compiler can:

  • Allocate it on the stack (instant deallocation).
  • Scalar Replacement: Break the object into its constituent fields and keep them in CPU registers, eliminating the allocation entirely. Pass by Value (or passing non-escaping references) enables these optimizations; passing references to heap objects that do escape inhibits them.

3. Copy Elision (C++ / Rust)

Returning large objects by value in C++ (NRVO/RVO) or Rust involves zero copies—the compiler constructs the return value directly in the caller's stack frame. This makes "Return by Value" for large structs faster than "Return by Reference" (which implies heap allocation

and thus still involves a heap allocation behind the scenes) Worth knowing..

In Rust, this is guaranteed by the ownership system: when a value is returned, the compiler emits a memcpy of the stack frame or elides it entirely via RVO—there is no heap involvement unless the type itself owns heap data (like Box<T> or Vec<T>), in which case only the pointer is copied while the heap buffer is shared or transferred Worth knowing..

Honestly, this part trips people up more than it should.

4. Memory Allocation Pressure & Garbage Collection

In managed languages (Java, C#, Go, JavaScript), Pass by Reference typically means fewer allocations on the heap—shared references to existing objects don't trigger GC pressure. That said, Pass by Value for large structs can cause allocation storms, especially in loops where copies are created and discarded rapidly. This is where the trade-off becomes language-specific:

  • Java: Primitives are passed by value; objects are passed by reference. Value Types (Project Valhalla) aim to change this by allowing lightweight, inlineable value objects without heap allocation overhead.
  • C#: struct types are value types (stack-allocated or inline); class types are reference types. Choosing between them is a deliberate design decision balancing copy cost vs. sharing needs.
  • Go: Everything is passed by value by default, including slices (which are lightweight headers pointing to an underlying array). This makes the cost of "pass by value" predictable—at most a few bytes copied—while still enabling shared mutation through the referenced array.

5. Concurrency and Thread Safety

Pass by Sharing introduces a critical challenge in concurrent systems: data races. When multiple threads hold references to the same mutable object, synchronization (locks, atomics, monitors) is required to ensure correctness. This adds complexity and can negate performance gains from sharing.

Pass by Value, conversely, is inherently thread-safe—each thread operates on its own copy, and no synchronization is needed. Day to day, this is why functional programming paradigms (Erlang, Elixir, Haskell) and frameworks like Akka stress immutable message passing. The cost of copying is almost always cheaper than the cost of contention on a shared lock Practical, not theoretical..


The Broader Picture: Choosing the Right Strategy

There is no universal answer—only trade-offs:

Criterion Pass by Value Pass by Reference / Sharing
Small data (int, float, small struct) ✅ Fast (registers/cache) ❌ Indirection overhead
Large data (arrays, trees) ❌ Expensive copies ✅ Cheap pointer pass
Mutability needed ❌ Copies diverge ✅ Shared state
Concurrency safety ✅ No races ❌ Needs synchronization
Immutability ✅ Naturally safe ✅ If data is immutable
Cache locality ✅ Contiguous data ❌ Pointer chasing
GC pressure ❌ More allocations (managed) ✅ Fewer allocations

The best engineers treat this as a spectrum rather than a binary. In practice:

  1. Default to immutability. Whether achieved through value semantics or shared immutable references, immutability eliminates entire categories of bugs.
  2. Pass small values by value. Primitives, fixed-size structs, and vectors belong in registers and cache lines.
  3. Share large or mutable data by reference—but enforce access discipline through ownership models (Rust), defensive copies (Java), or concurrency primitives (channels, actors).
  4. Let the compiler help. Trust escape analysis, copy elision, and link-time optimization to make the "expensive" choices free when it matters.

At the end of the day, the trend across modern language design—from Rust's ownership model to Java's Project Valhalla, from C++'s move semantics to Go's slice headers—is a convergence toward explicit, compiler-enforced decisions about who owns data and how it flows. Understanding the interplay between mutability, immutability, and performance is not just an academic exercise; it is the foundation upon which strong, efficient systems are built Most people skip this — try not to..

Honestly, this part trips people up more than it should Not complicated — just consistent..

Just Made It Online

Out This Morning

Round It Out

You May Find These Useful

Thank you for reading about Passing By Value Vs Passing By Reference. We hope the information has been useful. Feel free to contact us if you have any questions. See you next time — don't forget to bookmark!
⌂ Back to Home