The classic gotchas
Every bug in this guide has the same root cause: someone treated a reference like a value, or forgot that following an address only works if there's something valid at the other end. Three shapes of this cover almost every bug report that starts with "but I didn't touch that variable."
Gotcha 1: null / nil dereference
An address variable can be empty — it points nowhere. Most languages have a specific value for this: null, nil, None, nullptr. The variable exists, but there's no address in it to follow.
customer = find_customer(id) # returns None/null if no match was found
read(customer.name) # dereferencing None -> crash
What just happened: find_customer didn't find anyone, so it returned "no address" instead of an address. The next line assumes there's a customer to follow the reference to, and tries to dereference nothing. The program has no data to read, so it fails — usually with an error like NullPointerException, AttributeError: 'NoneType' object has no attribute, or a segmentation fault, depending on the language.
A null/nil reference is a box that's honestly empty. The bug isn't the emptiness — it's dereferencing without checking first.
The fix is always the same shape: check before you follow.
customer = find_customer(id)
if customer is not None:
read(customer.name)
else:
handle_not_found()
Gotcha 2: dangling pointers and use-after-free
This one is sharper in languages with manual memory management (C, C++, and Rust if you reach for unsafe). An address can point at memory that used to hold valid data — and has since been freed and handed back to something else.
p = allocate(SomeStruct)
free(p) // the memory is released; p still holds the old address
read(p) // use-after-free: reading memory that's no longer yours
What just happened: free(p) tells the system "I'm done with this memory, it's available again" — but it does not erase p. p still holds the same address it always did. That address is now a dangling pointer: it points at memory that might be reused by something completely unrelated a moment later, or might still contain the old bytes for now. Reading through it is undefined — sometimes it looks like it works, sometimes it silently corrupts unrelated data, sometimes it crashes. That inconsistency is what makes this bug miserable to track down.
Null dereference -> the address is honestly empty; fails immediately and loudly
Dangling pointer -> the address looks valid but points at freed/reused memory; fails unpredictably
What just happened: both are "following an address that shouldn't be followed," but null dereference tends to fail fast and dangling pointers tend to fail later, somewhere else, in a way that looks unrelated to the actual mistake. Garbage-collected languages (Python, Java, JavaScript, Go, C#) sidestep this specific gotcha almost entirely — the runtime won't free memory that a live reference still points at. Rust prevents it at compile time in safe code through its ownership rules. It's mainly manual-memory languages where this is a live daily concern.
Gotcha 3: the shared-reference surprise
This is the one that doesn't require any manual memory management at all — it shows up in Python, Java, JavaScript, anywhere Bucket 2 from Phase 2 applies — and it's the most common of the three in everyday application code.
original = {"count": 1}
copy = original # this looks like a copy. it is NOT.
copy["count"] = 99
read(original["count"]) # -> 99, not 1
What just happened: copy = original assigns the reference, not the data. copy and original are two names pointing at the exact same dictionary. Mutating through copy mutates the one shared object, and original sees it too — because there was never a second object to begin with. This is Phase 1's picture, showing up as a bug: two boxes holding the same address, and someone expected two independent boxes holding the same starting value.
The fix is to make the copy explicit when you actually want one:
original = {"count": 1}
copy = clone(original) # now an independent object with the same starting contents
copy["count"] = 99
read(original["count"]) # -> 1, untouched
What just happened: clone (spelled dict(original), .copy(), {...original}, Object.assign, or similar depending on the language) allocates a genuinely new box and copies the contents into it. Now there are two addresses pointing at two different pieces of data, which is what "make a copy" actually requires.
The one question that catches all three
Before you assign, pass, or return something that isn't a plain number or boolean, ask: is this a reference to shared data, and does that matter here? If it's a reference and might be empty, check before dereferencing. If it's a reference into memory you manage yourself, make sure it's still valid before you follow it. If it's a reference and you wanted an independent copy, clone it explicitly. All three gotchas are the same one instinct, applied at the point where it counts: don't assume you're holding a value when you might be holding an address.
← Phase 2: Pointers vs. references across languages | Overview