skip to content

Allocation in fmt Calls

Every fmt argument is boxed into an interface and inspected reflectively, so Sprintf in a hot loop costs far more than strconv or an Append call. Senior interviews probe exactly this.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why does fmt.Sprintf with the %d verb cost more than strconv.Itoa for the same int?

level: juniorimportance: should knowfreq 58%

answer

  1. one is a general printer, one is not
  2. the argument has to become an interface value
  3. the format string is read at run time
  4. digits go straight into a small array
  5. small values come from a constant table

basics

~20 s

fmt.Sprintf boxes the int into an interface value, scans the format string at run time, and dispatches on the argument's type before writing digits. strconv.Itoa runs a digit loop directly, so it is several times faster and allocates less.

solid answer

~50 s

Both produce the same text, but by very different routes. `fmt.Sprintf` takes `format string, a ...any`, so the int is boxed into an interface value first — a small heap allocation unless the runtime's static table for byte-sized values covers it. Sprintf then walks the format string byte by byte on every call, dispatches on the argument's dynamic type, formats into a pooled scratch buffer, and copies that buffer into a new string. For a large int that is typically two allocations plus a lot of branching. `strconv.Itoa` takes a plain `int`: it writes digits backwards into a small array on the stack and allocates the result string once, and for non-negative values below 100 it returns a slice of a precomputed constant, allocating nothing. Per request the difference is invisible; inside a loop rendering millions of values it is the whole cost.

code

go · 2 lines
go
s := fmt.Sprintf("%d", n) // boxes n into an any, scans "%d" at run time, copies a buffer into a string
s = strconv.Itoa(n)       // digit loop into a stack array, one string allocation (none below 100)

go deeper

for a junior

Be ready to name strconv.Itoa as the direct way to turn an int into decimal text, and to say that fmt.Sprintf does noticeably more work than its one verb suggests.

for a middle

Explain the three costs inside a Sprintf call: boxing the argument into an interface value, scanning the format string at run time, and copying the scratch buffer into the returned string.

for a senior

Show that you know when the difference matters. Once per request it is noise; inside a loop emitting millions of values it becomes allocation rate and collector CPU, and that is the only time you trade the readability away.

for a principal

Own the rule rather than the fact: decide where in the codebase this substitution is required, where it is premature, and what evidence has to exist before anyone makes the swap.

### The same result, two very different routes `fmt.Sprintf("%d", n)` and `strconv.Itoa(n)` both hand you the decimal text of an integer. They are not close relatives. `strconv.Itoa` is a specialised function whose parameter is an `int`; the compiler knows the type at the call site and the function does one job. `fmt.Sprintf` is a general printer whose parameter list is `format string, a ...any` — it accepts anything, so everything it needs to know about your argument has to be discovered at run time. ### What Sprintf pays for **Boxing.** To pass `n` as an `any`, the compiler has to build an interface value: a pair of a type descriptor and a pointer to the data. An `int` is not a pointer, so the data has to live somewhere the pointer can point at. The runtime keeps a small static table for byte-sized values, so boxing a value below 256 costs nothing, but a general integer means a small heap allocation. Escape analysis cannot rescue it: `fmt` inspects the boxed value through its own generic machinery, so the compiler must assume the value outlives the call. **Scanning the format string.** `"%d"` is not a compile-time template. Sprintf walks the format string byte by byte on every call, recognising `%`, reading flags, width and precision, and pairing each verb with the next argument. Two characters is cheap, but it is work done per call, forever. **Dispatching on the argument.** Having found the verb, `fmt` has to decide what the argument is. It checks the interfaces it honours first, then type-switches the common concrete kinds. An `int` is matched quickly, but the branching still happens on every call. **Producing the string.** `fmt` formats into a scratch byte buffer taken from a pool, then converts that buffer into a `string` — a copy and an allocation, because strings are immutable and the buffer goes back to the pool. So a typical `fmt.Sprintf("%d", n)` for a large `n` is two allocations (the box and the result string) plus a good deal of branching. ### What Itoa pays for `strconv.Itoa` divides the number down, writing digits backwards into a small fixed array that lives on the stack, then allocates the finished `string` once. For non-negative values below 100 it does not even do that: it returns a slice of a precomputed constant string, so those calls allocate nothing at all. There is no interface value, no format string, and no dispatch. ### Related conversions worth knowing - `strconv.FormatInt(v, base)` — the same thing for an `int64` in any base from 2 to 36. - `strconv.AppendInt(dst, v, base)` — writes the digits onto an existing `[]byte` and returns it, so no string is created at all. This is the form to reach for when you are assembling a line. - `strconv.Quote`, `strconv.FormatBool`, `strconv.FormatFloat` — the same pattern for other kinds. ### The conversion trap `string(n)` is not a decimal conversion. Converting an integer to a string interprets it as a Unicode code point, so `string(65)` is `"A"`. `go vet` warns about the untyped form precisely because people write it expecting `"65"`; writing `string(rune(n))` makes the real intent explicit. If you want digits, you want `strconv`. ### When the difference actually matters Per HTTP request, per CLI invocation, per error message, the difference is invisible — a few hundred nanoseconds and one extra allocation against work measured in microseconds or milliseconds. `fmt` is the right default and reads better, especially when several values are interleaved with literal text. The difference becomes real when the call rate is high: a loop rendering millions of lines, a serialiser, a metrics emitter, an encoder in a tight path. There the extra allocation per call turns into allocation rate, and allocation rate turns into garbage-collector CPU. That is the situation where you replace the printer with `strconv`, and ideally with the `Append` family so that no intermediate string exists at all. ### What this is not It is not an argument that `fmt` is slow and should be avoided. It is an argument that `fmt` is *general*, and generality is paid for at run time, once per call. Knowing where that cost lives is what lets you spend it deliberately.

  • Does fmt.Sprint avoid the cost, since there is no format string to parse?
    It saves the format scan and nothing else. `fmt.Sprint` still takes `...any`, so the argument is still boxed, still dispatched on by type, and the scratch buffer is still copied into a new result string. If you are counting allocations, `strconv.Itoa` is the answer, and `strconv.FormatInt` when you need a base other than 10.
  • How would you render an int64 in hexadecimal without fmt?
    `strconv.FormatInt(v, 16)` returns the string. `strconv.AppendInt(dst, v, 16)` goes further and appends the digits to an existing byte slice, producing no string at all — that is the form to use when you are assembling a larger line. `fmt.Sprintf` with `%x` gives the same text but pays boxing, format scanning and a result copy.
  • Is string(n) a reasonable way to turn an int into its decimal text?
    No, and it is a classic beginner bug. Converting an integer to a string interprets the value as a Unicode code point, so `string(65)` is "A" rather than "65". `go vet` warns about the untyped form for exactly this reason, and writing `string(rune(n))` makes the real intent explicit. For decimal digits, use `strconv.Itoa`.

strconv.Itoa is a direct line to one extension. fmt.Sprintf is a switchboard: every caller has to be announced, identified and connected before anyone speaks.

saying these in an interview costs you the question

  • Claims Sprintf and Itoa compile to the same code
  • Thinks string(n) yields the decimal digits of n
  • Says the format string is parsed at compile time
  • Assumes the only allocation is the returned string
  • Insists Sprintf must be avoided everywhere on principle
open as a page

What makes printing a large struct with fmt's %+v verb expensive at run time?

level: middleimportance: should knowfreq 38%

basics

~20 s

The %+v verb drops fmt out of its fast type switch into a reflective walk: every field is visited by name, nested structs, slices and maps are recursed into, map keys are sorted, and String methods found on the way are called.

open as a page

A metrics daemon builds every StatsD line with fmt.Sprintf and GC CPU climbs with traffic. How do you cut the allocations per line?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Stop producing strings. Keep one reusable byte slice per emitter, reset it with buf = buf[:0], build the line with append and strconv.AppendInt, and write those bytes straight out. That removes the boxing, the format scan and two string copies.

open as a page

When do you require a team to replace fmt.Sprintf with strconv.AppendInt-style calls, and how do you stop that spreading?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Only where a measurement ties formatting allocations to a cost you care about, and only inside a boundary narrow enough to write in one sentence. Everywhere else fmt.Sprintf stays, because readability is the default and the rewrite has to be maintained.

open as a page