skip to content

Which []byte-to-string conversions does the Go compiler already do without allocating, making unsafe.String unnecessary?

level: middleimportance: nice to knowfreq 26%

answer

  1. the compiler already saw this coming
  2. the temporary string never escapes
  3. lookup yes, store no
  4. check allocs/op before reaching for unsafe

basics

~20 s

Using string(b) as a map lookup key, comparing it with ==, switching on it, or ranging over it allocates nothing: the compiler proves the temporary string cannot escape and reads the bytes in place. Reach for unsafe.String only outside those shapes.

solid answer

~40 s

The compiler special-cases the conversions where the resulting string provably dies inside the expression. A map lookup `m[string(b)]`, a comparison such as `string(b) == "END"`, a `switch string(b)` and a `for range string(b)` all hash, compare or decode `b`'s bytes directly, with no allocation and no copy. The moment the string outlives the expression the copy comes back: `m[string(b)] = v` must allocate, because the map keeps the key for as long as the entry lives, and so must returning it, storing it in a struct or appending it to a slice. This matters because a lot of `unsafe.String` in review is defending against an allocation the compiler had already removed. Measure first with a benchmark and `-benchmem`, or look at the escape decisions from `go build -gcflags=-m`, before trading away safety.

code

go · 13 lines
go
var b []byte
counts := map[string]int{}

n := counts[string(b)] // lookup: hashes b's bytes in place

if string(b) == "END" { // comparison: compares bytes directly
	n++
}

switch string(b) { // switch: a chain of the same comparisons
case "ping", "pong":
	n = 0
}

go deeper

for a junior

Know that the ordinary conversion string(b) copies the bytes, and that a copy costs an allocation on a hot path. That is the cost people are trying to avoid when they reach for unsafe.

for a middle

Be able to list the shapes the compiler already handles for free — map lookup, comparison, switch and range over string(b) — and explain the common reason: the temporary string cannot escape the expression.

for a senior

Insist on a measurement before an unsafe conversion lands. Show that you would run a benchmark with -benchmem or read go build -gcflags=-m output, and that you keep the benchmark as a regression guard.

for a principal

Frame it as a budget question: unsafe conversions cost review attention and future correctness forever, so they should be justified by a profile and confined to a small number of measured hot paths.

## Why the ordinary conversion normally allocates `string(b)` copies the bytes of `b` into a fresh array so that the resulting string is isolated from a slice its owner may still write to. That copy costs an allocation, and on a hot loop it shows up as both CPU time and garbage-collector work. It is the reason people reach for `unsafe.String(unsafe.SliceData(b), len(b))` in the first place. But the copy is only needed when the string can outlive the moment of conversion. When the compiler can prove it cannot, it removes the copy for you. ## The shapes the compiler already handles Four patterns are recognised and compiled without allocating a string: - **Map lookup.** `n := m[string(b)]` hashes `b`'s bytes and compares them against stored keys in place. Nothing retains the temporary string, so nothing needs to own it. - **Comparison.** `string(b) == "END"`, and the ordering comparisons too, compare bytes directly. - **Switch.** `switch string(b) { case "ping": ... }` is a series of comparisons and is handled the same way. - **Range.** `for i, r := range string(b)` decodes UTF-8 straight out of `b`. What unites them is lifetime: in each, the converted value is consumed by the expression that created it and is unreachable afterwards, so borrowing the bytes is safe *and the compiler can prove it*, which is exactly the proof you are asserting by hand when you write `unsafe.String`. ## Where the copy is unavoidable As soon as the string escapes the expression, the allocation returns, and it must: - `m[string(b)] = 1` — a stored map key lives as long as the entry, so the map needs its own copy. - `return string(b)` — the value outlives the frame. - `s.Name = string(b)` or `out = append(out, string(b))` — the string is retained by something else. - `fmt.Println(string(b))` — passing it into an interface value normally forces it to the heap. These are the cases where a zero-copy conversion actually buys something, and equally the cases where it is dangerous, because a retained string over borrowed bytes is exactly the aliasing bug waiting to happen. ## Measuring instead of guessing Before trading a language guarantee for speed, find out whether the copy exists. Two tools answer it directly. A benchmark run with `-benchmem` reports `B/op` and `allocs/op`. If the loop you are worried about already shows zero allocations per operation, the conversion was never the problem and `unsafe.String` will buy nothing at all. Keep the benchmark afterwards, because it is the only thing that will notice if a refactor reintroduces the copy. `go build -gcflags=-m` prints the compiler's escape-analysis decisions line by line — which values stay on the stack and which escape to the heap. Running it before and after a change shows exactly which copy disappeared, and it is the honest way to answer "did that unsafe conversion actually remove an allocation?" in a code review. ## The other direction Go also avoids some conversions in the string-to-bytes direction, not by optimisation but by specification. `copy(dst, s)` accepts a string as the source, and `append(b, s...)` accepts a string to append, so neither needs a `[]byte(s)` conversion at all. Anything that retains or writes to the bytes, though, must copy: the whole point is that the result is independent of a string that is guaranteed never to change. ## How this lands in review The useful review question when someone adds a zero-copy conversion is not "is this safe?" but "what did it save?". If the code is a map lookup, a comparison or a switch, the answer is nothing, and the plain conversion is both faster to read and impossible to get wrong. If the string is genuinely retained — put into a map, returned, stored — then the saving is real, and the conversation moves to the harder question of who owns those bytes and for how long.

  • Why does m[string(b)] avoid the allocation while m[string(b)] = v does not?
    A lookup only needs the bytes long enough to hash them and compare them against stored keys, and the compiler can prove the temporary string does not outlive the expression, so it works from `b`'s array directly. A store keeps the key inside the map for as long as the entry lives, and those borrowed bytes could be rewritten or reused, so the map must take its own copy.
  • How would you check whether one particular string(b) is actually allocating?
    Write a benchmark for the hot path and run it with `-benchmem`: the `allocs/op` and `B/op` columns answer it directly, and the benchmark stays as a regression guard. For a line-by-line view, `go build -gcflags=-m` prints the escape-analysis decisions, so you can compare the output before and after and see exactly which copy disappeared.
  • Does an equivalent shortcut exist in the string-to-[]byte direction?
    Partly, and by specification rather than optimisation: `copy(dst, s)` takes a string as its source and `append(b, s...)` appends a string's bytes, so neither needs a `[]byte(s)` conversion. Anything that retains or writes to the bytes must still copy, because the result has to be independent of a value the language guarantees never changes.

saying these in an interview costs you the question

  • Assumes every string(b) conversion allocates and copies
  • Reaches for unsafe.String without a benchmark
  • Thinks a map store with a converted key is also allocation-free
  • Cannot say how they measured the allocation they removed
  • Claims the optimisation depends on the slice being small