skip to content

sync.Pool and Reuse

The one runtime mechanism for reusing memory instead of allocating it: sync.Pool holds per-P caches whose objects can vanish at any GC cycle. Interviewers ask because candidates treat it as a cache.

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

questions

4

Why does `make([]byte, 0, 4096)` cut allocations, and how does it differ from `make([]byte, 4096)`?

level: juniorimportance: must knowfreq 60%

answer

  1. count allocations, not bytes
  2. append copies when it runs out
  3. length is visible, capacity is reserved room
  4. the third argument, with length zero

basics

~20 s

make([]byte, 0, 4096) reserves a 4096-byte array but starts with length 0, so appends fill it without reallocating. make([]byte, 4096) sets the length to 4096, so the slice already holds 4096 zero bytes and append adds data after them.

solid answer

~50 s

A slice is a pointer to a backing array plus a length and a capacity. `make([]byte, 0, 4096)` allocates one 4096-byte array with length 0 and capacity 4096, so the first 4096 bytes you append land in that array with no further allocation; starting from `var buf []byte` instead, `append` has to allocate a larger array and copy everything written so far several times as the data grows. `make([]byte, 4096)` is a different thing: it sets the *length* to 4096, so the slice already contains 4096 zero bytes and `append` writes after them — the classic bug that produces output with 4 KB of NUL bytes in front. Use the length form when you will fill by index or with `copy`, and the length-0-with-capacity form when you will append. Maps take a size hint too: `make(map[string]int, n)`.

code

go · 11 lines
go
data := []byte("hello")

// Wrong when you are going to append: 4096 zero bytes come first.
buf := make([]byte, 4096)
buf = append(buf, data...)
fmt.Println(len(buf)) // prints: 4101

// Right: length 0, capacity 4096, no growth while it fits.
buf = make([]byte, 0, 4096)
buf = append(buf, data...)
fmt.Println(len(buf), cap(buf)) // prints: 5 4096

go deeper

for a junior

Be ready to state the three parts of a slice and what each argument to make sets. Show that make([]byte, 0, n) starts empty while make([]byte, n) starts with n zero bytes already in it.

for a middle

Explain what append does when capacity runs out — allocate a larger array, copy the existing elements, return a new header — and why reserving room up front turns several allocations and copies into one.

for a senior

Show judgment about where preallocation is worth it: a known element count in a hot loop, yes; a capacity taken from an untrusted length field, never, because that hands a caller a memory-exhaustion lever.

for a principal

Own the guidance rather than the trick. Say when the team should preallocate by default, when an oversized reservation costs more memory than the allocations it saves, and how you stop it becoming cargo-cult advice applied to cold paths.

## What a slice actually is A Go slice value is three words: a **pointer** to a backing array, a **length**, and a **capacity**. `len(s)` is how many elements are visible and indexable; `cap(s)` is how many the backing array can hold counting from the slice's first element. Only the length is a statement about data you have; the capacity is reserved room you already own. ## The two forms of `make` `make` takes a length and an optional capacity: - `make([]byte, 4096)` — length 4096, capacity 4096. The slice already **contains** 4096 zero bytes. - `make([]byte, 0, 4096)` — length 0, capacity 4096. The array is allocated and empty; nothing is visible yet. - `make([]byte, 10, 4096)` — length 10, capacity 4096. Ten zero bytes visible, room for 4086 more appends. All three perform exactly one allocation. What differs is where `append` starts writing. ## Why the capacity argument removes allocations `append` writes into the spare capacity when there is room. When there is not, it allocates a **new, larger** backing array, copies the existing elements across, writes the new ones, and returns a slice header pointing at the new array — which is why you must always write `s = append(s, x)`. Building a 4 KB payload from `var buf []byte` therefore costs several allocate-and-copy rounds: each round allocates a bigger array, copies everything written so far, and abandons the old array as garbage the collector must later sweep. Starting from `make([]byte, 0, 4096)` costs exactly **one** allocation and **zero** copies, as long as the data fits. That is the whole win: fewer allocations, no repeated copying, and less garbage. ## The trap the length form sets The commonest bug in this area is reaching for `make([]byte, n)` and then appending: ``` buf := make([]byte, 4096) buf = append(buf, data...) // len is now 4096 + len(data) ``` `append` adds **after** the existing elements, and the existing elements are 4096 zero bytes. The symptom is a response or a file that starts with 4 KB of NUL bytes and is 4 KB longer than expected. Pick the form by how you will fill the slice: - filling by **index** (`buf[i] = x`, `copy(buf, src)`, `r.Read(buf)`) — use the **length** form. - filling by **append** — use **length 0 with a capacity**. ## Maps take a hint too `make(map[string]int, 1000)` sizes the map's initial table for roughly a thousand entries so the runtime does not grow and rehash repeatedly while you fill it. It is a hint, not a limit: the map still grows past it, `len(m)` is 0 right after the call, and there is no `cap` for maps. ## Where preallocation pays, and where it hurts It pays when you know the count in advance and the loop is hot: `out := make([]Row, 0, len(rows))` before appending one element per input row turns a handful of allocations into one, on every call. It does not pay, or actively hurts, when: - the slice is tiny — one small allocation is cheap and the code is clearer without the ceremony; - you cannot estimate the size, and a wild guess reserves memory you never use; - the reservation is far too large — the **whole** backing array stays alive as long as any slice of it is reachable, so a 1 MB array kept for a 200-byte result wastes 1 MB indefinitely; - the capacity comes from **untrusted input**. `make([]byte, 0, hdr.Length)` where `hdr.Length` is client-supplied is a memory-exhaustion lever: validate against a maximum first, or grow naturally and let `append` bound the damage. ## Reserving is not the same as reusing Preallocating removes the allocations *within* one construction. Keeping the same buffer alive and refilling it removes the allocation *between* uses — the same array serving iteration after iteration, or request after request. They compose: reserve a sensible capacity once, then reuse that buffer rather than making a new one each time. ## What an interviewer is listening for That you can name the three parts of a slice header; that you know `make([]byte, n)` and `make([]byte, 0, n)` differ in the *length*, not the amount of memory allocated; that you can say what `append` does when capacity runs out; and that you do not treat "always preallocate" as a rule — you can name a case where reserving costs more than it saves.

  • When is preallocating a capacity not worth it?
    When the slice is small, when you cannot estimate the final size, or when the guess is far too large — the whole backing array stays alive as long as any slice of it is reachable, so an oversized reservation wastes memory indefinitely. And a capacity taken from a client-supplied length is a memory-exhaustion lever: validate against a maximum before you reserve anything.
  • Does the second argument to make(map[string]int, 1000) work like a slice's capacity?
    It is a hint, not a limit. The runtime sizes the map's initial table for roughly that many entries so it does not have to grow and rehash repeatedly while you fill it, but the map still grows past the hint. `len(m)` is 0 immediately after the call, and there is no `cap` for maps.
  • How do you preallocate when the elements are already in hand?
    Either `make([]T, 0, len(src))` and append, or `make([]T, len(src))` and assign by index — both allocate once. For a straight copy, `dst := make([]T, len(src))` followed by `copy(dst, src)` is the direct form; `copy` returns the number of elements copied, which is the smaller of the two lengths.

Reserving capacity is like booking a table for eight when you arrive alone: the room is already yours, but nobody is seated yet. The length form seats eight silent guests before your friends turn up.

saying these in an interview costs you the question

  • Says make([]byte, n) and make([]byte, 0, n) are interchangeable
  • Thinks capacity is a hard limit a slice can never exceed
  • Believes append always allocates a new backing array
  • Reserves capacity straight from a client-supplied length field
  • Cannot say what append does when capacity runs out
open as a page

In Go, what does `buf = buf[:0]` reuse, and what stays in the underlying array?

level: middleimportance: should knowfreq 45%

basics

~20 s

buf[:0] keeps the same backing array, pointer and capacity and only sets the length to 0, so later appends overwrite from index 0 with no new allocation. The old bytes stay in the array; nothing is zeroed or freed.

open as a page

A sync.Pool of output buffers accepts rare 30 MB buffers alongside typical 4 KB ones — why does memory stay high, and what guard fixes it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

sync.Pool has no size limit: it retains whatever you Put until a collection clears it, and it caches per P, so several oversized buffers stay live at once. Guard the release path — drop any buffer whose capacity exceeds a threshold.

open as a page

What do you require before approving sync.Pool in a shared library other teams import?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Require evidence the allocation matters, a reset enforced inside the package's own get and put wrappers rather than by callers, an API that returns nothing aliasing pooled memory, a capacity guard on release, and a test proving a reused object comes back clean.

open as a page