Skip to content

Pointers, Values & Memory

Go has no hidden references and no manual memory management, but it does have a precise model of what gets copied when. Getting that model right is the difference between code that’s fast and correct and code that mysteriously aliases, leaks, or corrupts data. We’ll build the model from the ground up — value semantics, then the “reference-like” header types (slices, maps, strings) — using a real distributed-systems codebase, multigres (“Vitess for Postgres”), whose SQL lexer, parser, and wire codec lean hard on these details for performance.

If you haven’t met defined types and value-vs-pointer receivers yet, Types, Structs & Methods covers them. New to the repo? Start at the orientation.

Value semantics: every assignment is a copy

Section titled “Value semantics: every assignment is a copy”

Go has no hidden references. Every assignment and every function argument is a copy of the value. For a struct, “the value” is all its fields laid out contiguously — so copying a struct copies every field.

value-semantics.go
type Point struct{ X, Y int }
func move(p Point) { p.X = 99 } // mutates a COPY
func main() {
a := Point{1, 2}
b := a // b is an independent copy
b.X = 5 // a.X is still 1
move(a) // a.X is still 1
}

This is the same model as C structs or C# struct — not Java/Python objects, where a variable holds a reference. To mutate the caller’s value, or to avoid copying a large struct, you pass a pointer.

value-semantics.go
func move(p *Point) { p.X = 99 } // p.X is shorthand for (*p).X
a := Point{1, 2}
move(&a) // a.X is now 99

A method’s receiver follows the same rules. A value receiver operates on a copy; a pointer receiver operates on the original. The codebase makes this distinction deliberately. In its sqltypes package, Value (a small 3-word slice header — more below) uses value receivers, while Result/Row (larger structs that also need nil-checking) use pointer receivers:

go/common/sqltypes/sqltypes.go
// Value represents a nullable column value.
// nil means NULL, []byte{} means empty string.
type Value []byte
func (v Value) IsNull() bool {
return v == nil
}
go/common/sqltypes/sqltypes.go
func (r *Result) ToProto() *query.QueryResult { if r == nil { return nil } /* ... */ }
func (r *Row) ToProto() *query.Row { if r == nil { return nil } /* ... */ }

Value is a value receiver because copying it is cheap (it copies the header, not the bytes) and because a Value is meant to behave like a primitive. *Result and *Row are pointer receivers because (1) they are larger structs you don’t want to copy on every call, and (2) the methods need to handle a nil receiver gracefully — if r == nil { return nil } is a legal pointer-receiver pattern (see Errors for nil-receiver methods). You can’t nil-check a value receiver the same way; a value can’t be the nil pointer.

What is “reference-like”? slices, maps, channels, strings

Section titled “What is “reference-like”? slices, maps, channels, strings”

Some built-in types are headers — small structs that contain a pointer to backing storage. Copying the header copies the pointer, so the copy shares the backing data. These are slices, maps, channels, and strings. They’re still copied by value (the header is), but the thing they point at is shared.

This is the single most important mental model in this chapter: a slice variable is not the array; it is a (ptr, len, cap) triple that points at an array.

header-aliasing.go
s := []int{1, 2, 3}
t := s // copies the 3-word header; t and s share the same backing array
t[0] = 99 // s[0] is now 99 too

Contrast with arrays, which are genuine values (covered later).

A slice header is three machine words pointing into a separate backing array:

A slice header points into a backing array
Rendering diagram…
  • ptr — points at the first element of the backing array.
  • len — number of elements you can index (s[0] .. s[len-1]).
  • cap — number of elements from ptr to the end of the backing array.

Subslicing creates a new header that points into the same backing array:

subslice-aliasing.go
s := []byte("hello world")
sub := s[6:11] // sub == "world", shares s's backing array
sub[0] = 'W' // s is now "hello World" — aliasing!

len(sub) is 5; cap(sub) is 5 (from offset 6 to the end of the 11-byte array). No bytes were copied — sub’s ptr is just &s[6].

The real high-perf user: the lexer’s scan buffer

Section titled “The real high-perf user: the lexer’s scan buffer”

The SQL lexer scans by indexing and subslicing a single backing buffer, never copying per-token. Its ParseContext holds the source as a []byte built once from the input string:

go/common/parser/context.go
scanBuf []byte // the string being scanned (for lexer)
// ...
scanBuf: []byte(input),
scanBufLen: len(input),

The lexer then reads scanBuf[pos] by index and takes subslices like scanBuf[startPos:scanPos] to identify tokens. Because subslicing is just header arithmetic (no copy), the whole hot path stays allocation-free. The query path is latency-sensitive, so this matters (see Parser, Lexer, AST & Codegen).

Subslice aliasing in the wire codec — the gotcha that bites

Section titled “Subslice aliasing in the wire codec — the gotcha that bites”

RowFromProto decodes a protobuf row whose values are concatenated into one big []byte (pr.Values) plus a Lengths array. It carves each column out as a subslice of that one buffer:

go/common/sqltypes/sqltypes.go
func RowFromProto(pr *query.Row) *Row {
if pr == nil {
return nil
}
values := make([]Value, len(pr.Lengths))
offset := 0
for i, length := range pr.Lengths {
switch length {
case -1:
values[i] = nil // NULL
case 0:
values[i] = []byte{} // empty string, not NULL
default:
values[i] = pr.Values[offset : offset+int(length)]
offset += int(length)
}
}
return &Row{Values: values}
}

The same pattern shows up when decoding bind parameters. Two consequences follow directly from slice internals:

  1. Every Value here aliases the one pr.Values backing array. Mutating the bytes of one column would corrupt its neighbors. Retaining one tiny Value keeps the entire pr.Values buffer alive — the garbage collector frees the backing array only when no slice references it. That’s a classic memory-retention trap.
  2. It is fast on purpose. Decoding a row of N columns does one make([]Value, N) and zero per-column byte copies.

The defensive copy: “return copy for safety”

Section titled “The defensive copy: “return copy for safety””

When the lexer exposes its internal buffer to outside callers, it must not hand out an aliasing slice — a caller could mutate the lexer’s state through it. So it does an explicit make+copy:

go/common/parser/context.go
func (ctx *ParseContext) GetScanBuf() []byte {
// Return copy for safety
buf := make([]byte, len(ctx.scanBuf))
copy(buf, ctx.scanBuf)
return buf
}

If this returned ctx.scanBuf directly, a caller writing buf[0] = 'x' would scribble on the lexer’s live scan buffer mid-parse. Returning a copy breaks the aliasing.

append: growth, reallocation, and predictable aliasing

Section titled “append: growth, reallocation, and predictable aliasing”

append adds elements to a slice. If len < cap, it writes into the existing backing array and returns a header with a longer lenmutating shared storage in place. If len == cap, it allocates a new, larger backing array, copies the old elements, and returns a header pointing at the new array — at which point the old aliases stop seeing your new writes.

append-aliasing.go
a := make([]int, 2, 4) // len 2, cap 4
b := append(a, 7) // fits in cap: b shares a's array, b[2]==7
a = a[:3] // a now also sees the 7 — same backing array
c := append(b, 1, 2, 3) // overflows cap 4: c gets a NEW array
c[0] = 99 // a/b are UNAFFECTED — different backing array now

Preallocate with make to avoid growth churn

Section titled “Preallocate with make to avoid growth churn”

ToProto knows the exact final size up front, so it allocates once with make([]byte, 0, totalLen)length 0, capacity totalLen — then appends without ever reallocating:

go/common/sqltypes/sqltypes.go
lengths := make([]int64, len(r.Values))
var totalLen int
for i, v := range r.Values {
if v == nil {
lengths[i] = -1
} else {
lengths[i] = int64(len(v))
totalLen += len(v)
}
}
values := make([]byte, 0, totalLen)
for _, v := range r.Values {
if v != nil {
values = append(values, v...) // spread one slice into another
}
}

Two things to read carefully:

  • make([]byte, 0, totalLen) — the third arg is capacity. The slice is empty (len 0) but has room for totalLen bytes, so the append loop never grows the array. Without the cap hint, append would reallocate roughly log₂(N) times, copying old data each time.
  • append(values, v...) — the ... spreads the elements of v as individual arguments. append(values, v) (no dots) would be a type error here, since v is a []byte and values wants byte elements.

Note the two make shapes side by side: make([]int64, len(r.Values)) (len == N, for index assignment lengths[i] = ...) versus make([]byte, 0, totalLen) (len 0, cap N, for append). Pick the shape by how you’ll fill it — index-assign needs length; append needs capacity.

When the exact size isn’t known, the lexer uses cap as an upper-bound estimate — decoding a Unicode escape can’t produce more bytes than the input:

go/common/parser/lexer.go
result := make([]byte, 0, len(input))
// ...
result = append(result, []byte(string(cp))...) // rune -> string -> []byte (UTF-8)

string(cp) where cp is a rune produces the UTF-8 encoding of that codepoint as a string; []byte(...) of it gives the bytes; ... spreads them. (More on rune-to-string below.)

copy(dst, src) copies min(len(dst), len(src)) elements and returns that count. It does not grow dst. To clone a slice into independent storage you make the destination to the right length, then copy:

clone.go
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src) // dst is independent; mutating dst won't touch src

The generated AST cloner shows this in its purest form, preserving the nil/non-nil distinction (see nil-vs-empty below):

go/common/parser/ast/ast_clone.go
func CloneSliceOfOid(n []Oid) []Oid {
if n == nil {
return nil
}
res := make([]Oid, len(n))
copy(res, n)
return res
}

copy does a shallow copy — for []Oid (a slice of values) that’s a full deep copy, because the values are the data. But for a slice of pointers, copy would duplicate the pointers, leaving both slices pointing at the same elements. The cloner handles that case with a per-element deep clone instead:

go/common/parser/ast/ast_clone.go
func CloneSliceOfRefOfDefElem(n []*DefElem) []*DefElem {
if n == nil {
return nil
}
res := make([]*DefElem, len(n))
for i, x := range n {
res[i] = CloneRefOfDefElem(x) // recurse into each pointed-at node
}
return res
}

nil slice vs empty slice — load-bearing here

Section titled “nil slice vs empty slice — load-bearing here”

A nil slice (var s []byte or []byte(nil)) has ptr == nil, len == 0, cap == 0. An empty slice ([]byte{} or make([]byte, 0)) has a non-nil ptr, len == 0, cap == 0. Both have length zero, so ranging over either does nothing and append works on either — for most code the difference is invisible.

In sqltypes the difference is semantic and must be preserved: a nil Value is SQL NULL; an empty Value ([]byte{}) is the empty string ''. Look back at RowFromProto — the -1 and 0 cases are kept distinct on purpose:

go/common/sqltypes/sqltypes.go
case -1:
values[i] = nil // NULL
case 0:
values[i] = []byte{} // empty string, not NULL

Value.IsNull() is literally v == nil. If you “helpfully normalized” a nil slice to []byte{} anywhere in this path, you’d turn every SQL NULL into an empty string — a correctness bug.

Maps: reference-like, must be made, comma-ok, no order

Section titled “Maps: reference-like, must be made, comma-ok, no order”

A map value is a pointer to a runtime hash table. Copying a map variable copies the pointer — both names see the same table. The zero value of a map is nil, and a nil map is readable (every lookup returns the zero value) but not writable (a write panics).

nil-map.go
var m map[string]int // nil map
_ = m["missing"] // ok: returns 0 (zero value), no panic
m["x"] = 1 // PANIC: assignment to entry in nil map
m = make(map[string]int) // now writable
m["x"] = 1 // ok

The codebase builds its keyword lookup table in an init() using make with a capacity hint:

go/common/parser/keywords.go
var keywordLookupMap map[string]*KeywordInfo
// ...
func init() {
keywordLookupMap = make(map[string]*KeywordInfo, len(Keywords))
for i := range Keywords {
keywordLookupMap[Keywords[i].Name] = &Keywords[i]
}
}

The len(Keywords) second argument tells the runtime how many entries to expect, so it can size the buckets once instead of rehashing as the map grows. It’s a hint, not a cap — the map can still grow past it.

Taking the address of a slice element, not a loop variable

Section titled “Taking the address of a slice element, not a loop variable”

Notice &Keywords[i] — the address of element i in the slice’s backing array. This is deliberate. The tempting alternative is wrong in spirit:

loop-var-address.go
// WRONG mental model:
for _, k := range Keywords {
keywordLookupMap[k.Name] = &k // address of the loop copy, not of Keywords[i]
}

k is a copy of each element, so &k takes the address of that copy. (Pre-Go-1.22 the same k variable was reused across iterations, so every stored pointer aliased the last value — a notorious bug. Go 1.22+ makes k per-iteration, so this specific form is no longer the last-value bug, but &k still points at a copy, not at Keywords[i].) Using &Keywords[i] gets a stable pointer into the slice’s storage, so the map holds pointers to the real keyword records — exactly what you want when value identity matters.

Because a missing key returns the zero value, you can’t distinguish “absent” from “present but zero” by the value alone. The two-result form does:

comma-ok.go
v, ok := m["k"] // ok is false if absent

A settings cache uses it, combining the lookup with pointer identity for deduplication:

go/services/multipooler/internal/connstate/settings_cache.go
if elem, ok := c.cache[key]; ok {
// Move to front (most recently used)
c.lru.MoveToFront(elem)
c.hits++
return elem.Value.(*cacheEntry).settings
}

Its GetOrCreate returns the same *Settings pointer for identical inputs — that’s a documented contract. It only works because maps are reference-like and pointers carry identity: two callers with the same inputs get pointer-equal results.

Strings vs []byte: immutability and conversion cost

Section titled “Strings vs []byte: immutability and conversion cost”

A Go string is a 2-word header (ptr, len) over immutable bytes; a []byte is the 3-word mutable slice you already know:

string header vs []byte header
Rendering diagram…

Because one is immutable and the other mutable, converting between them allocates a new backing array and copies the bytes in the general case:

string-bytes-conversion.go
s := "hello"
b := []byte(s) // allocates len(s) bytes, copies them (b is mutable, independent)
s2 := string(b) // allocates again, copies back

The lexer pays this conversion exactly oncescanBuf: []byte(input) at setup — and then scans by index over the mutable buffer. That’s the right place to pay: one copy at setup, zero in the hot loop.

GetCurrentText does the reverse at a controlled point — subslice (cheap, aliases) then convert to string (one copy, makes the result safe to retain):

go/common/parser/context.go
func (ctx *ParseContext) GetCurrentText(startPos int) string {
if startPos < 0 || startPos > ctx.scanPos {
return ""
}
return string(ctx.scanBuf[startPos:ctx.scanPos])
}

The subslice aliases the live buffer (no copy), but string(...) copies those bytes into a fresh immutable string — so the returned token text is independent of the lexer and safe to keep even as scanning overwrites that buffer region.

The zero-allocation string(b) == "literal" optimization

Section titled “The zero-allocation string(b) == "literal" optimization”

There’s one important exception to “conversion allocates.” The Go compiler recognizes the pattern string(byteSlice) == stringValue and lowers it to a direct byte comparison (memcmp) with no allocation — the temporary string is never materialized on the heap. The lexer documents and relies on this in its hot scanning loop:

go/common/parser/context.go
// HasPrefixAtScanPos reports whether scanBuf at the current scan position
// starts with needle. needle is taken as a string so callers don't have to
// allocate a []byte: `string(byteSlice) == stringLit` lowers to a direct
// memcmp in the Go compiler with no heap allocation.
func (ctx *ParseContext) HasPrefixAtScanPos(needle string) bool {
pos := ctx.scanPos
if len(ctx.scanBuf)-pos < len(needle) {
return false
}
return string(ctx.scanBuf[pos:pos+len(needle)]) == needle
}

Avoid the conversion entirely when nothing changes

Section titled “Avoid the conversion entirely when nothing changes”

normalizeKeywordCase lowercases a keyword for lookup, but most keywords are already lowercase. It scans first and returns the original string untouched if there’s nothing to change — paying zero allocations on the common path — and only allocates when it must rewrite bytes:

go/common/parser/keywords.go
func normalizeKeywordCase(s string) string {
hasUpper := false
for i := 0; i < len(s); i++ {
if s[i] >= 'A' && s[i] <= 'Z' {
hasUpper = true
break
}
}
// If no uppercase, return original string (avoid allocation)
if !hasUpper {
return s
}
// Need to convert case - create new string
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
ch := s[i]
if ch >= 'A' && ch <= 'Z' {
ch += 'a' - 'A' // PostgreSQL ASCII-only conversion
}
result[i] = ch
}
return string(result)
}

The allocation avoided on the fast path is the make([]byte, len(s)) plus the final string(result) copy. Returning s directly costs nothing because a string is immutable — handing back the same header is safe.

Indexing a string (s[i]) gives a byte (uint8), not a character. Ranging over a string (for i, r := range s) decodes runes (int32 Unicode codepoints), and i is the byte offset of each rune. A multi-byte UTF-8 character is several bytes but one rune. string(someRune) UTF-8-encodes that codepoint (used in the lexer’s []byte(string(cp)) above); string(someByteSlice) reinterprets bytes as a string. string(65) is "A" (the codepoint) — not "65".

strings.Builder: cheap incremental building, never copy it

Section titled “strings.Builder: cheap incremental building, never copy it”

Concatenating with + in a loop reallocates every iteration (strings are immutable). strings.Builder accumulates into an internal growable buffer and produces the final string once with .String():

builder.go
var b strings.Builder
for i := 0; i < 3; i++ {
b.WriteString("x")
}
return b.String() // "xxx", one final allocation

The SQL-rendering code threads a *strings.Builder through helper functions so writes accumulate in the caller’s builder:

go/common/parser/ast/json_parse_nodes.go
func appendJsonReturning(b *strings.Builder, output *JsonOutput) {
// ...
b.WriteString(" RETURNING ")
// ...
}

The parameter is *strings.Builder (pointer), not strings.Builder (value), for two reasons: passing a value would (1) write into a copy the caller never sees, losing the output, and (2) trip go vet’s copylocks checkstrings.Builder contains a noCopy marker precisely to forbid copying after first use.

For the same reason, ParseContext embeds a strings.Builder as a value field, which is exactly why ParseContext is only ever used as *ParseContext (every method is func (ctx *ParseContext) ...):

go/common/parser/context.go
literalBuf strings.Builder // accumulates literal values

If ParseContext were ever copied by value, it would copy literalBuf — corrupting the builder and tripping copylocks. Using it only via pointer guarantees no copy ever happens.

An array has a fixed length that’s part of its type: [3]int and [4]int are different, incompatible types. Arrays are genuine values — assigning or passing one copies all elements.

array-vs-slice.go
var a [3]int
b := a // copies all 3 ints
b[0] = 9 // a[0] is still 0
// A slice over an array does NOT copy:
s := a[:] // s is a slice header pointing into a's storage
s[0] = 9 // a[0] is now 9

You’ll rarely declare arrays directly here; slices are the workhorse. The relevant connection: []byte(input) builds a slice over a fresh backing array (that’s the allocation), and a string is an immutable byte sequence, conceptually array-backed — which is why mutating one requires the []byte copy.

Two allocation builtins, easy to confuse:

  • make(T, ...)only for slices, maps, and channels. It initializes the internal structure (slice header + backing array; map buckets; channel buffer) and returns an initialized value of type T.
  • new(T) — for any type. It allocates a zeroed T and returns a *T pointing at it.
make-vs-new.go
s := make([]int, 0, 8) // a usable, empty slice with cap 8
m := make(map[string]int) // a usable, writable map
p := new(int) // p is *int, *p == 0

make is everywhere in this chapter’s examples. new is rare for reference types and usually a mistake there:

make-vs-new.go
bad := new([]byte) // bad is *[]byte pointing at a NIL slice — almost never useful
*bad = append(*bad, 1) // works, but you wanted `b := make([]byte, 0, n)` instead

Go decides automatically whether each value lives on the stack (cheap, freed when the function returns) or the heap (managed by the GC). The compiler’s escape analysis asks: can this value’s lifetime exceed the function? If a value’s address can be reached after the function returns, it must escape to the heap.

Common things that force a heap escape:

  • Returning a pointer to a local: func f() *T { var x T; return &x }x outlives f, so it heap-allocates. (Unlike C, this is safe in Go — no dangling pointer — but it’s still a heap allocation.)
  • Storing a value in an interface (interface boxing): assigning a concrete value to an any or any interface variable typically moves it to the heap, because the interface must hold a pointer. See Interfaces & Composition.
  • Closures capturing a variable by reference: if a closure outlives the function and mutates a captured local, that local escapes.
  • Slices/maps whose size isn’t known at compile time (make([]T, n) with dynamic n) generally heap-allocate the backing array.

The alloc-avoidance code you saw earlier — normalizeKeywordCase returning the original string, HasPrefixAtScanPos keeping string(b) == ... inline — is written against this model: the goal is to keep the hot path from producing heap garbage that the GC must later collect.

You cannot tell from reading source whether a given value escapes — it depends on the compiler. You observe it by asking the compiler to print its decisions:

observe escape decisions
go build -gcflags=-m ./go/common/parser/...

This prints lines like ([]byte)(input) escapes to heap and moved to heap: x, each tagged with the source position. Higher verbosity (-gcflags='-m -m') explains why. For allocation counts in benchmarks, go test -bench=. -benchmem reports allocs/op. See Debugging & Profiling.

Defined types vs aliases (memory-relevant recap)

Section titled “Defined types vs aliases (memory-relevant recap)”

One more distinction that interacts with everything above. type Value []byte defines a new named type with its own method set — it is not []byte for method/interface purposes, even though it has the same memory layout. type TokenType = int (note the =) is a pure alias: TokenType and int are the same type, fully interchangeable, sharing one identity and one method set.

go/common/parser/tokens.go
// This is now just an alias for int to maintain compatibility during transition
type TokenType = int
go/common/sqltypes/sqltypes.go
type Value []byte

You can pass a TokenType anywhere an int is wanted and vice versa (same type). You cannot pass a plain []byte where a method expects a Value receiver, nor call Value’s methods on a bare []byte, without an explicit conversion — they’re distinct types. The alias is a refactoring shim; the named type is a real abstraction carrying behavior (IsNull, SQLLiteral). Full treatment in Types, Structs & Methods.

  1. After RowFromProto returns, you keep a single 4-byte Value from a result whose pr.Values was 2 MB. How much memory stays reachable, and why?

    AnswerAll 2 MB. The 4-byte Value is a subslice (pr.Values[offset:offset+4]) whose ptr points into the 2 MB backing array; the GC keeps the entire backing array alive as long as any slice references it. To release the rest, copy the 4 bytes out with make+copy.

  2. Why does Value use value receivers while Result and Row use pointer receivers?

    AnswerValue is a 3-word slice header — cheap to copy — and behaves like a primitive, so value receivers are fine. Result/Row are larger structs that are also nil-checked (if r == nil { return nil }), which only works on a pointer receiver, and pointer receivers avoid copying the struct on every call.

  3. Why is string(ctx.scanBuf[pos:pos+len(needle)]) == needle allocation-free, but s := string(ctx.scanBuf[...]); return s == needle is not?

    AnswerThe compiler special-cases string(b) == stringValue consumed directly by a comparison and lowers it to a memcmp without materializing the temporary string. Assigning the conversion to a variable forces the string to actually exist on the heap, defeating the optimization.

  4. You write var m map[string]int then m["x"] = 1. What happens, and what’s the fix?

    AnswerIt panics: “assignment to entry in nil map.” A nil map is readable (lookups return the zero value) but not writable. Initialize it first with m = make(map[string]int) (optionally with a capacity hint).

  1. In go/common/sqltypes/sqltypes.go, trace one byte from RowFromProto back to where pr.Values originates (look at Row.ToProto). List every []byte/Value that aliases the same backing array after RowFromProto returns, and argue whether retaining one small Value keeps the whole buffer alive.

  2. Grep go/common/parser/context.go for Return copy for safety (two hits). For each method, write the concrete aliasing bug that would occur if it returned ctx.scanBuf (or ctx.scanBuf[ctx.scanPos:]) directly instead of a make+copy.

  3. In keywords.go’s init(), explain why &Keywords[i] is used rather than &k from for _, k := range Keywords. What capacity does the make(...) request, and what does the runtime do differently because of it?

  4. Pick any three CloneSliceOf* functions in go/common/parser/ast/ast_clone.go. Classify each as a shallow make+copy (slice of values) or a per-element deep clone (slice of pointers), and explain why slices of pointers need the per-element form to fully break aliasing.

  5. Read normalizeKeywordCase and HasPrefixAtScanPos. For each, name the exact allocation a naive implementation would incur and the precise source construct that avoids it.