{"slug": "noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang", "title": "noalloc: Tooling and libraries to enforce zero heap allocations in Golang", "summary": "The Consensus Labs released noalloc, an experimental Go tool that checks an entire call tree for heap allocations from functions marked with a //noalloc:root directive, exiting 1 when it finds an allocation and 2 when it cannot run. The tool requires Go 1.27 or later and ships with the bounded package, which provides four fixed-capacity containers — Buf, Vec, Slice and Map — plus a JSON encoder and decoder, and it is used in production at The Consensus in both its web service and code search engine. The repository and README were written entirely by AI under human guidance, and the project is described as experimental and subject to large changes.", "body_md": "*Disclaimer: This repo is entirely written by AI. The README is\nlargely written by AI. It is guided by a human and used in production\nat The Consensus in both the web service and our code search engine. It\nis experimental and may change vastly.*\n\nIt's one thing to be able to write some code that doesn't allocate on the heap. It's another thing to be able to keep code from not allocating as the code changes. With noalloc you can assert Golang functions should not allocate and the noalloc CLI will check the entire call tree from that function for heap allocations.\n\nnoalloc will tell you every line that allocates, allowing you to remediate or exempt lines. noalloc also provides zero-allocating, fixed-sized data structures and algorithms (including a JSON encoder and decoder) to make it easier and cleaner to write code that does not allocate.\n\n```\ngo install github.com/theconsensuslabs/noalloc/cmd/noalloc@latest\n```\n\nYou'll need Go 1.27 or later.\n\nFor example, mark a function with `//noalloc:root` and run noalloc in its\nmodule.\n\n```\n//noalloc:root\nfunc (c *Counter) Add(name string, n int) string {\n\tc.counts[name] += n\n\treturn name + \"=\" + strconv.Itoa(c.counts[name])\n}\nbash\n$ noalloc\ncounter.go:11: (*example.com/counter.Counter).Add: map write\ncounter.go:12: (*example.com/counter.Counter).Add: calls strconv.Itoa; string concatenation; escapes: name + \"=\" + ~r0 escapes to heap\n```\n\nnoalloc exits 1 (it found an allocation) or 2 (it couldn't run at all). It allows allocations on an error or panic that ends the root's call and in first-use setup, which the caveats below cover.\n\n- `//noalloc:root` checks a function and everything it calls.\n- `//noalloc:exempt <reason>` exempts the line it's on and everything that\nline calls. On a line by itself, it applies to the next line.\n- `//noalloc:fixed` on a type says its methods never grow its slices, so\nnoalloc treats them as reused memory.\n- `//noalloc:append` marks a function that works like`append` , so a call to\nit is judged like a call to`append` .\n\nReused memory is memory kept between requests, such as a buffer in a\nlong-lived struct. An append to it is only allowed after a comparison proves\nthere's room. You can also mark a type's fields as reused memory with\n`-scratch pkg/path.Type`. A call to a function outside your module is\nallowed when that function's own code allocates nothing, which noalloc works\nout from its source, or from its compiled code when it's written in assembly\nor is part of the runtime. Packages you check from source with `-source`,\nwhich takes an import path prefix, are checked as part of your module\ninstead. Check noalloc's own libraries that way:\n`-source github.com/theconsensuslabs/noalloc/bounded`, and the same for\n`encoding/json`, so their `//noalloc:fixed` types and other directives count,\nwhich they don't in code outside your module. Run `noalloc -h` for every flag.\n\n[bounded](https://github.com/theconsensuslabs/noalloc/blob/main/bounded) has four containers with a fixed maximum capacity: `Buf`,\n`Vec`, `Slice`, and `Map`. When a write exceeds it, the write does nothing and\nmarks the container full, so you can check once at the end instead of\nafter every write. A container's memory can be your own, or borrowed from a\n`Pool` through a `Region`. These examples are also in\n[example_test.go](https://github.com/theconsensuslabs/noalloc/blob/main/bounded/example_test.go), where `go test` checks their\noutput.\n\nA `Buf` is a byte buffer. Give it memory with `Reset`, and once a write doesn't\nfit it stays full:\n\n``` js\nvar b bounded.Buf\nb.Reset(make([]byte, 32), nil)\nb.WriteString(\"id=\")\nb.WriteInt(42)\nfmt.Println(string(b.Bytes()), b.Full()) // id=42 false\n\nb.WriteString(strings.Repeat(\"x\", 40))\nfmt.Println(string(b.Bytes()), b.Full()) // id=42 true\n```\n\nGive it a writer too and it flushes to the writer instead of filling up:\n\n``` js\nvar b bounded.Buf\nb.Reset(make([]byte, 8), os.Stdout)\nb.WriteString(\"hello, \")\nb.WriteString(\"world\\n\") // flushes \"hello, \" first\nb.Flush()                // prints hello, world\n```\n\nA `Vec` is a list of any type. With memory you give it, it holds as many items\nas that memory's capacity:\n\n``` js\nvar v bounded.Vec[int]\nv.Reset(make([]int, 0, 3))\nfor i := range 5 {\n\tv.Push(i * i)\n}\nfmt.Println(v.Items(), v.Full()) // [0 1 4] true\n```\n\nWith `ResetFrom` it can borrow chunks from a pool as it fills, up to the\nregion's budget. Its items aren't contiguous, so read it with `At`, or\na chunk at a time with `Chunks`:\n\n``` js\npool := bounded.NewPool[int](4, 8)\nvar r bounded.Region\nr.Reset(2 * pool.ChunkBytes())\ndefer r.Release()\n\nvar v bounded.Vec[int]\nv.ResetFrom(&r, pool)\nfor i := range 10 {\n\tv.Push(i)\n}\nfmt.Println(v.Len(), v.Full(), v.At(6)) // 8 true 6\nfor c := range v.Chunks() {\n\tfmt.Println(c) // [0 1 2 3], then [4 5 6 7]\n}\n```\n\nA `Slice` is contiguous. When it fills a chunk, it can move to a chunk\nfrom the next larger pool, the way `append` moves a slice to a bigger array:\n\n```\nclasses := bounded.NewClasses(bounded.NewPool[int](4, 8), bounded.NewPool[int](16, 8))\nvar r bounded.Region\nr.Reset(1 << 10)\ndefer r.Release()\n\nvar s bounded.Slice[int]\ns.ResetFrom(&r, classes)\nfor i := range 10 {\n\ts.Push(i)\n}\nfmt.Println(s.Items(), s.Cap()) // [0 1 2 3 4 5 6 7 8 9] 16\n```\n\nA `Map` holds up to the number of entries you reset it for. A new key past\nthat isn't added, but you can still update the keys it has:\n\n``` js\nvar m bounded.Map[string, int]\nm.Reset(2)\nm.Set(\"a\", 1)\nm.Set(\"b\", 2)\nfmt.Println(m.Set(\"c\", 3), m.Full()) // false true\n\nv, ok := m.Get(\"a\")\nfmt.Println(v, ok, m.Len()) // 1 true 2\n```\n\nWith `Grow` it starts small and can move to larger pools of slots as it fills:\n\n```\nmaps := bounded.NewMapClasses(bounded.NewMapPool[string, int](4, 8), bounded.NewMapPool[string, int](64, 8))\nvar r bounded.Region\nr.Reset(1 << 20)\ndefer r.Release()\n\nvar m bounded.Map[string, int]\nm.Grow(&r, maps)\nfor i := range 20 {\n\tm.Set(strconv.Itoa(i), i)\n}\nfmt.Println(m.Len(), m.Limit()) // 20 64\n```\n\nA `Pool` lends a fixed number of chunks. When they're all out, `Take` returns\nnil until a region gives one back:\n\n``` js\npool := bounded.NewPool[byte](1<<10, 2)\nvar a, b bounded.Region\na.Reset(4 << 10)\nb.Reset(4 << 10)\n\nbounded.Take(&a, pool) // a gets the first chunk\nbounded.Take(&a, pool) // and the second\nbounded.Take(&b, pool) // nil: the pool is empty\na.Release()\nbounded.Take(&b, pool) // b gets one of a's\nb.Release()\n\nst := pool.Stats()\nfmt.Println(st.Lent, st.Empty) // 3 1\n```\n\nUse `Wait` instead of `Take` to wait for a chunk until a channel closes, and\n`Reserve` to take several at once or none.\n\nA request's input size isn't known until the input arrives, so give each\nrequest a `Region` with a fixed budget when it starts. Within that budget it\nborrows chunks from a shared `Pool` as its input calls for them, and\nreleasing the region when the request ends gives them all back:\n\n``` js\nvar rows = bounded.NewPool[byte](16<<10, 64)\n\n// nowOnly starts closed, so Reserve takes a free chunk and never waits.\nvar nowOnly = func() chan struct{} { c := make(chan struct{}); close(c); return c }()\n\nfunc (p *page) serve(w io.Writer) error {\n\tp.region.Reset(4 * rows.ChunkBytes())\n\tdefer p.region.Release()\n\tif !bounded.Reserve(nowOnly, &p.region, rows, 1) {\n\t\treturn errBusy\n\t}\n\tp.out.ResetFrom(&p.region, rows, w)\n\tfor p.next() {\n\t\tp.writeRow(&p.out)\n\t}\n\tp.out.Flush()\n\treturn p.out.Err()\n}\n```\n\nHere the page streams through one chunk that flushes as it fills, so a long\npage needs no more memory than a short one. Output that must be held whole\ncan call `Buf.Borrow` instead, which moves the buffer to a larger chunk each\ntime it fills, until the region's budget runs out.\n\nEvery site that can exhaust its memory budget needs a plan for when it does. For example, you might:\n\n- End the page early and let the client resume from a cursor.\n- Skip an optimization when the slower path still gives the right answer.\n- Forget an entry in a memo.\n- Refuse input that's too large to parse before you start parsing it.\n\nStream output through a `Buf` that flushes to the response, so its memory\ndoesn't grow with the response. Reserve what a request needs to start when\nadmission control admits it, and size the pool to hold every request admitted\nat once.\n\n[encoding/json](https://github.com/theconsensuslabs/noalloc/blob/main/encoding/json) encodes and decodes JSON without allocating,\nthe first call included, through methods its `jsongen` command writes for\nyour types:\n\n```\n//go:generate go run github.com/theconsensuslabs/noalloc/cmd/jsongen -write Reply -read Request\n```\n\n`WriteJSON` writes through a `Writer`, into memory you give it and keep\nacross calls, flushing to an `io.Writer` as that fills:\n\n``` js\nvar w json.Writer\nw.Reset(mem, out)\nreply.WriteJSON(&w)\nif w.Flush() != nil {\n\treturn errReply\n}\n```\n\n`ReadJSON` reads through a `Reader`, from memory or from an `io.Reader`\nthrough a buffer you give it. Strings decode into the Reader's `Arena`, over\nyour memory, which calls its `More` for another chunk when it fills, and\nstay valid until you reuse that memory. A slice decodes into the capacity it\nhas and a pointer into what it points at; past either, the decode fails\nwith `Full` rather than allocating:\n\n``` js\nreq := Request{Tags: make([]string, 0, 16)}\nvar r json.Reader\nr.Arena.Reset(stringMemory)\nr.ResetBytes(body)\nreq.ReadJSON(&r)\nif r.Finish() != nil {\n\treturn errRequest\n}\n```\n\nA `Writer` or `Reader` can live in a local, and allocates nothing there,\nbut the memory you give it can't: `Reset` keeps the slice, which moves an\narray behind it to the heap. The error `Err`, `Flush`, and `Finish` return\npoints into the `Writer` or `Reader`, so compare it with nil, as above,\nrather than return or log it, which moves the whole value to the heap.\n\nWhat `WriteJSON` writes decodes, with the standard library's\n`encoding/json`, to what `json.Marshal`'s output decodes to, and `ReadJSON`\ndecodes what `json.Unmarshal` does, except that a time keeps its instant but\ndecodes in UTC. `jsongen` refuses types it can't encode or decode without\nallocating, such as maps and interfaces, and says why. A type can write its\nown `WriteJSON` or `ReadJSON` instead, and\n[encoding/json/jsontest](https://github.com/theconsensuslabs/noalloc/blob/main/encoding/json/jsontest) checks either kind against\nthe standard library's by the round trip.\n\nHere is the code `jsongen` writes next to the standard library and four\nother libraries, each on its own fastest documented path: encoding and\ndecoding a feed of 500 items, about 130 KB, and a small object the size of\na session cookie, decoding each into a value it reuses. Every library\ndecodes both to the value `encoding/json` does.\n\nEach figure is CPU seconds, user and system, for 3,000 feed or 3,000,000 cookie operations in a process of the library's own, so it includes garbage collection and a fixed setup cost. These are medians of four alternating rounds on an Apple M4 with 10 cores and Go 1.27.0, at a load average between 18 and 37.\n\n| Library | Feed encode (s) | Feed decode (s) | Cookie encode (s) | Cookie decode (s) | Allocations per feed decode | \n|---|---|---|---|---|---|\n| goccy/go-json v0.11.2 | 0.64 | 0.74 | 0.68 | 0.67 | 4 | \n| noalloc's `jsongen` | 0.87 | 1.16 | 0.75 | 1.08 | 0 | \n| go-faster/jx v1.2.0 | 0.98 | 1.54 | 0.80 | 1.13 | 3,500 | \n| mailru/easyjson v0.9.0 | 0.97 | 1.77 | 0.87 | 1.88 | 4,001 | \n| bytedance/sonic v1.15.5 | 1.52 | 1.04 | 1.89 | 1.82 | 3 | \n| `encoding/json/v2` | 1.58 | 2.63 | 1.82 | 2.11 | 3,196 | \n| `encoding/json` | 1.52 | 2.94 | 1.77 | 2.65 | 1,696 | \n\nsonic's fastest path is built for amd64, so on the M4, an arm64 machine, these numbers likely understate it.\n\n[html/template](https://github.com/theconsensuslabs/noalloc/blob/main/html/template) has append versions of six of the sixteen\nescapers `html/template` picks among: the two for HTML text and attribute\nvalues, the URL filter, normalizer, and escaper, and the step that finishes\na value in a script, given that value already encoded as JSON. It doesn't\nparse or execute templates, or escape CSS, JavaScript strings, comments, or\n`srcset`. [net/url](https://github.com/theconsensuslabs/noalloc/blob/main/net/url) has an append version of `url.QueryEscape`.\nEach writes the bytes its standard library counterpart returns, and\nallocates only when the slice you pass must grow.\n\nThese examples share these declarations:\n\n```\nvar (\n\tpool    = bounded.NewPool[byte](4<<10, 8)\n\tclasses = bounded.NewClasses(bounded.NewPool[int](16, 8), bounded.NewPool[int](256, 8))\n\tr       bounded.Region\n\tb, next bounded.Buf\n\ts       bounded.Slice[int]\n)\n```\n\nA `Vec` or `Slice` panics if you use it after its region is released. A `Buf`\nor `Map` can't tell, and reads whatever the next borrower wrote:\n\n```\nr.Reset(1 << 20)\nb.ResetFrom(&r, pool, nil)\nb.WriteString(\"alice's page\")\nr.Release()\nr.Reset(1 << 20)\nnext.ResetFrom(&r, pool, nil)\nnext.WriteString(\"bob's page..\")\nfmt.Printf(\"%q\\n\", b.Bytes()) // \"bob's page..\"\n```\n\nRelease a region only once the request is done with everything it borrowed,\nwhich a `defer` right after `Reset` does. `-race` can catch a request reading\nmemory another request has borrowed.\n\nWhen a `Slice`, or a `Buf` that can borrow, reaches its capacity, it can move\nto a larger chunk and return the old one to its pool. A slice you got from `Items` or `Bytes`\nbefore the move still points at the old chunk:\n\n```\ns.ResetFrom(&r, classes)\ns.Push(1)\nfirst := s.Items()\ns.Extend(make([]int, 100)) // moves to a larger chunk\nfirst[0] = 2               // writes to a chunk s gave back\n```\n\nCall `Items` or `Bytes` again after each write.\n\nA region holds on to the chunks it has while it waits for the rest. So two regions that each need both chunks of a two-chunk pool can each take one and deadlock:\n\n``` js\ntwo := bounded.NewPool[byte](64, 2)\nfor range 2 {\n\tgo func() {\n\t\tvar r bounded.Region\n\t\tr.Reset(1 << 20)\n\t\tbounded.Reserve(nil, &r, two, 2) // can deadlock\n\t\tr.Release()\n\t}()\n}\n```\n\nReserve only once a request is admitted, and size the pool to hold every reservation the admitted requests can hold at once.\n\nBorrowing and giving back each take a lock, so borrow once per request rather than once per item:\n\n```\nfor _, line := range lines {\n\tr.Reset(1 << 20)\n\tb.ResetFrom(&r, pool, w)\n\tb.WriteString(line)\n\tb.Flush()\n\tr.Release()\n}\n```\n\nA `Pool` is safe to share between goroutines but a `Region` isn't. `-race` can\ncatch this:\n\n```\nr.Reset(1 << 20)\nfor range 4 {\n\tgo bounded.Take(&r, pool) // races on r\n}\n```\n\nThe first call to `Done` on a cancelable context allocates its channel:\n\n```\nbounded.Reserve(ctx.Done(), &r, pool, 1) // one allocation\n```\n\nnoalloc counts this as [first-use setup](#first-use-setup) and doesn't\nreport it, but every new context allocates its own. Pass a channel you make\nonce instead, such as one your server closes on shutdown.\n\nA pool only zeroes a chunk that comes back if the chunk's type holds pointers. Otherwise you get whatever the last borrower left in it:\n\n```\nfirst := bounded.Take(&r, pool)\nfirst[0] = 'x'\nr.Release()\nr.Reset(1 << 20)\nagain := bounded.Take(&r, pool)\nfmt.Printf(\"%q\\n\", again[0]) // 'x'\n```\n\nClear a chunk yourself if you need zeros.\n\nAn exemption covers everything the line calls. Here noalloc never looks inside\n`format`, so nothing it allocates gets reported:\n\n```\n//noalloc:root\nfunc (p *page) render(w io.Writer) {\n\tp.format(w) //noalloc:exempt response: the page\n}\n```\n\nTo exempt only the line itself, pass the reason's kind (here `response`) to\n`-line-only`.\n\nAn exemption still shows what it lets through. `-report` lists, under each\none, the allocations on its line and in what only its line calls, and the\nlist `-write-exemptions` writes holds them too, indented under the exemption,\nso a change in what an exemption covers fails the check until you review it\nand write the list again. An exemption whose line is reached but lets\nnothing through fails as well: delete it.\n\nnoalloc judges a function with no Go body by its compiled code: it follows the calls in it to the runtime's allocator. A call through a register can't be followed, so a function that makes one counts as allocating:\n\n```\nfunc sum(xs []int) int // written in sum_arm64.s\n\n//noalloc:root\nfunc Total(xs []int) int { return sum(xs) } // reported only if sum's code can reach the allocator\n```\n\nWhen noalloc can't follow a function you know allocates nothing, list it as\n`measured` and test it with `alloctest.Zero`. It runs the function through\n`testing.AllocsPerRun`, fails when the count isn't zero, and skips the test\nunder `-race`, which allocates for its own bookkeeping:\n\n```\nfunc TestSumAllocatesNothing(t *testing.T) {\n\txs := []int{1, 2, 3}\n\talloctest.Zero(t, \"sum\", func() { sum(xs) })\n}\n```\n\nnoalloc works out what each function outside your module allocates from the\ncode your build compiles, and caches it in a `noalloc` directory in your user\ncache, one file for each build. The first run on a new toolchain, platform,\nset of build tags, or set of dependency versions works it out again, and\ntakes longer than later runs: on The Consensus's server, 39 seconds against\n4. A function it can't follow, such as one that calls through a function\nvalue, counts as allocating, and `-report` gives the chain of calls that led\nto the allocation under each finding:\n\n```\ncounter.go:12: (*example.com/counter.Counter).Add: calls strconv.Itoa; string concatenation; escapes: name + \"=\" + ~r0 escapes to heap\n    because strconv.Itoa calls internal/strconv.Itoa at number.go:215 > internal/strconv.Itoa calls internal/strconv.FormatInt at itoa.go:53 > internal/strconv.FormatInt calls internal/strconv.formatBits at itoa.go:47 > internal/strconv.formatBits: string([]byte) conversion at itoa.go:146\n```\n\nnoalloc parses the output of `go build -gcflags=-m`, and that output can change\nbetween Go releases. Pin your toolchain in `go.mod`, and rerun noalloc when you\nupgrade:\n\n```\ngo 1.27.0\n\ntoolchain go1.27.0\n```\n\nnoalloc trusts its allowlist, `//noalloc:fixed`, and every exemption without\nverifying them. The allowlist is a built-in [list](https://github.com/theconsensuslabs/noalloc/blob/main/allow.txt) plus the files\nyou pass with `-allow`, and it only overrides what noalloc works out: each line\nnames a function and why it can be taken to allocate nothing even though its\ncode doesn't show it. The reason is one of these:\n\n- `amortized` : it allocates only to grow memory the runtime keeps and reuses,\nsuch as the cache a blocked`sync.Mutex` takes its waiter from.\n- `measured` : its code can't be followed, and a test that calls it counts no\nallocation.\n- `debug` or`profiling` : it allocates only on a path that runs to debug or\nprofile.\n\n```\nmeasured: example.com/fmtx.Format\n```\n\nThe built-in list takes `sync`'s locks and wait groups as `amortized`.\n`-report` lists every line and says which ones no check relied on. For a\n`measured` line, write the test:\n\n```\nfunc TestFormatAllocatesNothing(t *testing.T) {\n\tbuf := make([]byte, 0, 64)\n\talloctest.Zero(t, \"fmtx.Format\", func() { fmtx.Format(buf[:0], 42) })\n}\n```\n\nWhen noalloc can't tell which function a function value refers to, it reports\nthe call. [Interface calls](#interface-calls) have a section of their own.\n\n```\n//noalloc:root\nfunc Run(f func(int) int, n int) int {\n\treturn f(n)\n}\nbash\n$ noalloc\nrun.go:5: example.com/app.Run: call with no known callee: f\n```\n\nTo fix this, call a concrete function or type instead, or exempt the call.\n\nnoalloc checks every method an interface call can reach.\n\nIf your module shows the value's concrete type, only that type's method is checked. noalloc finds this with VTA (variable type analysis), which follows what gets assigned to locals and fields.\n\n``` js\n//noalloc:root\nfunc Area(n int) int {\n\tvar s Shape = Square{n}\n\treturn s.Area() // Only Square.Area is checked.\n}\n```\n\nOtherwise, as with a root's parameter, every implementation noalloc can find is checked. Your module's are checked where they're defined. Those outside your module are checked by their code, and the call is reported if any of them allocates.\n\n```\n//noalloc:root\nfunc Send(w io.Writer, p []byte) (int, error) {\n\treturn w.Write(p)\n}\nbash\n$ noalloc\nsend.go:5: example.com/app.Send: calls io.Writer.Write\n```\n\nHere `(*bytes.Buffer).Write` can grow its buffer. A call to `Err` on a\n`context.Context` passes, since no implementation of it in the standard\nlibrary allocates.\n\nA method nothing implements is reported as a `call with no known callee`. A\nmethod call on a type parameter is checked against the parameter's\nconstraint.\n\nnoalloc allows:\n\n- Type assertions and type switches.\n- Converting a value to an interface, if the value stays on the stack or is zero-size, a single byte, or a bool.\n- Passing your own type to code outside your module as an interface.\n`sort.Sort(byLen(xs))` checks`byLen` 's`Len` ,`Less` , and`Swap` .\n\nLimits:\n\n- Only packages your module imports are searched for implementations. A caller outside your module can pass one from somewhere else, and adding an import anywhere in your module can change what a call is checked against.\n- Every type in your module that implements the interface is checked, even one no caller passes.\n- An unnamed interface, such as `interface{ Do() int }` , and`error` have no\nimplementations noalloc can look up outside your module. Calls to them are\nreported, as in`calls error.Error` .\n- A call through a method value, as in `f := s.Area` then`f()` , is reported\nas a`call with no known callee` .\n\nTo allow a reported interface call, exempt the line or add the call to an\nallowlist file passed with `-allow`:\n\n```\nmeasured: io.Writer.Write\n```\n\nnoalloc only checks code that a root reaches. Nothing calls this function from a root, so noalloc never looks at it:\n\n```\nfunc Handle(w io.Writer) {\n\tfmt.Fprintf(w, \"%d\", 1)\n}\n```\n\nSo mark every entry point with `//noalloc:root`.\n\nnoalloc allows allocations on a failure that ends the root's call: a path that panics, or returns a non-nil error that reaches the root's return.\n\n```\n//noalloc:root\nfunc Parse(s string) (int, error) {\n\tif s == \"\" {\n\t\treturn 0, fmt.Errorf(\"empty input\") // noalloc allows this\n\t}\n\treturn len(s), nil\n}\n```\n\nA failure that ends the call costs it a fixed number of allocations, however much input the call reads. One that doesn't end the call is checked like any other path, because it can happen again on the next item:\n\n```\nfor _, s := range items {\n\tn, err := parse(s) // parse's fmt.Errorf is reported\n\tif err != nil {\n\t\tcontinue\n\t}\n\ttotal += n\n}\n```\n\nThe same holds for a panic recovered by a function called in a loop, and for a branch that records an error and goes on, as code that keeps its first failure does, when it can run more than once in the root's call. Once per call is allowed:\n\n```\nif n > limit {\n\tr.fail(fmt.Errorf(\"%d is over the limit\", n)) // noalloc allows this\n\treturn 0\n}\n```\n\nCode outside the module can call a function you hand it any number of times, drop its errors, and recover its panics, so a callback's failures are checked. A root's own failures stay allowed however else it is reached.\n\nIf a client can send invalid input on every request, every request still allocates once. On a path requests can reach frequently, return a sentinel error made once:\n\n``` js\nvar errEmpty = errors.New(\"empty input\")\n```\n\nnoalloc follows an error along every path from the call that made it: past tests against nil and against a package-level sentinel, through reassignments, and into a helper of the module that returns it. An error stored in a field, or turned into a result the caller stops on, such as a flag or a message, looks swallowed, and its failure is reported. A function that calls itself directly counts as called again, but mutual recursion does not.\n\nnoalloc allows a branch that finds a field or package variable nil and sets\nit, directly or through `sync/atomic`:\n\n```\nif p.index == nil {\n\tp.index = make(map[string]int, 64) // noalloc allows this\n}\n```\n\nIf something resets the field to nil, say between requests, every request allocates. Set it once, when you create the value.\n\nnoalloc compares its findings with the allocating runtime calls in your compiled code. That comparison has a few gaps:\n\n- A baseline can't list its failures and exemptions don't cover them, so a run keeps failing until you change the code.\n- It depends on the compiler's assembly and symbol names, which can change between Go releases.\n- It counts but doesn't compare calls inlined from other packages, calls on a line that also holds code noalloc doesn't check, and calls to runtime functions it can't classify:\n\n```\ncross-check: 9 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[typeAssert:3]\n```\n\n- It only sees one build: your platform, with the inlining your compiler chose.\n\nIf you're adding noalloc to code that already allocates, start a baseline and check against it:\n\n```\nnoalloc -baseline noalloc.txt -start\nnoalloc -baseline noalloc.txt\n```\n\nThe second command fails on any new allocation, and on a listed allocation\nyou've since removed until you run `-prune`. To review new exemptions in code\nreview, keep a list of them: `-exemptions file -write-exemptions` writes it,\nand `-exemptions file` fails when the code and the list disagree.\n\nbounded's `Pool` and `Map` are slower than `sync.Pool` and Go's `map`, in\nexchange for a bounded memory footprint. These are medians of six runs, at a\nload average between 3.5 and 11:\n\n| Operation | Go | bounded | \n|---|---|---|\n| Borrow and return a 16 KB buffer | 8.0 ns ( `sync.Pool` ) | 15.0 ns | \n| The same on 10 cores at once, per borrow | 1.5 ns ( `sync.Pool` ) | 3.1 ns | \n| 1,024 sets and gets | 12.0 µs ( `map` ) | 17.3 µs ( `Map` ) | \n| Read 4,096 ints | 4.8 µs (slice) | 4.0 µs ( `Vec.At` ), 4.8 µs (`Vec.Chunks` ) | \n\nThese numbers come from an Apple M4 with 10 cores and Go 1.27.0 while other builds were running on it, so compare the rows with each other rather than with your own machine.\n\nTo repeat these comparisons, run `go test -bench .` in `bounded`.\n\n- [OxCaml's zero_alloc checker](https://oxcaml.org/documentation/miscellaneous-extensions/zero_alloc_check/) has the compiler prove that a function marked`[@zero_alloc]` , and\neverything it calls, never allocates on the heap.\n- [gcassert](https://github.com/jordanlewis/gcassert) checks the specific\nlines you annotate.\n- [testing.AllocsPerRun](https://pkg.go.dev/testing#AllocsPerRun) counts the\nallocations for the inputs a test happens to run.\n- [TigerBeetle](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md) allocates all of its memory at startup.\n- [PostgreSQL's memory contexts](https://github.com/postgres/postgres/blob/master/src/backend/utils/mmgr/README) free all of a query's memory at once.\n- [Zig](https://ziglang.org/documentation/master/#Choosing-an-Allocator) passes an allocator to every function that allocates.\n\nMIT. See [LICENSE](https://github.com/theconsensuslabs/noalloc/blob/main/LICENSE). Code adapted from Go's source keeps Go's BSD\nlicense, in the `GO_LICENSE` or `LICENSE` beside it:\n[cmd/jsongen](https://github.com/theconsensuslabs/noalloc/blob/main/cmd/jsongen), [html/template](https://github.com/theconsensuslabs/noalloc/blob/main/html/template),\n[net/url](https://github.com/theconsensuslabs/noalloc/blob/main/net/url), and [encoding/json](https://github.com/theconsensuslabs/noalloc/blob/main/encoding/json), whose\n[internal/strconv](https://github.com/theconsensuslabs/noalloc/blob/main/encoding/json/internal/strconv) is a copy of Go's own.\n[encoding/json/testdata/JSONTestSuite](https://github.com/theconsensuslabs/noalloc/blob/main/encoding/json/testdata/JSONTestSuite)\nis Nicolas Seriot's JSONTestSuite, under the MIT license in its directory.", "url": "https://wpnews.pro/news/noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang", "canonical_source": "https://github.com/theconsensuslabs/noalloc", "published_at": "2026-10-28 18:29:25+00:00", "updated_at": "2026-10-28 18:42:53.354359+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["The Consensus Labs", "noalloc", "Go", "The Consensus", "bounded", "Buf", "Vec", "Map"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang", "markdown": "https://wpnews.pro/news/noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang.md", "text": "https://wpnews.pro/news/noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang.txt", "jsonld": "https://wpnews.pro/news/noalloc-tooling-and-libraries-to-enforce-zero-heap-allocations-in-golang.jsonld"}}