Zero-Allocation Parsing and Serialization in High-Throughput Go Services
Our ingest path parses text records off a network buffer and indexes them. At 1.2M events/s, the parser was the hottest function in the service – not because parsing is expensive, but because every record spawned a hidden family of heap allocations: one []byte to string conversion per field, each of which escaped to the heap because the string outlived the parse.
The allocation count was the real story. go test -benchmem reported a “simple” parse loop at ~10 allocs/op, and 1.2M events/s times ten allocs is ~12M allocations/s. The GC was running every ~300ms under load, and the p99 latency graph had the shape of a forest: a wall of pause spikes.
Zero-allocation parsing is not about cleverness. It is about not paying for conversions you do not need. This post is the pattern we ended up with, and the measurements that justify it.
The hidden cost of the obvious code
Three “obvious” choices in a typical Go parser are each an allocation factory:
string(b)on a[]bytethat escapes allocates a string header and a copy. If the string outlives the slice, the copy is mandatory; the compiler cannot keep it on the stack.strings.Splitallocates a string per field.bytes.Splitallocates a slice header per field but shares the input buffer – the sub-slices reference the original, no copy. Operating on[]byteend to end is the first big win.strconv.ParseIntand friends take astring, forcing a conversion even when you hold a[]byte.
The pattern: never convert []byte to string until you are ready to hand the data off. Parse, index, and compare directly on the byte slice.
The field-scanner pattern
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type Row struct {
UserID int64
Bytes int64
Path string
}
// parseRow fills dst from a line like "1234,5678,/v1/orders".
// No allocations on the happy path; Path is the only string, assigned by the caller.
func parseRow(line []byte, dst *Row) error {
i := 0
for field := 0; ; field++ {
j := bytes.IndexByte(line[i:], ',')
if j < 0 {
if field != 2 {
return errShortLine
}
j = len(line) - i
}
part := line[i : i+j]
switch field {
case 0:
id, err := parseInt(part)
if err != nil {
return err
}
dst.UserID = id
case 1:
n, err := parseInt(part)
if err != nil {
return err
}
dst.Bytes = n
case 2:
dst.Path = string(part)
}
i += j + 1
if field == 2 {
return nil
}
}
}
// parseInt parses a base-10 int directly from bytes; strconv needs a string.
func parseInt(b []byte) (int64, error) {
if len(b) == 0 {
return 0, errEmpty
}
neg := false
if b[0] == '-' {
neg, b = true, b[1:]
}
var v int64
for _, c := range b {
if c < '0' || c > '9' {
return 0, errInvalid
}
v = v*10 + int64(c-'0')
}
if neg {
v = -v
}
return v, nil
}
The two structural choices that matter: bytes.IndexByte on a sub-slice (no allocation, one cache-friendly scan) and parseInt over raw bytes (no strconv string conversion). dst.Path is the only string conversion in the whole path, and it is the one place a heap copy is genuinely required because the caller owns the field.
Serialization, same idea
The output side mirrors the input. Build responses into a scratch []byte with strconv.AppendInt and append, not fmt.Sprintf, and you eliminate the per-field temporary strings the formatter’s varargs allocates. A pooled scratch buffer (see the sync.Pool notes in our other post) makes the whole request cycle allocation-free except for the final copy into the response.
For JSON, encoding/json allocates on decode even for fixed schemas, and Decoder reuse helps only partially. For the handful of hot, fixed-schema endpoints, a hand-rolled scanner over the byte stream – skipping strings you do not need with bytes.IndexByte and validating structure minimally – was 3-4x faster in our benchmarks than the stdlib, allocation-free. Do not do this for schemas you do not control; it is a maintenance commitment.
The numbers
On the ingest box, before and after:
1
2
3
allocs/op GC interval under load p99 latency
before (naive) ~10 ~300 ms 28 ms
after (scanner) ~1.5 ~2 s 4 ms
Throughput on the same hardware went from 1.2M to 3.8M events/s. The latency win is the interesting one: we did not speed up the parse so much as stop feeding the collector. When GC stops running every 300ms, the pause spikes that dominated p99 simply vanish.
Verifying you actually did it
go test -bench=. -benchmemand watch theallocs/opcolumn. Zero means the compiler’s escape analysis agreed with you.go build -gcflags='-m=2'prints escape analysis decisions. The phraseescapes to heapnext to your hot parser function is the bug report.go vetis cheap insurance against converting the code back into allocation soup in a refactor.
Production lessons
- Zero-allocation code is brittle: index math and missing bounds checks are the failure mode, not allocation. Keep the safe, obviously-correct parser as the reference implementation and property-test the fast path against it on randomized inputs.
- Reserve the treatment for the hottest ~10% of your code.
unsafe-style parsing discipline everywhere makes a codebase unmaintainable for a win you cannot measure in the other 90%. - Watch the allocation profile, not just the CPU profile.
pprof-alloc_spaceis where the GC-killing allocation source shows up; it will not be in the CPU samples.