Weak Pointers in Go: The Runtime's Special Span for Non-Tracked Objects

The weak package gives Go a pointer that does not keep its target alive; the runtime implements it with a per-object special record holding a uintptr handle that the GC clears when the object dies.

TL;DR: weak.Pointer[T] is a pointer the garbage collector does not honour. It is a specialWeakHandle record on the object’s span holding an atomic.Uintptr — a raw address, not a Go pointer — so the GC never treats it as a reference. When the object dies, the sweeper zeroes the handle.

Every garbage-collected language eventually needs a way to say “I would like to point at that object, but please don’t keep it alive for me”. Caches need it. Canonicalisation tables need it. Graph algorithms with weak edges need it. Go did not have it for a very long time, and the usual workaround — runtime.SetFinalizer plus a map[uintptr]T — is a mess of resurrection semantics and race conditions.

Go 1.24 added the weak package. It is small, and the implementation is one of the more elegant pieces of runtime code in the tree, because it reuses machinery that already existed: span specials.

The API

The whole package is one type and two methods:

go
// src/weak/pointer.go:60
type Pointer[T any] struct {
	// Mention T in the type definition to prevent conversions
	// between Pointer types, like we do for sync/atomic.Pointer.
	_ [0]*T
	u unsafe.Pointer
}

// Make creates a weak pointer from a pointer to some value of type T.
func Make[T any](ptr *T) Pointer[T] {
	// Explicitly force ptr to escape to the heap.
	ptr = abi.Escape(ptr)

	var u unsafe.Pointer
	if ptr != nil {
		u = runtime_registerWeakPointer(unsafe.Pointer(ptr))
	}
	runtime.KeepAlive(ptr)
	return Pointer[T]{u: u}
}

// Value 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.
// 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.
func (p Pointer[T]) Value() *T {
	if p.u == nil {
		return nil
	}
	return (*T)(runtime_makeStrongFromWeak(p.u))
}

Three details in twenty lines carry most of the weight.

The _ [0]*T field. It is a zero-sized array whose only job is to make the type mention T, so that Pointer[int] and Pointer[string] are distinct types and cannot be converted between each other. The same trick sync/atomic.Pointer uses.

abi.Escape(ptr). The pointed-to object must live on the heap, because the runtime stores a special record on the span that contains it, and the stack has no spans. Escape is a compiler intrinsic that forces the argument to escape:

go
// src/internal/abi/escape.go:27
// Escape forces any pointers in x to escape to the heap.
func Escape[T any](x T) T {
	if alwaysFalse {
		escapeSink = x
	}
	return x
}

The alwaysFalse branch is never taken, but the compiler’s escape analysis sees the assignment to a global sink and therefore concludes that x escapes. It compiles down to nothing at runtime.

runtime.KeepAlive(ptr) after the call. Between the escape and the registration, the compiler could in principle prove ptr is dead. The KeepAlive makes sure the object is still alive at the moment the weak handle is installed. This is the first of the runtime’s documented weak pointer invariants:

Strong-to-weak conversions must ensure the strong pointer remains live until the weak handle is installed. This ensures that creating a weak pointer cannot fail.

What is actually stored

Pointer[T].u is an unsafe.Pointer, but it does not point at the target object. It points at a handle — a heap cell holding the target’s address as a uintptr:

go
// src/runtime/mheap.go:2391
// specialWeakHandle is allocated from non-GC'd memory, so any heap
// pointers must be specially handled.
type specialWeakHandle struct {
	_       sys.NotInHeap
	special special
	// handle is a reference to the actual weak pointer.
	// It is always heap-allocated and must be explicitly kept
	// live so long as this special exists.
	handle *atomic.Uintptr
}

//go:linkname internal_weak_runtime_registerWeakPointer weak.runtime_registerWeakPointer
func internal_weak_runtime_registerWeakPointer(p unsafe.Pointer) unsafe.Pointer {
	return unsafe.Pointer(getOrAddWeakHandle(p))
}

//go:linkname internal_weak_runtime_makeStrongFromWeak weak.runtime_makeStrongFromWeak
func internal_weak_runtime_makeStrongFromWeak(u unsafe.Pointer) unsafe.Pointer {
	handle := (*atomic.Uintptr)(u)

The address lives inside an atomic.Uintptr, which is a uintptr — an integer. The garbage collector does not treat integers as references. That is the whole trick. The weak pointer looks like a pointer to the GC, and is not one.

The specialWeakHandle struct itself is marked sys.NotInHeap, and it is allocated out of a fixalloc arena, i.e. outside the garbage-collected heap. That means the GC will not find it by scanning, so the runtime has to scan it by hand. You will see that below.

Note also the two //go:linkname declarations: the weak package reaches into the runtime for registerWeakPointer and makeStrongFromWeak. This is the escape hatch that article four is about, and it is the sanctioned way to do it — the runtime pushes the symbols, the linker checks the pull.

Registering the handle

go
// src/runtime/mheap.go:2508
// Retrieves or creates a weak pointer handle for the object p.
func getOrAddWeakHandle(p unsafe.Pointer) *atomic.Uintptr {
	if debug.sbrk != 0 {
		// debug.sbrk never frees memory, so it'll never go nil. However, we do still
		// need a weak handle that's specific to p. Use the immortal weak handle map.
		// Keep p alive across the call to getOrAdd defensively, though it doesn't
		// really matter in this particular case.
		handle := mheap_.immortalWeakHandles.getOrAdd(uintptr(p))
		KeepAlive(p)
		return handle
	}

	// First try to retrieve without allocating.
	if handle := getWeakHandle(p); handle != nil {
		// Keep p alive for the duration of the function to ensure
		// that it cannot die while we're trying to do this.
		KeepAlive(p)
		return handle
	}

	lock(&mheap_.speciallock)
	s := (*specialWeakHandle)(mheap_.specialWeakHandleAlloc.alloc())
	unlock(&mheap_.speciallock)

The lookup path is getWeakHandle, which searches the span’s specials list for an existing record at the right byte offset:

go
// src/runtime/mheap.go:2594
func getWeakHandle(p unsafe.Pointer) *atomic.Uintptr {
	span := spanOfHeap(uintptr(p))
	if span == nil {
		if isGoPointerWithoutSpan(p) {
			return mheap_.immortalWeakHandles.getOrAdd(uintptr(p))
		}
		throw("getWeakHandle on invalid pointer")
	}

	// Ensure that the span is swept.
	// Sweeping accesses the specials list w/o locks, so we have
	// to synchronize with it. And it's just much safer.
	mp := acquirem()
	span.ensureSwept()

	offset := uintptr(p) - span.base()

	lock(&span.speciallock)

	// Find the existing record and return the handle if one exists.
	var handle *atomic.Uintptr
	iter, exists := span.specialFindSplicePoint(offset, _KindSpecialWeakHandle)
	if exists {
		handle = ((*specialWeakHandle)(unsafe.Pointer(*iter))).handle
	}
	unlock(&span.speciallock)
	releasem(mp)

	// Keep p alive for the duration of the function to ensure
	// that it cannot die while we're trying to do this.
	KeepAlive(p)
	return handle
}

Two very important facts fall out of this.

One. The special is attached at a byte offset into the object, not to the object as a whole. offset := uintptr(p) - span.base(). So a weak pointer to the third field of a struct gets its own handle, distinct from a weak pointer to the first field. That matches the documented behaviour:

Two Pointer values compare equal if and only if the pointers from which they were created compare equal. … If multiple weak pointers are made to different offsets within the same object (for example, pointers to different fields of the same struct), those pointers will not compare equal. In other words, weak pointers map to objects and offsets within those objects, not plain addresses.

So interior pointers are supported. What is not supported is a pointer that is not inside a heap allocation at all: spanOfHeap returns nil for a stack address or a random integer, isGoPointerWithoutSpan only rescues globals and the zero-sized zerobase, and everything else gets a throw("getWeakHandle on invalid pointer").

Two. Weak handles are canonical per (object, offset). Two calls to weak.Make(&x) return the same handle. The runtime guarantees this so that the same object does not accumulate hundreds of special records:

Weak handles are unique and canonical for each byte offset into an object that a strong pointer may point to, until an object becomes unreachable.

Now the padding comment, which is a small masterpiece of defensive engineering:

go
// src/runtime/mheap.go:2532
	// N.B. Pad the weak handle to ensure it doesn't share a tiny
	// block with any other allocations. This can lead to leaks, such
	// as in go.dev/issue/76007. As an alternative, we could consider
	// using the currently-unused 8-byte noscan size class.
	type weakHandleBox struct {
		h atomic.Uintptr
		_ [maxTinySize - unsafe.Sizeof(atomic.Uintptr{})]byte
	}
	handle := &(new(weakHandleBox).h)
	s.special.kind = _KindSpecialWeakHandle
	s.handle = handle
	handle.Store(uintptr(p))
	if addspecial(p, &s.special, false) {

The Go runtime has a “tiny allocator” that packs several small pointer-free objects into one 16-byte block. If the weak handle were allocated with new(atomic.Uintptr), it would be 8 bytes and could be packed with other objects. Then when the handle is swept, the whole block would be freed — including unrelated objects that merely happened to share it. Worse, the handle might never be freed, because a live neighbour keeps the block alive, and the weak pointer would never become nil. The padding forces the handle into its own allocation.

This is exactly the hazard the weak package documents:

because [Pointer.Value] is not guaranteed to eventually return nil, even after an object is no longer referenced, 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.

The special kinds

Weak pointers get their own entry in the runtime’s list of span specials:

go
// src/runtime/mheap.go:1935
const (
	// _KindSpecialTinyBlock indicates that a given allocation is a tiny block.
	// Ordered before KindSpecialFinalizer and KindSpecialCleanup so that it
	// always appears first in the specials list.
	// Used only if debug.checkfinalizers != 0.
	_KindSpecialTinyBlock = 1
	// _KindSpecialFinalizer is for tracking finalizers.
	_KindSpecialFinalizer = 2
	// _KindSpecialWeakHandle is used for creating weak pointers.
	_KindSpecialWeakHandle = 3
	// _KindSpecialProfile is for memory profiling.
	_KindSpecialProfile = 4
	// _KindSpecialReachable is a special used for tracking
	// reachability during testing.
	_KindSpecialReachable = 5
	// _KindSpecialPinCounter is a special used for objects that are pinned
	// multiple times
	_KindSpecialPinCounter = 6
	// _KindSpecialCleanup is for tracking cleanups.
	_KindSpecialCleanup = 7
	// _KindSpecialCheckFinalizer adds additional context to a finalizer or cleanup.
	// Used only if debug.checkfinalizers != 0.
	_KindSpecialCheckFinalizer = 8
	// _KindSpecialBubble is used to associate objects with synctest bubbles.
	_KindSpecialBubble = 9
	// _KindSpecialSecret is a special used to mark an object
	// as needing zeroing immediately upon freeing.
	_KindSpecialSecret = 10
)

A “special” is a small record hanging off a span, describing something extra about one object in that span: it has a finalizer, it has a cleanup, it is being profiled, or — in our case — it has a weak handle.

go
// src/runtime/mheap.go:1965
type special struct {
	_      sys.NotInHeap
	next   *special // linked list in span
	offset uintptr  // span offset of object
	kind   byte     // kind of special
}

Note _ sys.NotInHeap: the specials list lives outside the GC heap, in memory the collector never scans. That is why every kind of special needs its own explicit handling in the mark phase.

How the GC treats weak references

There are exactly two places the collector touches weak handles.

Marking: the handle is a root, the target is not

go
// src/runtime/mgcmark.go:404
	// Objects with weak handles have only one invariant related
	// to this function: weak handle specials (which are not in the
	// garbage collected heap) are roots. In practice, this means
	// the handle field must be scanned. Note that the value the
	// handle pointer referenced does *not* need to be scanned. See
	// the definition of specialWeakHandle for details.

and the code that does it:

go
// src/runtime/mgcmark.go:453
			lock(&s.speciallock)
			for sp := s.specials; sp != nil; sp = sp.next {
				switch sp.kind {
				case _KindSpecialFinalizer:
					gcScanFinalizer((*specialfinalizer)(unsafe.Pointer(sp)), s, gcw)
				case _KindSpecialWeakHandle:
					// The special itself is a root.
					spw := (*specialWeakHandle)(unsafe.Pointer(sp))
					scanblock(uintptr(unsafe.Pointer(&spw.handle)), goarch.PtrSize, &oneptrmask[0], gcw, nil)
				case _KindSpecialCleanup:
					gcScanCleanup((*specialCleanup)(unsafe.Pointer(sp)), gcw)
				}
			}
			unlock(&s.speciallock)

Read the comment carefully: “the value the handle pointer referenced does not need to be scanned”. The scanblock call marks the handle field itself — that is, the *atomic.Uintptr allocation — so that the handle cell stays alive. It does not mark the object the handle points at. The target object is free to die.

This is the difference between a weak pointer and a strong one, expressed in one line of code: the pointer to the handle is scanned; the pointer inside the handle is not.

Sweeping: the handle is zeroed

When the sweeper finds an unmarked object, it walks the object’s specials and frees them. For a weak handle, “freeing” means storing zero:

go
// src/runtime/mheap.go:2779
func freeSpecial(s *special, p unsafe.Pointer, size uintptr) {
	switch s.kind {
	case _KindSpecialFinalizer:
		sf := (*specialfinalizer)(unsafe.Pointer(s))
		queuefinalizer(p, sf.fn, sf.nret, sf.fint, sf.ot)
		lock(&mheap_.speciallock)
		mheap_.specialfinalizeralloc.free(unsafe.Pointer(sf))
		unlock(&mheap_.speciallock)
	case _KindSpecialWeakHandle:
		sw := (*specialWeakHandle)(unsafe.Pointer(s))
		sw.handle.Store(0)
		lock(&mheap_.speciallock)
		mheap_.specialWeakHandleAlloc.free(unsafe.Pointer(s))
		unlock(&mheap_.speciallock)

sw.handle.Store(0) — the address is erased. From that moment on, every weak pointer to that object sees a zero handle.

The sweeper does this even for objects that are being finalised:

go
// src/runtime/mgcsweep.go:593
			if hasFinAndRevived {
				// Pass 2: queue all finalizers and clear any weak handles. Weak handles are cleared
				// before finalization as specified by the weak package. See the documentation
				// for that package for more details.
				for siter.valid() && siter.s.offset < endOffset {
					// Find the exact byte for which the special was setup
					// (as opposed to object beginning).
					special := siter.s
					p := s.base() + special.offset
					if special.kind == _KindSpecialFinalizer || special.kind == _KindSpecialWeakHandle {
						siter.unlinkAndNext()
						freeSpecial(special, unsafe.Pointer(p), size)
					} else {
						// All other specials only apply when an object is freed,
						// so just keep the special record.
						siter.next()
					}
				}

So the ordering is: weak handles are cleared first, then the finalizer is queued. A weak pointer never observes a finalised-but-resurrected object. The runtime states this as an invariant:

Weak handles contain nil as soon as an object becomes unreachable the first time, before a finalizer makes it reachable again. New weak handles created after resurrection are newly unique.

Converting back to a strong pointer

Value() is not a simple dereference. It has to handle the race where the GC is running right now:

go
// src/runtime/mheap.go:2407
//go:linkname internal_weak_runtime_makeStrongFromWeak weak.runtime_makeStrongFromWeak
func internal_weak_runtime_makeStrongFromWeak(u unsafe.Pointer) unsafe.Pointer {
	handle := (*atomic.Uintptr)(u)

	// Prevent preemption. We want to make sure that another GC cycle can't start
	// and that work.strongFromWeak.block can't change out from under us.
	mp := acquirem()

	// Yield to the GC if necessary.
	if work.strongFromWeak.block {
		releasem(mp)

		// Try to park and wait for mark termination.
		// N.B. gcParkStrongFromWeak calls acquirem before returning.
		mp = gcParkStrongFromWeak()
	}

	p := handle.Load()
	if p == 0 {
		releasem(mp)
		return nil
	}

The work.strongFromWeak.block mechanism exists because converting a weak pointer to a strong one during the mark phase could resurrect an object that the collector has already decided is garbage. If the mark phase is still running, the conversion parks the goroutine until mark termination completes, at which point the answer is unambiguous.

go
// src/runtime/mheap.go:2446
	// Ensure the span is swept.
	span.ensureSwept()

	// Now we can trust whatever we get from handle, so make a strong pointer.
	//
	// Even if we just swept some random span that doesn't contain this object, because
	// this object is long dead and its memory has since been reused, we'll just observe nil.
	ptr := unsafe.Pointer(handle.Load())

	// This is responsible for maintaining the same GC-related
	// invariants as the Yuasa part of the write barrier. During
	// the mark phase, it's possible that we just created the only
	// valid pointer to the object pointed to by ptr. If it's only
	// ever referenced from our stack, and our stack is blackened
	// already, we could fail to mark it. So, mark it now.
	if gcphase != _GCoff {
		shade(uintptr(ptr))
	}
	releasem(mp)

That shade call is the subtle bit. By materialising a strong pointer to the object during the mark phase, we might have created the only reference to it, and if the stack frame that holds it has already been blackened, the object would never be marked and would be swept out from under us. Shading it fixes that. It is the same invariant the Yuasa write barrier maintains.

Trade-offs

What weak pointers buy you:

  • Caches that shrink. A map[K]weak.Pointer[V] does not pin V.
  • Canonicalisation tables. The unique package is built on them.
  • No resurrection semantics. Unlike finalizers, weak pointers never resurrect anything, so they cannot be used to resurrect objects by accident.
  • Cheap. A weak pointer is one word. A weak handle is one 16-byte allocation plus one special record.

What they cost:

  • One special record per weak-referenced object. Specials live in a per-span linked list, and a span full of weak pointers has a long list.
  • A handle allocation per object. The handle is padded to 16 bytes so it cannot be packed into a tiny block with unrelated objects.
  • Weak-to-strong conversion can block. During mark termination, a Value() call parks the goroutine.
  • Value() is not guaranteed to return nil. If the object was batched with a live neighbour, or the program exits first, the weak pointer may never observe the death of its target.

Caveats

Value() returning nil does not mean the object was freed. It means the object is no longer guaranteed to be alive. The runtime may keep the memory around; you simply may not use it.

Value() returning non-nil does not mean the object will stay alive. The moment Value() returns, the strong pointer it gave you is on your stack and the object is alive. But if you store that pointer somewhere and drop your last other reference, it can still die. Use runtime.KeepAlive if you need the object to survive across a call.

You cannot make a weak pointer to a stack variable. weak.Make forces the target to the heap, but if you hand it a pointer into a stack frame, the runtime throws getWeakHandle on invalid pointer.

A weak pointer to a global works, but via a different path. Globals have no span, so they go into the immortalWeakHandleMap, a lock-free append-only hash trie, and never become nil.

Weak pointers and finalizers interact in a specific order. The weak handle is cleared before the finalizer runs. If you were expecting to read a finalised object’s contents through a weak pointer, you will get nil.

Do not confuse weak.Pointer with runtime.SetFinalizer. Finalizers keep the object alive for one extra GC cycle and then resurrect it. Weak pointers do neither.

Wrapping up

The one thing to remember: a weak pointer stores the target’s address in an atomic.Uintptr — an integer, not a pointer — so the garbage collector never treats it as a reference. The GC scans the handle, not the target.

The part people get wrong: weak.Pointer.Value() is allowed to return non-nil forever. The runtime batches tiny pointer-free objects together to save space, and a batch containing a live object keeps its dead neighbours weakly reachable. unique copes with this; hand-rolled weak-map code usually does not.

What to go do about it: if you are building a cache with map[K]V and worrying that it grows without bound, that is exactly the shape weak pointers were made for. Store weak.Pointer[V] in the map, check Value() on lookup, and delete the entry when it comes back nil.

Further reading

  • src/weak/pointer.go
  • src/weak/doc.go
  • src/runtime/mheap.go
  • src/runtime/mgcmark.go
  • src/runtime/mgcsweep.go
  • src/runtime/mfinal.go
  • src/internal/abi/escape.go
  • src/unique/canonmap.go

GoInternalsGarbage collectionWeak pointersRuntime