Why does clear(s) before s = s[:0] matter when reusing a []*Record buffer across batches?
answer
- Reslicing moves a number, not the data
- The collector scans the whole backing array
- Spare capacity still holds live pointers
- One builtin, but the order of two lines decides it
- Zero the elements while the length still covers them
basics
~10 sReslicing to s[:0] only moves the length to zero; the pointers stay in the backing array, so the collector keeps every Record alive. clear(s) zeroes those elements, so it must run before the truncation.
solid answer
~50 sA slice is a header over a backing array, and `s = s[:0]` changes only the length — the array, and every pointer word still stored in it beyond the new length, remains reachable through the slice. So a long-lived reusable `[]*Record` buffer pins the largest batch of `Record` values it ever held, indefinitely. `clear(s)` sets `s[0:len(s)]` to the element type's zero value, which for `[]*Record` means nil, dropping those references; it does not change `len` or `cap`. The ordering is the whole point: call `clear(s)` while the elements are still within the length, then reslice. Reversed, `clear` sees a zero-length slice and does nothing. The symptom in production is an `inuse_space` heap profile where `Record` allocations stay live long after their batch finished, with the retained bytes tracking the peak batch size rather than the current one.
code
go · 10 linestype Record struct{ ID int }
var buf []*Record // long-lived, reused for every batch
func process(in []*Record) {
buf = append(buf, in...)
// ... work over buf ...
clear(buf) // nil every element the length still covers
buf = buf[:0] // shrink; spare capacity now holds only nil
}go deeper
Know that a slice is a view over a backing array and that reslicing changes only the length. That alone explains why dropped elements do not disappear.
Explain what clear does to a slice versus a map, and why the call must come before the reslice. Be precise that it never changes len or cap of a slice.
Demonstrate the diagnosis: the memory shape it produces, the inuse_space heap profile that confirms it, and the alternatives to zeroing, such as bounding retained capacity or not reusing the buffer at all.
Decide whether buffer reuse is worth its failure modes in this codebase at all. Reuse trades a measured allocation win for a subtle retention hazard that every future contributor must remember; make that call with numbers, not habit.
## The shape of the problem A slice value is three words: a pointer to a backing array, a length, and a capacity. Reslicing rewrites those words; it never touches the array. `s = s[:0]` therefore says "I am no longer interested in these elements", and says it only to your code. The array is still there, still reachable through the slice header, and still holds whatever it held. That is harmless for `[]int`. It is a retention bug for any element type that contains a pointer — `[]*Record`, `[]string`, `[]any`, a slice of structs with a pointer field, a slice of maps. Go's collector scans the whole backing array object, not the prefix your length happens to cover, so every pointer parked between `len` and `cap` keeps its target alive. The bug needs one more ingredient: the buffer must outlive the batch. If the whole slice becomes garbage when the batch ends, the array and everything it points at go together and nothing is retained. The pattern that bites is deliberate reuse — a package-level or long-lived struct field kept precisely so the allocation is paid once — where the buffer is alive for the process's whole life. ## What clear does to a slice `clear(s)` sets every element of `s[0:len(s)]` to the element type's zero value. For `[]*Record` that is nil; for `[]string` the empty string; for a struct element type, an all-zero struct. It leaves `len(s)` and `cap(s)` exactly as they were, and it does not touch anything beyond the length. That last sentence is the whole ordering rule: ``` clear(s) // s still has its length, so this nils every live element s = s[:0] // now shrink; the spare capacity holds only nil ``` Write those two lines the other way round and `clear` receives a slice of length 0 and does nothing at all — a silent no-op that looks like a fix in review. If you have already truncated and want to scrub the spare capacity anyway, you can reach it with `clear(s[:cap(s)])`, which reslices back up to the capacity first. `clear` on a map is a different operation with the same name: `clear(m)` deletes every entry, leaving an empty but perfectly usable map. It changes `len(m)` to 0, whereas `clear(s)` leaves `len(s)` untouched. Worth keeping straight, because the two behaviours sound similar when described loosely. On a map it also removes entries whose keys are NaN, which a `delete` loop cannot address at all since NaN is not equal to itself. ## Diagnosing it The production shape is a service whose resident memory settles at a level set by its worst batch and never comes back down, while allocation rates look fine. Take a heap profile and read `inuse_space`: you will see `Record` objects still live, in a count that matches the largest batch the process has handled, attributed to the allocation site that created them rather than to the buffer. The buffer itself shows up as a small, unremarkable array. The tell is the mismatch — live objects whose logical owner has long since returned. An easy confirmation without profiling: log `len` and `cap` of the reused buffer. A capacity that has ratcheted to the peak batch size and stayed there, on a slice whose length is usually small, is the retention pattern in one line. ## The fix, and its alternatives Zeroing before truncating is the direct fix and costs one pass over the live elements. Alternatives worth weighing: * **Do not reuse the buffer.** If batches are infrequent or small, allocating a fresh slice per batch is simpler and the allocator is fast. Reuse should be a measured decision, not a default. * **Cap the retained capacity.** Reuse the buffer only while `cap` stays under some bound, and drop it otherwise, so one pathological batch does not pin memory forever. * **Store values instead of pointers.** A `[]Record` of pointer-free structs retains nothing beyond the array itself, which sidesteps the problem — at the cost of copying. ## Before the builtin existed The `clear` builtin arrived in Go 1.21. Older code does the same job with an explicit loop, `for i := range s { s[i] = nil }`, and emptied maps with `for k := range m { delete(m, k) }` — a pattern the compiler has long recognised and turned into a fast bulk clear. Both loops are still correct; `clear` is shorter, works generically over a type parameter, and states the intent, which is why it reads better in a review than a loop whose purpose is easy to misread as dead code.
- Does clear(s) touch the spare capacity between len(s) and cap(s)?No. It only zeroes `s[0:len(s)]`, so anything parked past the length keeps its pointers. That is why you clear before truncating; if you have already truncated, reach the rest with `clear(s[:cap(s)])`, which reslices back up to the capacity first.
- How does clear behave differently on a map than on a slice?`clear(m)` deletes every entry, so `len(m)` becomes 0 and the map stays usable — and it removes NaN keys that a delete loop cannot reach. `clear(s)` changes no lengths at all: it assigns the zero value to each element in `s[0:len(s)]`, leaving `len` and `cap` as they were.
- Which profile would you take to confirm the retention, and what would you look for?An `inuse_space` heap profile. Look for live `Record` objects whose count tracks the largest batch the process has handled rather than the current one, attributed to the site that allocated them. The reused array itself looks small and innocent; the mismatch is the evidence.
- When is this retention not worth fixing?When the buffer does not outlive the batch. If the slice becomes garbage at the end of the call, the backing array and everything it references are collected together. The bug needs a long-lived buffer plus pointer-bearing elements; without both, `s = s[:0]` on its own is fine.
saying these in an interview costs you the question
- Thinks s = s[:0] releases the elements it dropped
- Calls clear after truncating and expects it to zero anything
- Believes clear(s) sets the slice length to zero
- Assumes the collector ignores elements past len(s)
- Says the retention happens even for a short-lived local buffer