Weak Pointers

A weak pointer is a reference that lets a program point at an object without keeping that object alive. An ordinary Go pointer is a strong reference — as long as one exists, the garbage collector treats the target as reachable and will never reclaim it. A weak pointer is invisible to reachability analysis: “Objects that are only pointed to by weak pointers are not considered reachable, and once the object becomes unreachable, Pointer.Value may return nil” (pkg.go.dev/weak). Go added the weak package in Go 1.24 as “a low-level primitive provided to enable the creation of memory-efficient structures, such as weak maps … canonicalization maps … and various kinds of caches” (Go 1.24 release notes). It is generic — weak.Pointer[T] — and its entire surface is three things: the type, weak.Make, and the Value method.

The Problem: Caches That Will Not Let Go

Consider a cache keyed by some identity — a parsed file, an interned string, a decoded image. A naive map[string]*BigThing keeps every value it has ever cached strongly reachable. The garbage collector can never reclaim a cached value, because the map’s pointer is a strong reference. The cache grows without bound; it is a memory leak with a friendly name.

The classic workarounds are all unsatisfying. A bounded LRU cache evicts on a size limit but throws away entries the program is still using if the limit is too low, and wastes memory if too high — it has no idea what is actually live. A manual Release() API pushes lifetime management onto every caller and leaks the moment one forgets. What you actually want is a cache that holds a value only as long as something else in the program holds it strongly — the instant the last real user drops it, the cache entry should evaporate too. That is exactly a weak pointer: the cache holds weak.Pointer[BigThing], which does not count toward reachability, so the value’s lifetime is driven entirely by the program’s strong references.

Mental Model

Think of two kinds of string tied to an object. A strong string is a leash: while you hold it, the object cannot wander off (be collected). A weak string is a thread of spider silk attached to the same object: you can follow it to find the object if it is still there, but the silk exerts no force — it will not stop the GC from taking the object. When the last leash is dropped, the GC reclaims the object and every silk thread goes slack: following it now yields nil.

flowchart LR
    subgraph Before["Object still strongly reachable"]
        R["strong ref\n(local var / map value)"] -->|"keeps alive"| Obj["heap object"]
        W1["weak.Pointer"] -.->|"Value() → *T"| Obj
    end
    subgraph After["Strong ref dropped, GC runs"]
        R2["strong ref gone"]
        Obj2["object reclaimed"]
        W2["weak.Pointer"] -.->|"Value() → nil"| Obj2
    end
    Before -->|"drop strong ref, GC cycle"| After

Diagram: while any strong reference exists (left), Value() returns the live *T; the weak pointer rides along. Once the strong reference is gone and a GC cycle reclaims the object (right), Value() returns nil. The insight: a weak pointer’s Value() is the runtime telling you, atomically, whether the object survived — it never resurrects the object, it only reports.

Mechanical Walk-through

The weak package’s entire API (pkg.go.dev/weak):

  • type Pointer[T any] struct{ ... } — an opaque generic value type. “Pointer is a weak pointer to a value of type T.” It is a small value (an indirect handle), safe to copy and store in maps and structs.
  • func Make[T any](ptr *T) Pointer[T] — “creates a weak pointer from a pointer to some value of type T.” Make(nil) is legal and “returns a weak pointer whose Pointer.Value always returns nil.”
  • func (p Pointer[T]) Value() *T — “returns the original pointer used to create the weak pointer. It returns nil if the value pointed to by the original pointer was reclaimed by the garbage collector.”

How Value() interacts with the collector. The weak pointer holds an indirect handle to the object rather than the object’s address directly. During the mark phase (see Tricolor Mark and Sweep) the GC does not trace through weak pointers — that is the whole point — so an object reachable only through weak pointers is marked dead and swept. When you call Value(), the runtime consults that handle: if the object is still live, it returns a fresh strong *T (which now keeps the object alive for as long as you hold it); if the object was reclaimed, it returns nil. Value() is therefore the only way to “upgrade” a weak reference back to a usable strong one, and it does so atomically with respect to the collector — you cannot observe a half-collected object.

Interior pointers and equality. Like an ordinary Go pointer, a weak pointer “may reference any part of an object, such as a field of a struct or an element of an array.” Equality has a precise rule: “Two Pointer values compare equal if and only if the pointers from which they were created compare equal. This property is maintained even after the object referenced by the pointer … is reclaimed.” So two weak pointers made from the same address stay == forever, even past the object’s death — essential for using weak pointers as map keys. But “if multiple weak pointers are made to different offsets within the same object … those pointers will not compare equal” — distinct fields are distinct keys.

Interaction with finalizers and cleanups. A subtle ordering rule connects the weak package to Finalizers and runtime.AddCleanup: “If a weak pointer points to an object with a finalizer, then Value will return nil as soon as the object’s finalizer is queued for execution.” In other words, an object with a finalizer “dies” — from a weak pointer’s perspective — the moment its finalizer is queued, not when its memory is finally reclaimed two cycles later. This prevents a weak pointer from resurrecting an object that the runtime has already committed to finalizing.

Code: A Weak-Valued Canonicalization Cache

package canon
 
import (
    "sync"
    "weak"
)
 
type Data struct {
    key     string
    payload []byte
}
 
var (
    mu    sync.Mutex
    cache = map[string]weak.Pointer[Data]{} // (1)
)
 
func Lookup(key string) *Data {
    mu.Lock()
    defer mu.Unlock()
 
    if wp, ok := cache[key]; ok { // (2)
        if d := wp.Value(); d != nil { // (3)
            return d
        }
        delete(cache, key) // (4)
    }
 
    d := &Data{key: key, payload: load(key)} // (5)
    cache[key] = weak.Make(d)                // (6)
    return d
}

(1) The map stores weak.Pointer[Data], not *Data — entries do not keep their Data alive. (2) On lookup, fetch the weak pointer. (3) Value() upgrades it: a non-nil result is a fresh strong pointer that pins the object for the caller’s use — a cache hit on a still-live value. (4) A nil result means the Data was collected while the cache slept; the stale weak entry is pruned. (5) On a miss, build the value normally — d is a strong pointer the caller now owns. (6) Store only a weak reference. The lifetime of every Data is now governed entirely by whoever holds the strong *Data returned from Lookup; when the last such holder drops it, a GC cycle reclaims it and the cache entry becomes a tombstone awaiting step (4).

The remaining flaw: dead entries linger as nil-yielding tombstones until the next Lookup for that exact key prunes them. A production cache pairs this with [[Finalizers and runtime.AddCleanup|runtime.AddCleanup]] — attach a cleanup to each Data that deletes its own map key — so tombstones are swept proactively rather than lazily. The standard library’s unique package does exactly this combination.

// Eager pruning: combine weak.Make with a cleanup.
d := &Data{key: key, payload: load(key)}
cache[key] = weak.Make(d)
runtime.AddCleanup(d, func(k string) { // runs after d is reclaimed
    mu.Lock()
    if wp, ok := cache[k]; ok && wp.Value() == nil {
        delete(cache, k) // remove only if still the dead entry
    }
    mu.Unlock()
}, key)

The cleanup’s arg is the bare string key — never d — so the closure cannot keep d alive (that would defeat the weak pointer entirely; see the resurrection trap in Finalizers and runtime.AddCleanup).

Failure Modes and Common Misunderstandings

Value() will eventually return nil once I drop the strong reference.” Not guaranteed. The docs state plainly: “Pointer.Value is not guaranteed to eventually return nil.” The GC reclaims memory on its own schedule; until a cycle runs and frees the object, Value() keeps returning the live pointer. Code must treat a non-nil result as “maybe still alive” and a nil result as “definitely gone” — never assume promptness.

Premature collection — the reachability boundary. Just like with cleanups, “a function argument or receiver may become unreachable at the last point where the function mentions it.” If you create a weak pointer and expect the object to survive, you must hold a strong reference across that span: “To ensure Pointer.Value does not return nil, pass a pointer to the object to the runtime.KeepAlive function after the last point where the object must remain reachable.” Forgetting this makes a weak pointer go nil surprisingly early.

The tiny-object batching caveat. The mirror of the finalizer batching trap: “the runtime is allowed to perform a space-saving optimization that batches objects together in a single allocation slot. The weak pointer for an unreferenced object in such an allocation may never become nil if it always exists in the same batch as a referenced object. Typically, this batching only happens for tiny (on the order of 16 bytes or less) and pointer-free objects.” A weak pointer to a tiny noscan object (see Tiny Allocator) may never observe the object’s death, because the slot stays alive for a batch-mate. Do not build weak caches of tiny pointer-free values.

Using a weak pointer as a strong one. Calling Value() and storing the result defeats the purpose — you now hold a strong reference and the object cannot be collected. Use the upgraded pointer for the duration of one operation and let it fall out of scope.

Equality past death is a feature, not a bug. Because weak pointers from the same address stay == even after reclamation, they make stable map keys — but two weak pointers to different fields of the same struct are unequal. Pick the pointer offset deliberately.

Alternatives and When to Choose Them

A bounded LRU/LFU cache is the right choice when you want a hard, predictable memory ceiling and are willing to evict live entries — weak pointers give no size bound, only liveness-tracking. A strong map plus manual Release() gives deterministic eviction but leaks on the first missed call. Weak pointers shine specifically for canonicalization (one shared instance per logical value, dropped when unused — exactly unique), weak maps (associating data with a key without extending the key’s life), and opportunistic caches that should shrink automatically under memory pressure. They are a low-level primitive: the Go 1.24 notes call them exactly that, expecting most programmers to consume them via higher-level packages like unique rather than wield weak.Pointer directly. The sibling primitive is Finalizers and runtime.AddCleanup: a cleanup lets you react to an object’s death; a weak pointer lets you check whether it is dead. Real caches use both.

Production Notes

The motivating in-tree consumer is the standard library’s unique package (also Go 1.24), which interns comparable values so equal values share one allocation — net/netip uses it to deduplicate IP-address zone strings. unique holds its interned values weakly so an interned value is reclaimed once no client holds it, and uses AddCleanup to prune the interning map; this is the canonical weak-pointer-plus-cleanup pattern. Practical guidance: (1) store weak.Pointer[T], never *T, in the structure whose entries should not pin their values; (2) always re-check Value() for nil on every access and prune stale entries; (3) pair with a cleanup for eager pruning of tombstones; (4) never weak-cache tiny pointer-free objects (batching caveat); (5) hold a strong reference plus runtime.KeepAlive across any region where the object must survive; (6) treat the whole mechanism as best-effort — weak pointers tune memory, they do not bound it.

See Also