Skip to content

AllocsPerRun counts allocations

Level: 301 · anyone who wants an allocation count without writing a benchmark

One line: testing.AllocsPerRun(runs, f) calls f once to warm up and then runs more times with GOMAXPROCS set to 1, and returns the heap allocations per call as a whole number — the same number on every run and every machine, so it can sit in a key, in a test's assertion, or in a program with no _test.go at all.

Four pairs of ways to do the same thing, each pair measured by one ordinary program: building a line by concatenation or with a strings.Builder, formatting an integer with fmt.Sprintf or strconv.Itoa, filling a slice from nil or from make with a capacity, and boxing an int into an any. The quantities are read from a slice at run time, because a constant would take a different path:

Verified output of allocsperrun_counts_allocations_go.go — regenerated by tools/run_examples.py, never hand-typed.

line += field, 4 fields                    4
strings.Builder with Grow, 4 fields        1
fmt.Sprintf("%d", 4096)                    2
strconv.Itoa(4096)                         1
strconv.Itoa(42)                           0
append to a nil slice, 10 ints             5
make([]int, 0, 10), then 10 appends        1
boxed = 42 (an int into any)               0
boxed = 255 (an int into any)              0
boxed = 256 (an int into any)              1
boxed = 4096 (an int into any)             1
boxed = 4096 (the constant)                0

Reading the output

4 and 1: the two ways of building a line. Each line += f + ";" is a new string (concatstrings ↗); a Builder with Grow allocates its buffer once and String ↗ does not copy. These are the counts allocs/op is a key, ns/op is not got from go test -bench, for four fields instead of eight.

2, 1 and 0: formatting an integer. fmt.Sprintf("%d", 4096) allocates twice: once to box the int into the any that Sprintf takes, once for the result string. strconv.Itoa(4096) allocates the result only. strconv.Itoa(42) allocates nothing, because itoa.go ↗ answers any value below 100 with a slice of a constant string. The lesson is not "Itoa is free": it is that the count depends on the value, and a measurement taken on small numbers can miss an allocation the real data pays for.

5 and 1: growing a slice. Ten appends to a nil slice allocate five times, as the capacity grows 1, 2, 4, 8, 16 (growslice ↗). make([]int, 0, 10) allocates once. When the count is known, say so in the make.

0, 0, 1, 1, and 0 for the constant: boxing an int. Converting an int to any needs somewhere for the value to live. For values below 256, convT64 ↗ points at an entry of a static array, staticuint64s, and allocates nothing; 256 and 4096 get eight heap bytes each. The last line boxes the literal 4096 and costs nothing: the compiler emits a constant converted to an interface as read-only static data, so no runtime call is made at all (the mechanism is not machine-checked here; the zero is). That is why the program reads its quantities from a slice — and why a count taken on a constant says nothing about the same code on a variable.

Why the numbers are whole, and the same every time. allocs.go ↗ reads runtime.MemStats.Mallocs before and after the runs and divides by runs with integer division — the documentation promises an integral value even though the type is float64. It also sets runtime.GOMAXPROCS(1) ↗ for the duration, so no other goroutine runs in parallel while the counter is read. The demo below ran the program twenty times and saw one output; the Linux run in Docker printed the same twelve lines.

Real runs — go1.25.5, x86-64 Mac, 20 runs, 2026-09-23
$ bash demo/allocs_twenty_runs.sh
1 distinct output(s) over 20 runs
allocsperrun_counts_allocations_go.go

allocsperrun_counts_allocations_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// testing.AllocsPerRun counts the heap allocations one call of a function
// makes, from an ordinary program: no test file, no benchmark, and a whole
// number that is the same on every run. Four pairs of ways to do the same
// thing, and the count for each.
//
//  go build allocsperrun_counts_allocations_go.go && ./allocsperrun_counts_allocations_go
package main

import (
    "fmt"
    "strconv"
    "strings"
    "testing"
)

var fields = []string{
    "customer=acme-industries",
    "item=steel-bracket-40mm",
    "quantity=1200",
    "warehouse=rotterdam-east",
}

// Values read from a slice at run time, so the compiler cannot treat them as
// constants (a constant boxed into an interface costs nothing: see the last line).
var quantities = []int{42, 255, 256, 4096}

// The results are kept in package variables so that no call can be optimised
// away as unused.
var (
    line  string
    items []int
    boxed any
)

func main() {
    report := func(what string, f func()) {
        fmt.Printf("%-42s %v\n", what, testing.AllocsPerRun(200, f))
    }

    report("line += field, 4 fields", func() {
        line = "order:"
        for _, f := range fields {
            line += f + ";"
        }
    })
    report("strings.Builder with Grow, 4 fields", func() {
        var b strings.Builder
        b.Grow(128)
        b.WriteString("order:")
        for _, f := range fields {
            b.WriteString(f)
            b.WriteByte(';')
        }
        line = b.String()
    })

    big, small := quantities[3], quantities[0]
    report("fmt.Sprintf(\"%d\", 4096)", func() { line = fmt.Sprintf("%d", big) })
    report("strconv.Itoa(4096)", func() { line = strconv.Itoa(big) })
    report("strconv.Itoa(42)", func() { line = strconv.Itoa(small) })

    report("append to a nil slice, 10 ints", func() {
        var s []int
        for i := range 10 {
            s = append(s, i)
        }
        items = s
    })
    report("make([]int, 0, 10), then 10 appends", func() {
        s := make([]int, 0, 10)
        for i := range 10 {
            s = append(s, i)
        }
        items = s
    })

    for _, q := range quantities {
        report(fmt.Sprintf("boxed = %d (an int into any)", q), func() { boxed = q })
    }
    report("boxed = 4096 (the constant)", func() { boxed = 4096 })
}

What to do

  • Assert the count in a test. if n := testing.AllocsPerRun(100, f); n > 0 { t.Errorf("f allocated %v times", n) } turns "this path must not allocate" into a test that fails when someone adds a fmt.Sprintf. The standard library's own tests do this — bufio, bytes and context among them.
  • Keep the result. Assign what f computes to a variable outside the closure, as the program does with line, items and boxed, so the compiler cannot drop the work.
  • Feed it the values the real code sees. Small integers, constants and empty strings take free paths; a count taken on them is a count of the wrong thing.
  • Read the count next to escape analysis. Escape analysis decides stack or heap shows -gcflags=-m naming the variables that move to the heap; AllocsPerRun is the cost of those decisions, per call.

Practice

An invoice line item:quantity is built with fmt.Sprintf("%s:%d", item, quantity) on a hot path. Rebuild it with append and strconv.AppendInt into a []byte buffer that is reused between calls, print AllocsPerRun for both versions, and check that the two produce the same text. Take quantity from something the compiler cannot fold, such as len(os.Args).

Solution

allocsperrun_counts_allocations_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: an invoice line "item:quantity" is built with fmt.Sprintf on a hot
// path. Rebuild it with append and strconv.AppendInt into a buffer that is
// reused between calls, and let AllocsPerRun show what that saved.
//
//  go build allocsperrun_counts_allocations_kata_go.go && ./allocsperrun_counts_allocations_kata_go
package main

import (
    "fmt"
    "os"
    "strconv"
    "testing"
)

func main() {
    item := "steel-bracket"
    quantity := 1200 + len(os.Args) // not a constant, so it is boxed at run time

    var viaSprintf string
    fmt.Printf("fmt.Sprintf(\"%%s:%%d\", item, quantity): %v\n", testing.AllocsPerRun(200, func() {
        viaSprintf = fmt.Sprintf("%s:%d", item, quantity)
    }))

    buf := make([]byte, 0, 64) // allocated once, before the measurement
    var viaAppend []byte
    fmt.Printf("append + strconv.AppendInt into a reused buffer: %v\n", testing.AllocsPerRun(200, func() {
        buf = buf[:0]
        buf = append(buf, item...)
        buf = append(buf, ':')
        buf = strconv.AppendInt(buf, int64(quantity), 10)
        viaAppend = buf
    }))

    fmt.Println("same text:", viaSprintf == string(viaAppend))
}

Verified output of allocsperrun_counts_allocations_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

fmt.Sprintf("%s:%d", item, quantity): 2
append + strconv.AppendInt into a reused buffer: 0
same text: true

The Sprintf version's two allocations are the boxed quantity and the result string; item is a constant and costs nothing to box. The append version's buffer was allocated before the measurement and is reused inside it, so the count is zero — and the result is a []byte that the caller must use before the next call overwrites it, which is the price of that zero.

In other languages

  • The Rust library's When String is too slow ↗ — the same question about building strings, put to Rust's String. Rust's standard library has no AllocsPerRun; the idiom is a counting GlobalAlloc ↗ installed as the global allocator, which is more work and counts every allocation in the process.
  • Python's tracemalloc ↗ traces allocations by source line; C's malloc counts nothing, and a wrapper or valgrind does the counting.

Sources