A look at theconsensuslabs/noalloc: how it reports heap allocations reachable from //noalloc:root functions, and how its jsongen tool uses go/types to generate reflection-free JSON.
· 8 min read

noalloc: Enforcing Zero Heap Allocations in Go with Static Analysis and Codegen


Most allocation-hunting in Go happens after the fact: run a benchmark, read -benchmem, guess at the culprit. theconsensuslabs/noalloc flips it. Mark a function //noalloc:root, run the checker, and it reports every line reachable from that function that allocates on the heap. For code where an unplanned make or fmt.Sprintf is a bug rather than a cost, that’s a very different relationship with the compiler.

Two parts of the repository are worth reading closely. The noalloc command is a static analysis CLI with an unusually well-thought-out adoption path. jsongen is a code generator that reads your types through go/types and emits JSON encoders with no reflection in sight.

What the noalloc command reports

From the package doc in cmd/noalloc/main.go:

Command noalloc reports each line that allocates on the heap in the code reachable from functions marked //noalloc:root.

Packages default to ./... and must all be in one module. The hard problem isn’t the first run on a greenfield package, it’s the first run on code that already exists, where the report is long enough to be useless as a gate. That’s what -baseline is for: with a baseline file, only allocations missing from it fail the run, and a baseline line that no longer allocates also fails.

That second rule is the one that matters. Plenty of lint-suppression systems let you accumulate a file of excuses that nobody ever revisits. Here, fixing an allocation without deleting its baseline line breaks the build, so the file can’t drift out of sync with reality.

The baseline header, written by the command, states the contract:

# Allocations noalloc reports that are not fixed yet, as
# file | function | reason | the line's Go tokens.
# Delete the lines you fix, or run noalloc -prune. Never add one.

Three flags manage that file: -start writes the baseline from what’s found now and refuses if the file already exists, -prune deletes lines that no longer allocate, and -restate updates lines whose statement was reworded. run rejects combinations that make no sense. All three require -baseline, and passing more than one at a time exits with code 2.

Escape hatches live in a separate file. A //noalloc:exempt comment covers a finding, and -exemptions holds those comments to a checked-in list you regenerate with -write-exemptions. -kinds restricts the word an exemption’s reason must start with before a colon. -line-only names the kinds that cover their own line but not what that line calls, which is the distinction that keeps a policy from rotting: an exemption that silently swallows an entire call tree is indistinguishable from no policy at all.

Two more flags are specific to allocation analysis. -scratch names a type whose slice and map fields are reused memory, written as pkg/path.Name, so writes into them aren’t counted as fresh allocations. -allow adds files of functions outside the module to treat as allocating nothing, each line formatted as "kind: function". Those get appended to a built-in list the module exports as a string:

allowed := strings.Split(noalloc.Allowlist, "\n")

Shipping the allowlist as a newline-delimited string rather than a slice literal is a small trick worth stealing. It stays readable in the source, costs nothing to embed, and parses in one line. There’s also -report, which prints every finding with the call path that reaches it, plus every exemption, for when the one-line-per-finding output isn’t enough to tell you why.

A testable main with repeatable flags

cmd/noalloc/main.go uses a pattern more Go CLIs should copy:

func main() {
	os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

func run(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("noalloc", flag.ContinueOnError)
	fs.SetOutput(stderr)
	// ...
}

main holds no logic. Everything is in run, which takes its arguments and its writers as parameters and returns an exit code. A test can call run([]string{"-baseline", "x"}, &buf, &buf) and assert on the output and the status together, with no global state and no subprocess.

Repeatable flags come from a three-line flag.Value:

type list []string

func (l *list) String() string     { return strings.Join(*l, ",") }
func (l *list) Set(v string) error { *l = append(*l, v); return nil }

fs.Var(&scratch, "scratch", ...) then accepts -scratch any number of times, appending each. If you’ve only ever reached for flag.String and flag.Bool, this is the part of the flag package that opens up the rest of it.

jsongen: encoding JSON without reflection

encoding/json allocates. json.Marshal hands back a freshly allocated byte slice, and reflection-driven encoding allocates on the way there. cmd/jsongen moves the work to build time:

jsongen [-write T1,T2] [-read T3] [-o json_gen.go] [dir]

Each type named after -write gets a WriteJSON method, each after -read a ReadJSON method, and so does every struct type of the package they hold. Generated code calls into github.com/theconsensuslabs/noalloc/encoding/json, imported as nojson (the path lives in the runtimePath constant in cmd/jsongen/gen.go).

A slice of real generated output, from encoding/json/internal/gentest/json_gen.go:

//noalloc:root
func (v *Page) WriteJSON(w *nojson.Writer) {
	if !w.Enter("Page") {
		return
	}
	w.Raw("{\"id\":")
	b1, err2 := v.Base.ID.AppendText(w.TextBegin())
	w.TextEnd(b1, err2, "Page.ID")
	w.Raw(",\"created\":")
	w.Time(v.Base.Created, "Page.Created")
	w.Raw(",\"title\":")
	w.String(string(v.Title))
	if !(len(v.Tags) == 0) {
		w.Raw(",\"tags\":")
		// ...
	}
	w.Raw("}")
	w.Leave()
}

Several design decisions show through. The methods return nothing: failures travel with the Writer, and calls that can fail take a constant label like "Page.Created", so a later error can name the field without building a string to do it. Every value is converted explicitly (string(v.Title), int64(v.Count), float64(v.F32)), which means a defined type like type Title string needs no special case in the generator. omitempty compiles to if !(len(v.Tags) == 0), omitzero to if !(v.Zero == (Base{})), both evaluated at the field’s concrete type. And the methods carry //noalloc:root themselves, so the checker polices the generator’s own output. That last one is the detail I’d want in any codegen tool that promises a performance property.

Constant punctuation gets coalesced. gen.go buffers it in a raw field and flushes it as a single w.Raw(%q) call the moment non-constant output needs emitting, which is why the generated file contains w.Raw("],\"root\":") instead of three separate calls.

Porting encoding/json’s field rules to go/types

The hard part of generating a JSON encoder isn’t the output. It’s deciding which fields encode. cmd/jsongen/fields.go says so in its header: fieldsOf, validName, and hasOption adapt typeFields, dominantField, isValidTag, and tagOptions.Contains from Go’s encoding/json to go/types.

The structure survives the port largely intact. fieldsOf walks embedded structs breadth-first, one depth level at a time, with a visited map keyed by types.TypeString so a cycle can’t loop forever. Then it sorts and applies the dominance rules: shallowest field with a given name wins, a tagged field beats an untagged one at equal depth, and two left tied cancel out so neither encodes. The handling of the ambiguous case is clever. When the same struct is embedded twice at one depth, the code appends a second copy of the field so the deduplication pass downstream drops both.

Some of it is work reflection never makes you think about. Pointer fields unwrap to their element type, but only after types.Unalias confirms the pointer isn’t to a named type. Tags are read with reflect.StructTag(at.st.Tag(i)).Get("json"), so reflect still earns its import even when the types come from the type checker.

The code reads as current Go: for i := range at.st.NumFields() ranging over an int, slices.Clone, slices.SortStableFunc, cmp.Compare.

The overlay trick for regenerating in place

Regenerating a file into the package you’re type-checking creates a chicken-and-egg problem. The old json_gen.go defines WriteJSON methods. If the type checker sees them, the generator can’t tell which methods the package genuinely owns from the ones it wrote last time.

Generate handles it with packages.Config.Overlay:

outPath := filepath.Join(abs, out)
if old, err := os.ReadFile(outPath); err == nil {
	if m := regexp.MustCompile(`(?m)^package \w+`).Find(old); m != nil {
		cfg.Overlay = map[string][]byte{outPath: append(m, '\n')}
	}
}

Read the existing file, pull out the package clause, overlay that as the file’s whole contents. golang.org/x/tools/go/packages then type-checks a package in which the output file is empty but still a member. The source comment says it plainly: the file is read as an empty file of the package “so methods it held before are not taken for the package’s own.”

The generator is also deliberately forgiving about broken input. Type errors get printed as warnings and the run continues, bailing only when pkgs[0].Types == nil. The reasoning, again from the source: a package mid-change that calls methods this run is about to write still carries the types those methods are written from, and compiling the output catches whatever’s left.

Recursive and mutually referencing types go through two worklist queues and two map[*types.Named]bool sets (writeQueue/writeDone, readQueue/readDone). The main loop drains writes before reads, appending each method’s buffer to the body as it goes. Errors accumulate in a []string with slices.Contains guarding against duplicates, so a single run names every unsupported type instead of stopping at the first.

What jsongen refuses

The doc comment in cmd/jsongen/main.go lists what the generator won’t touch, and every item traces back to allocation behavior. Maps, interfaces, channels, functions, complex numbers, and generic types are out, with a struct instance like type Jobs List[Job] standing in for the generic case. Types with MarshalJSON, UnmarshalJSON, or only MarshalText are out because the result is memory the method allocates; give those types WriteJSON and ReadJSON methods, or an AppendText method for writing. Types with AppendText and no MarshalText are out because encoding/json writes them as text only with the jsonv2 experiment. For reading, add byte slices, embedded pointers, and ",string" fields to the refusal list.

The MarshalJSON rule is the one to carry into your own code. An API shaped ([]byte, error) offers no caller-supplied destination, so typical encoders allocate their result. An append signature, AppendText(b []byte) ([]byte, error), lets the caller provide capacity and can avoid that allocation when the value fits. That’s what the generated code does: w.TextBegin() supplies a bounded buffer, AppendText fills it, and w.TextEnd accepts the result only if it stayed within that storage.

Reading diverges from encoding/json at precisely the points where encoding/json would allocate. A nil pointer, a slice past its capacity, and a string longer than the Reader’s Arena can supply memory for all fail with Full. A time.Time decodes as the same instant in UTC, dropping the offset it was written with. Those are genuine behavior differences, documented rather than glossed over, which is the right call: a generator that quietly diverged from the standard library here would be a trap.

The shape of the whole thing is instructive if you’re building anything that enforces a property rather than a style: an analysis pass, a baseline with a rule against stale entries, a generator whose output is subject to the same analysis, and an explicit list of what you don’t support. For the other end of the problem, what the runtime does when you do allocate, the Go 1.27 size-specialized allocation changes pair well with this.