noalloc lets you mark a Go function //noalloc:root and then checks that function and everything it calls for heap allocations, line by line. It reached the Hacker News front page last week. Its author says they wanted what OxCaml's zero_alloc checker gives you, and that "No allocator available isn't the same as being able to enforce no allocations" (eatonphil). Our view after one afternoon with it: it is the best way we have seen to keep a Go hot path allocation-free as the code changes. But its green is a claim about one kind of path under rules it states, and on a small logger two of those rules let a real cost and a wrong answer through. Keep a measured test for the bad inputs, and an output test, next to it.
The run
We built noalloc at commit d10d4c9 with Go 1.27.0 on an Apple Silicon Mac. The README's own example reproduces exactly: a map write and a string concatenation in Counter.Add, exit code 1.
Then we wrote a small flow of our own: an access logger that formats METHOD path status ms into a 64-byte buffer it keeps, using noalloc's bounded.Buf. Line formats one request and returns an error for a status outside 100 to 599. Batch formats a list and counts the bad ones. Both are marked as roots:
//noalloc:root
func (l *Logger) Line(method, path string, status, ms int) ([]byte, error) {
if status < 100 || status > 599 {
return nil, fmt.Errorf("status %d out of range", status)
}
l.line.Reset(l.mem[:], nil)
l.line.WriteString(method)
// ... path, status and ms, separated by spaces
return l.line.Bytes(), nil
}
noalloc -source github.com/theconsensuslabs/noalloc/bounded
cross-check: 1 allocating calls inlined from other packages and 0 on lines that also hold unchecked code were not compared; calls to runtime functions not known either way: map[chansend1:1 selectgo:1]
exit=0
Green, in 0.34 seconds on a warm cache. Then we measured the same code with Go's own testing.AllocsPerRun, and fed it a path longer than the buffer:
| Check | noalloc | Measured at run time |
|---|---|---|
Line with a good status | pass | 0 allocations |
Batch with 4 good statuses | pass | 0 allocations |
Batch with 3 bad statuses | pass | 6 allocations per call |
Line with a 61-character path | pass | 0 allocations, err=<nil>, line "GET " |
Two rows are green and wrong in different ways.
Finding 1: marking more roots made the check weaker
noalloc allows allocations on an error that ends the root's call, because that costs a fixed amount per call, not per item. The README is careful here: an error inside a loop is "checked like any other path, because it can happen again on the next item." That is the case in Batch, which calls Line once per status and keeps going.
But the same README also says "A root's own failures stay allowed however else it is reached." We had marked Line as a root because it is an entry point. So its fmt.Errorf stayed allowed, even when Batch called it in a loop. Three bad statuses cost six allocations, two per error, and noalloc passed.
We removed the //noalloc:root line from Line and ran it again:
access.go:21: (*example.com/flow.Logger).Line: calls fmt.Errorf; escapes: status escapes to heap
exit=1
Same code, one fewer annotation, and now the check catches it. This is documented, not a bug, and the README's own fix (a sentinel error made once) brought the measured count to zero. But it is the opposite of the usual instinct that more annotations mean a stricter check.
Finding 2: zero allocations, wrong output
bounded.Buf never grows. Its source says "A write that does not fit writes nothing and leaves the buffer full until it is truncated or reset", and the package comment adds that "a caller checks once, after the writes". Our 61-character path did not fit after GET , so the path, the status and the timing were all dropped. Line returned "GET " and a nil error, because we never called Full().
noalloc is right to pass this. It checks memory, and the memory was fine. The point is where the failure moved. An allocation the allocator would have handled became a value the caller has to check, and nothing in the zero-allocation check makes you check it. Our fix was three lines: return a sentinel errLong when Full() is true. The test that caught it was one strings.Contains on the output.
What it verifies, and what it does not
Verifies: every line reachable from a root, through your module and into the standard library, judged from source, from the compiler's escape output, and cross-checked against the compiled code. That is far more than AllocsPerRun, which only counts what the inputs you happen to pass actually run.
Does not:
- Count error paths it chose to allow. That includes a root's own errors when another root calls it in a loop.
- Check code that no root reaches. The README says so: "mark every entry point."
- Check that your output is complete. That is a correctness test's job.
- Stay fixed across Go releases. It parses
go build -gcflags=-m, which the author calls "not really a standard format" (eatonphil). Pin the toolchain, as the README says.
None of this is hidden. The README is unusually honest about its own allowances, and every surprise above is in it if you read closely. The practical rule we took away is simple. Run noalloc in CI for the paths that must not allocate. Next to it, keep one AllocsPerRun test that feeds those paths bad input, and one test that checks what they actually produced. The static check tells you the hot path is clean. The other two tell you that "clean" covered the inputs you care about, and that a clean path still gave the right answer.
Sources
- 01GitHub: theconsensuslabs/noallocgithub.com
- 02noalloc: bounded/buf.gogithub.com
- 03Hacker News: noalloc, tooling and libraries to enforce zero heap allocations in Golangnews.ycombinator.com
- 04Hacker News: eatonphil on parsing the compiler's escape outputnews.ycombinator.com
- 05Hacker News: eatonphil on enforcing no allocationsnews.ycombinator.com
- 06Go: testing.AllocsPerRunpkg.go.dev
- 07OxCaml: zero_alloc checkeroxcaml.org
// The agent clicked "Pay". Did the right things actually happen?
Example from the README
Your agent says it works. Reticle checks the network, state and console inside your running app, and names the line to fix.
85 of 88 seeded bugs caught in the public benchmark