skip to content

Zero-Copy String Conversions

unsafe.String and unsafe.Slice are the supported way to view bytes as a string without copying, and the moment anyone mutates that backing array the immutability the rest of Go assumes is gone.

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

questions

4

Why is writing through unsafe.StringData(s) to a string's bytes undefined rather than a hack that happens to work?

level: middleimportance: must knowfreq 40%

answer

  1. a promise the whole toolchain leans on
  2. where a literal actually lives
  3. copying a string copies a pointer
  4. the hash was taken at insert time

basics

~20 s

Go guarantees a string never changes, and the compiler, runtime and library all build on that. Literals may sit in read-only memory, copies share one array, and a mutated map key stays stranded under its old hash.

solid answer

~50 s

`unsafe.StringData(s)` gives you a `*byte` into the string's bytes, and the documentation is explicit that those bytes must not be modified. The reason is that immutability is a language guarantee other machinery depends on, not a politeness. String literals are placed in read-only memory, so writing through the pointer can fault outright. Identical literals can be merged by the linker, so editing one edits another. Copying a string copies only the pointer and length, so every copy and every substring you have handed out sees the change. A string used as a map key was hashed once at insert time, and mutating it leaves the entry in a bucket that no longer matches its content. And the compiler is free to hoist loads and reuse comparison results because it knows the bytes cannot move. Any of those turns a "working" hack into a silent corruption later.

code

go · 8 lines
go
s := "hello"
p := unsafe.StringData(s) // *byte into the literal's data
*p = 'H'                  // undefined: literals live in read-only memory

// The supported way to edit text: work in a slice, convert once.
b := []byte(s)
b[0] = 'H'
s = string(b)

go deeper

for a junior

Know the rule and the safe alternative: never write to a string's bytes, even when unsafe hands you a pointer to them. To change text, work in a []byte and convert to a string once at the end.

for a middle

Be able to name the mechanisms that break rather than just asserting undefined behaviour: literals in read-only memory, every copy and substring sharing one array, and a map key hashed once at insert time.

for a senior

Point out that no tool catches this — it is not a data race and vet is silent — so the defence is review plus a doc comment stating that the bytes are borrowed and read-only.

for a principal

Decide the team-wide rule: whether zero-copy conversions live only inside a small audited package, and what every function returning a borrowed string is required to promise in its documentation.

## The rule `unsafe.StringData(s)` returns a `*byte` pointing at the first byte of the string's data. It exists so that you can hand those bytes to something that needs a pointer — for example to build a `[]byte` view with `unsafe.Slice` — and its documentation states the constraint bluntly: the bytes must not be modified. Writing through that pointer compiles cleanly, often appears to work in a toy program, and is nevertheless outside the language. The useful way to understand why is to list what actually relies on the guarantee. ## 1. Where a literal lives String literals are emitted into the binary's read-only data. On mainstream platforms that memory is mapped without write permission, so `*unsafe.StringData("hello") = 'H'` faults at the hardware level. It is not a Go panic with a helpful message; it is a segmentation fault, and it happens at the write, which may be nowhere near the code that decided to "just patch a byte". Worse, the linker is allowed to store one copy of identical literals and point every use at it. Editing what you think is your own `"ok"` can change an unrelated package's `"ok"`. ## 2. Every copy shares one array Assigning a string copies a two-word header — pointer and length — not the bytes. Slicing `s[2:5]` produces another header into the same array. So a string you handed to a logger, stored in a struct, sent on a channel, or captured in a closure is not a snapshot: it is another window onto the exact bytes you are about to overwrite. Mutation is therefore never local. This is precisely the property that makes strings cheap to pass around, and it only holds together because they never change. ## 3. Maps hash a key once When a string is used as a map key, the map computes its hash at insert time and places the entry in the bucket that hash selects. Maps never re-examine or rehash existing keys. Mutate the key's bytes afterwards and the entry is stranded: a lookup with the new content hashes to a different bucket and misses, while a lookup with the old content finds the right bucket but fails the byte comparison. The entry still counts toward `len(m)` and still appears during iteration, but neither `delete` call can remove it. Sets, caches and dedup tables built on string keys fail this way, and they fail quietly. ## 4. The compiler assumes it Because the bytes cannot change, the compiler may keep a loaded byte in a register across calls, hoist a length or comparison out of a loop, or prove that two strings are equal once and reuse the answer. Optimisation levels and inlining decisions differ between builds, so code that "works" today can break when an unrelated function grows past the inlining threshold. You are not relying on a rule; you are relying on the absence of an optimisation. ## What the toolchain will and will not tell you Nothing catches it. A single goroutine writing through the pointer is perfectly ordered, so the race detector under `-race` has nothing to report — this is not a data race, it is a violation of a type's contract. `go vet` does not model string immutability either. The compiler type-checks the assignment to a `*byte` and says nothing. Your defences are code review and a doc comment. ## What to do instead If you need to edit text, edit a `[]byte` and convert once at the end. Building output incrementally is what `strings.Builder` and `bytes.Buffer` are for: mutate freely while the buffer is private, then produce the string as the last step. If you have a string and need mutable bytes, take the copying conversion `[]byte(s)` — that copy is the price of being allowed to write. The legitimate use of `unsafe.StringData` is read-only: obtaining a pointer to pass to something that will only read the bytes, or building a `[]byte` view you promise to treat as read-only. If a reviewer cannot tell from the surrounding code that nothing writes to those bytes, that is a reason to reject the pattern, not a reason to add a comment saying it is fine. ## The one case people argue about "I created this string a line ago from my own `[]byte`; nobody else has it yet." The realistic failure narrows — heap memory is writable, and if the string has not yet been hashed, compared or copied you may well observe the mutation working. But you have written code whose correctness depends on the value never escaping, and strings escape easily, invisibly and later. Keep working in the slice and convert once; you get the same result with no rule to enforce.

  • What concretely goes wrong if the mutated string was already a map key?
    The hash was computed when the key was inserted, and maps never rehash existing keys. After the mutation the entry sits in a bucket chosen by the old content, so a lookup with the new value hashes elsewhere and misses, while a lookup with the old value finds the bucket but fails the byte comparison. The entry is unreachable yet still counted by len and still yielded by iteration, and neither value can delete it.
  • Would the race detector or go vet catch a write through unsafe.StringData?
    No. One goroutine writing to memory it can reach is not a data race, so a `-race` build reports nothing, and `go vet` has no check that models string immutability. The compiler accepts the assignment because the type is `*byte`. There is no automated guard at all — the only things standing between this and production are review and a doc comment saying the bytes are borrowed and read-only.
  • Is it safe if you built the string yourself from a fresh []byte a moment earlier?
    It is still a contract violation, though the realistic failure narrows: your heap array is writable, so there is no fault, and if the string has not been hashed, compared or copied yet the mutation may appear to work. You would be depending on the absence of an optimisation and on the value never escaping. Mutate the slice and convert once at the end instead — same result, no rule to police.

saying these in an interview costs you the question

  • Says it is fine as long as you own the byte slice
  • Treats string immutability as a convention rather than a guarantee
  • Expects the runtime to copy the string before it changes
  • Believes the race detector would report the mutation
  • Mutates a string already used as a map key
open as a page

What does unsafe.String(unsafe.SliceData(b), len(b)) return for a []byte b, and what must you never do to b afterwards?

level: juniorimportance: should knowfreq 25%

basics

~20 s

It returns a string that reuses b's existing byte array instead of copying it, so the conversion allocates nothing. Because Go strings must never change, nothing may write to b again for as long as that string is alive.

open as a page

A log tailer makes map keys with unsafe.String over bufio.Scanner.Bytes(); the counts go wrong once more lines arrive. Why?

level: seniorimportance: should knowfreq 35%

basics

~20 s

bufio.Scanner.Bytes() returns a slice into the scanner's own buffer, which the next Scan overwrites. The zero-copy string borrows those bytes, so every retained map key silently changes content while the map still files it under the old hash.

open as a page

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

level: middleimportance: nice to knowfreq 26%

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.

open as a page