The Perils of Pointers in the Land of the Zero-Sized Type

The Perils of Pointers in the Land of the Zero-Sized Type

31 May 2025

Imagine writing a translation function that transforms internal errors into public API errors. In the first iteration, you return nil when no translation takes place. You make a simple change — returning the original error instead of nil — and suddenly your program behaves differently:

translate1: unsupported operation
translate2: internal not implemented

This program prints two different results from the nearly identical functions (Go Playground). Can you spot why?

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type NotImplementedError struct{}

func (*NotImplementedError) Error() string {
	return "internal not implemented"
}

func Translate1(err error) error {
	if (err == &NotImplementedError{}) {
		return errors.ErrUnsupported
	}

	return nil
}

func Translate2(err error) error {
	if (err == &NotImplementedError{}) {
		return nil
	}

	return err
}

func main() {
	fmt.Printf("translate1: %v\n", Translate1(DoWork()))
	fmt.Printf("translate2: %v\n", Translate2(DoWork()))
}

func DoWork() error {
	return &NotImplementedError{}
}

The key to this divergent behavior lies in NotImplementedError being a zero-sized type (ZST). ZSTs are types that occupy no memory. Common examples include empty structs struct{} or a zero-length array [0]byte. While highly useful for tasks like signaling without data transfer (e.g., (chan struct{} in Context.Done()) or implementing sets (map[string]struct{}), their interaction with pointers can be particularly tricky.

So, why does this simple change lead to different outcomes?

Pointergeist: The Zero Dimension

To understand this behavior, let’s see what the Go compiler does behind the scenes.

The Great Pointer Escape

When we run go build -gcflags='-m=1' ., we get something like:

...
./main.go:14:17: err does not escape
./main.go:15:13: &NotImplementedError{} does not escape
./main.go:22:17: leaking param: err to result ~r0 level=0
./main.go:23:13: &NotImplementedError{} does not escape
./main.go:31:12: ... argument does not escape
./main.go:31:50: &NotImplementedError{} does not escape
./main.go:31:43: &NotImplementedError{} does not escape
./main.go:32:12: ... argument does not escape
./main.go:32:50: &NotImplementedError{} escapes to heap
./main.go:32:43: &NotImplementedError{} does not escape
./main.go:36:9: &NotImplementedError{} escapes to heap

The critical line is &NotImplementedError{} escapes to heap - this refers to the pointer created in the call to DoWork() in line 32. It escapes because the parameter in line 22 is a potential return value (return err), which is fed into fmt.Errorf.

Escape analysis determines whether variables can remain on the stack or must be allocated on the heap. When the compiler can’t prove a variable won’t outlive its function, it “escapes” to the heap.

Honey, I Shrunk the Alloc

When Go allocates memory for zero-sized types, it applies a clever optimization: the runtime returns a pointer to runtime.zerobase (see the mallocgc implementation), a single static variable shared by all heap-allocated ZSTs, instead of allocating new memory for each instance.

This means multiple &NotImplementedError{} instances that escape to the heap typically point to the same address.

On the other hand, the &NotImplementedError{} in the comparison in Translate2 does not escape. It gets the address of a (zero-sized) variable on the stack.

So in Translate2, the comparison err == &NotImplementedError{} involves two pointers that, due to one being on the stack and the other pointing to runtime.zerobase, have different memory addresses, thus evaluating to false.

By Design: The Equality Paradox

In Translate1, since the err parameter (which is another instance of &NotImplementedError{}) does not escape within this function, and the &NotImplementedError{} literal in the comparison also doesn’t escape, both are effectively stack-scoped. For stack-allocated ZSTs, the compiler may optimize by assigning them the same memory address, but this is not guaranteed. This contributes to the unreliability of direct pointer comparisons for ZSTs, as the outcome can vary depending on compiler decisions and optimizations.

This is not a bug — the Go language specification explicitly states that the equality of pointers to distinct zero-size variables is unspecified:

“pointers to distinct zero-size variables may or may not be equal.”

Depending on the path the compiler takes, our program’s behavior changes, making our logic unsound. The bug is in the program above, not the compiler.

You can easily validate the issue by making the error type non-zero-sized:

8
type NotImplementedError struct{ _ int }

The errors.Is Illusion: Escaped Zero-Sized Types All Look the Same

I can hear you saying “Yes, but you should compare errors with errors.Is.” You absolutely should. The example becomes a little more complex (Go Playground):

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type NotImplementedError struct{}

func (*NotImplementedError) Error() string {
	return "internal not implemented"
}

func Translate(err error) error {
	if errors.Is(err, &NotImplementedError{}) {
		return errors.ErrUnsupported
	}

	return err
}

func main() {
	fmt.Printf("translate1: %v\n", Translate(DoWork1()))
	fmt.Printf("translate2: %v\n", Translate(DoWork2()))
}

func DoWork1() error {
	return &NotImplementedError{}
}

func DoWork2() error {
	n := struct {
		NotImplementedError
		_ int // Makes this a non-zero-sized type
	}{}

	return &n.NotImplementedError // Returns pointer to embedded ZST
}

It’s important to understand that internally errors.Is first attempts a direct pointer comparison — the same pointer comparison as above when comparing concrete error values:

“An error is considered to match a target if it is equal to that target”

Arguments passed to functions that are not fully inlinable (like errors.Is), or whose lifetime cannot be determined at compile time to be confined to the caller’s stack, will usually escape to the heap. As mentioned earlier, the Go runtime frequently optimizes them to point to a single static address.

This makes errors.Is work more predictably for heap-allocated ZST pointers, but you’re still relying on an implementation detail rather than guaranteed behavior.

A New Linter of Hope

zerolint is a static analysis tool specifically designed to detect problematic usage patterns involving zero-sized types in Go. For example, it will flag the following:

go install fillmore-labs.com/zerolint@latest
zerolint .

/path/to/your/project/main.go:10:7: error interface implemented on pointer to zero-sized type "example.com/project.NotImplementedError" (zl:err)
/path/to/your/project/main.go:27:6: comparison of pointer to zero-size type "example.com/project.NotImplementedError" with error interface (zl:cme)
/path/to/your/project/main.go:35:6: comparison of pointer to zero-size type "example.com/project.NotImplementedError" with error interface (zl:cme)

Preventing Pointer Pandemonium

This tool helps avoid the errors discussed by catching potential issues before they lead to runtime bugs, saving debugging time and preventing unexpected behavior in production.

  1. Identifying Unreliable Comparisons: The linter flags comparisons of pointers to zero-sized types, such as errors.Is(err, &NotImplementedError{}). By highlighting these comparisons during development or in CI/CD pipelines, zerolint prompts developers to reconsider their logic. Instead of relying on pointer equality (which is unspecified for distinct ZSTs), developers are encouraged to use sentinel errors or reconsider whether a value type is more appropriate.

  2. Discouraging Pointer Receivers for ZSTs: The linter warns when methods are defined on pointer receivers for zero-sized types (e.g., func (*NotImplementedError) Error() string). While syntactically valid, using pointer receivers for ZSTs is often unnecessary and can lead to the kind of pointer-related confusion the article describes. Value receivers (func (NotImplementedError) Error() string) are usually clearer and avoid any ambiguity about pointer identity for ZSTs, as comparisons of values of these types (not pointers to them) are straightforward. zerolint encourages this safer practice.

  3. Promoting Idiomatic Go: By flagging these patterns, zerolint helps enforce idioms that lead to more robust and maintainable Go code. It steers developers away from relying on implementation details or unspecified behaviors of the compiler and runtime concerning ZST pointers.

The kinds of bugs arising from ZST pointer comparisons can be subtle and hard to reproduce, as they might depend on compiler versions or specific build conditions. zerolint catches these potential issues before they lead to runtime bugs.

In essence, zerolint helps developers adhere to best practices, such as those discussed next regarding struct embedding and those summarized in this post’s conclusion.

Practical Application: The Art of Embedding

Let’s look at some common scenarios, like struct embedding, where these principles and a tool like zerolint become particularly relevant.

Value Embedding: Saving Private Memory (With Free Methods)

Zero-sized defined types can group a set of methods for a default behavior or interface implementation.

A method call on a zero-sized receiver has the same runtime behavior as a pure function call, since there’s no instance data to pass on the stack. Passing a ZST as a parameter is essentially free at runtime. This is different for pointer, though, as we will see below.

Assuming we want to have a variant of afero.OsFs with the Remove method disabled, we would do:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"errors"
	"fmt"
	"unsafe"

	"github.com/spf13/afero"
)

var ErrInvalidOperation = errors.New("invalid operation")

type ReadOnlyFS struct {
	afero.OsFs  // Embedded value - 0 bytes overhead
}

func (ReadOnlyFS) Remove(name string) error {
	return ErrInvalidOperation
}

func main() {
	a := ReadOnlyFS{}
	fmt.Println(unsafe.Sizeof(a.OsFs))  // Prints: 0

	_, _ = a.Open("test.txt")  // Works perfectly
}

This prints 0, demonstrating that we get all the methods from afero.OsFs with zero memory overhead.

Common Pitfall: The Eight-Byte Burden & The Nil Panic

Now let’s see what happens when we use pointers instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"errors"
	"fmt"
	"unsafe"

	"github.com/spf13/afero"
)

var ErrInvalidOperation = errors.New("invalid operation")

type ReadOnlyFS struct {
	*afero.OsFs  // Embedded pointer - 8 bytes + nil pointer risk
}

func (*ReadOnlyFS) Remove(name string) error {
	return ErrInvalidOperation
}

func main() {
	a := &ReadOnlyFS{}
	fmt.Println(unsafe.Sizeof(a.OsFs))  // Prints: 8 (wasted space)

	_, _ = a.Open("test.txt")  // Panics! (nil pointer)
}

This approach has two problems:

First, adding a level of indirection to zero-sized types negates their primary memory benefit. A pointer to a ZST itself consumes memory (typically 8 bytes on a 64-bit system for the pointer itself), whereas the ZST value consumes zero bytes. Since the pointer could mostly be the address of runtime.zerobase or nil, we waste 8 bytes for very little information.

Second, the embedded pointer OsFs is nil because it’s uninitialized. While Go allows calling methods on nil receivers if the method is defined to handle nil (e.g., by checking if the receiver is nil), in this case, a.Open("test.txt") attempts to dereference the nil pointer (as Open has a value receiver), causing a runtime panic.

Interface Evolution: gRPC-Go Case Study

ZSTs are excellent for providing default implementations for interfaces, especially when embedded by value. Since an embedded ZST occupies 0 bytes, it adds no size to the surrounding struct, so there’s no overhead.

A prominent example of this is found in gRPC for Go, specifically in server implementations. To ensure forward compatibility, UnimplementedXXXServer structs are provided:

generated.go
// UnimplementedXXXServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedXXXServer struct{}

func (UnimplementedXXXServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
	return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
}
implementation.go
type XXXServer struct {
	UnimplementedXXXServer
}

func (XXXServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
	...
}

For some background see gRPC-Go’s issue 2318 and issue 3669.

The Tao of ZST: A Guide to Robust Go

Zero-sized types (ZSTs) are a Go feature offering memory efficiency and powerful design mechanisms. However, as we’ve seen, their interaction with pointers can lead to subtle, hard-to-diagnose bugs if not handled with care. Relying on specific compiler or runtime behaviors (like all escaped ZST pointers resolving to runtime.zerobase) for pointer comparisons is a path to fragile code.

To harness the benefits of ZSTs while avoiding the pitfalls, keep these core principles in mind:

  1. Embrace Values, Eschew Pointers: Whenever possible, use ZSTs as direct values (e.g., myZST) rather than pointers to them (&myZST). This approach completely avoids the ambiguity of ZST pointer comparisons.

  2. Value Receivers are Preferred: Define methods on value receivers (e.g., func (z MyZST) Method()) for ZSTs. Pointer receivers (e.g., func (z *MyZST) Method()) are rarely necessary for ZSTs and can reintroduce issues with pointer comparisons.

  3. Embed Values, Not Pointers: When composing structs, embed ZSTs directly (e.g., embed MyZST) to maintain their zero memory overhead benefit. Embedding pointers to ZSTs (embed *MyZST) negates this by adding pointer overhead and the risk of nil pointers.

  4. Be Mindful of ZSTs When Designing API Contracts: For example, use functional options instead of pointer-based configuration for zero-sized option structs.

  5. Mind API Boundaries: The primary legitimate use case for pointers to ZSTs arises when an external API you must interact with explicitly requires a pointer receiver or argument. In such instances, the choice is driven by compatibility.

  6. Leverage Static Analysis: You don’t have to navigate these nuances alone. Tools like zerolint are invaluable for automatically flagging problematic ZST pointer usage in your codebase, guiding you towards safer and more idiomatic Go practices.

The key insight is that zero-sized types are powerful as values but risky and wasteful as pointers. Understanding this distinction will save you from subtle, hard-to-debug issues that can vary between compiler versions and build conditions.