Calling a function with an object in Java or Python is often described as “pass by reference,” and so is calling with a pointer in Go. Look at what memory actually does, though, and all three languages copy values when arguments cross a function boundary. What gets copied differs — a primitive, an object address, a full struct header — but the mechanism is the same. Pass by reference in casual usage is pass by value in memory.

Pointer Basics

A pointer is a variable that holds a memory address. An integer variable holds an integer; a pointer variable holds the location of another variable. C and Go expose pointers explicitly.

func increment(p *int) {
    *p += 1
}

x := 10
increment(&x)
// x == 11

increment receives &x, the address of x, which is copied into a new variable p inside the function. The caller’s &x and the function’s p are two separate variables holding the same address. So *p += 1 writes through that address into the memory x lives in. The caller’s x changes not because “a reference was passed” but because an address value was copied so both variables now point at the same location.

This is the core idea behind the whole post. Every function call copies the contents of a variable. If the contents are a number, a number is copied. If they are an address, an address is copied. The same pattern repeats in every language below.

Java References

Java draws a clear line between primitives and reference types.

  • Primitives (int, boolean, double…) — the value itself sits on the stack.
  • References (String, List, user-defined classes…) — the object lives on the heap, and the variable on the stack holds the address that points at it.
void mutate(StringBuilder sb) {
    sb.append(" world");
}

StringBuilder s = new StringBuilder("hello");
mutate(s);
// s.toString() == "hello world"

s holds the address of a StringBuilder on the heap. When mutate(s) runs, that address value is copied into a new variable sb inside the function. Two references now point at the same heap object, so sb.append mutates what the caller’s s also sees.

You can call this “passing a reference,” but the specification puts it more precisely: “a reference value is passed by value” (JLS §8.4.1). The distinction shows up the moment you reassign the parameter.

void replace(StringBuilder sb) {
    sb = new StringBuilder("other");
}

StringBuilder s = new StringBuilder("hello");
replace(s);
// s.toString() == "hello"  // unchanged

Assigning a new object to sb only rebinds the function’s local variable. The caller’s s still holds the original address. If Java were truly pass by reference, s would have switched to the new object as well. It does not. Java is pass by value.

Python Name Binding

In Python every piece of data is an object on the heap. Integers, strings, lists — all objects. A variable is not an object; it is a name bound to one. In CPython terms, the name maps to a PyObject * pointer.

a = [1, 2, 3]
b = a
print(id(a) == id(b))   # True — both names refer to the same object

b = a does not copy the list; it binds a second name to the same object. Function calls work the same way.

def append_item(lst):
    lst.append(4)

x = [1, 2, 3]
append_item(x)
# x == [1, 2, 3, 4]

The call binds a new name lst to the same object that x refers to. Two names point at one object, so lst.append(4) mutates what x also sees. The academic term for this model is call by sharing (sometimes “pass by object reference”). Strictly it is not pass by value, but the mechanism — copying an object reference into a new local name — reaches the same conclusion.

The mutable/immutable distinction comes from the objects themselves, not from how they are passed.

def rebind(n):
    n = n + 1   # builds a new int and binds it to the local name n

x = 10
rebind(x)
# x == 10  // unchanged

n + 1 creates a new integer object and binds the local name n to it. The caller’s x still points at the original 10. The caller is not unchanged because integers travel “by value” in some special way — it is unchanged because there is no way to mutate an int in place, so the function had to make a new object. Call append on a list inside a function, and the caller sees the mutation.

Go Structs and Escape Analysis

Every function call in Go copies values. Integers, structs, and pointers are all copied into new variables when they cross a function boundary.

type Point struct {
    X, Y int
}

func move(p Point) {
    p.X += 1
}

q := Point{1, 2}
move(q)
// q.X == 1  // the whole struct was copied in, caller unchanged

The struct Point is duplicated wholesale. The function’s p and the caller’s q are unrelated. To mutate the caller, pass a pointer explicitly.

func moveP(p *Point) {
    p.X += 1
}

q := Point{1, 2}
moveP(&q)
// q.X == 2

&q is the address of q, which is copied into the function’s new pointer variable p. Writing through p.X writes into the same memory. The mechanism matches Java references.

Slices and maps follow the same pattern. A slice is internally a header of three fields (pointer, length, capacity). Passing a slice copies those three fields. The pointer in the copied header still points at the same backing array, so writing through an index visibly mutates the caller. Reassign the slice parameter or grow it with append past capacity, and the function ends up with a slice pointing at a different backing array — the caller is no longer affected.

Where Go diverges from Java and Python is how memory locations get decided. Java pins reference objects to the heap; Python keeps every object on the heap. Go’s compiler decides per variable via escape analysis whether each value lives on the stack or escapes to the heap.

func makeLocal() *Point {
    p := Point{1, 2}
    return &p   // address of p leaves the function → heap allocation
}

func makeTemp() Point {
    p := Point{1, 2}
    return p    // p stays inside the function → stack allocation
}

Compiling with -gcflags="-m" prints the compiler’s escape decisions. Using new or & does not force a heap allocation, and a plain local variable is not guaranteed to live on the stack either. If the compiler sees the value’s reach extend past the function, it places the value on the heap instead.

Three Languages Compared

Memory behavior looks different across the three, but what happens at a function call is the same shape.

block-beta
    columns 3
    j_h["Java"]
    p_h["Python"]
    g_h["Go"]
    j["Stack:
s = 0xA1

Heap:
0xA1 → StringBuilder"] p["Names:
a, b → 0xB2

Heap:
0xB2 → list"] g["Stack or Heap:
q Point{1,2}
p *Point = 0xC3

(escape analysis)"]
AspectJavaPythonGo
Argument passingReference value copied onto the stackObject address bound to a new nameValue / struct / pointer copied onto the stack
Object locationReference types always on the heapEvery object on the heapStack or heap, per escape analysis
Mutating the caller’s objectMutator on the shared objectIn-place method on a mutable objectPointer parameter, write through it
Reassigning the parameterCaller unchangedCaller unchangedCaller unchanged
Memory modelExplicit references + GCName binding + GCEscape analysis + GC

The common pattern is hard to miss. What you pass into a function is the contents of a variable; when those contents happen to be an address, the function can reach the caller’s object. True pass by reference (C++’s & reference parameters, Pascal’s var parameters) would let the function reassign the caller’s variable as well, and none of Go, Java, or Python work that way.

Every function call copies the contents of a variable. A primitive copies a primitive; an object address copies an address; a struct copies the whole struct. “Java passes objects by reference” and “everything in Python is a reference” are convenient shorthand, but at the memory level they are all pass by value. What gets copied — a primitive, an address, or a struct header — is what makes the languages feel different. The transfer mechanism itself does not.