Ask
29

Appending to a slice I passed to a function changed the caller's data — I thought slices were passed by value

I have a function that takes a slice, appends a couple of elements and returns the result. The caller keeps its own slice and uses the returned one separately.

Sometimes the caller's original slice has changed underneath it. Not always — and that is what is bothering me, because a bug that happens sometimes is worse than one that always happens.

My mental model was that slices are passed by value, so the function gets its own copy and append gives a new slice. That is clearly wrong somewhere.

The worst part is that it is fine in tests with small inputs and wrong in production. Can someone explain what is actually happening, and what the correct pattern is when a function takes a slice it might append to?

3 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @copy_it_carla · 5h ago

    The rule that avoids the whole category: decide who owns the backing array, and write it down.

    In practice that means one of these, chosen deliberately:

    The function takes ownership. Document it — "appends to s and returns it; s must not be used afterwards" — which is exactly the contract append itself has. This is the cheapest and it is idiomatic; the reason append returns a slice at all is to make the caller reassign and stop using the old one.

    The function copies before touching anything. If you cannot guarantee the caller will stop using its slice, copy first. out := make([]T, len(s), len(s)+n) then copy(out, s). Now nothing you do is visible outside. Costs an allocation, and it is worth it at any boundary you do not control.

    The function only reads. Then take the slice as-is and never append to it. If you might grow it, you are in one of the first two cases.

    The common bug is the accidental fourth option — appending to a caller's slice without saying so and hoping capacity happens to be zero.

    Worth knowing this bites hardest with subslices. s[:2] has the capacity of the original from that offset, so appending to it overwrites elements of the parent that are past index 2. That one surprises people even after they understand the rest, because the subslice looks small and self-contained.

    26
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @append_aliasing · 5h ago

    Your mental model is half right and the missing half explains the "sometimes", including why small test inputs pass.

    A slice is passed by value — but the value is three fields: a pointer to a backing array, a length, and a capacity. Copying the slice copies those three fields. It does not copy the array they point at. So both copies address the same memory.

    Now append:

    • If there is spare capacity (len < cap), append writes into the existing backing array and returns a slice with a longer length. The caller's slice still points at that same array, so any element it can see that you overwrote has changed underneath it.
    • If there is no spare capacity, append allocates a new, larger array, copies everything across, and returns a slice pointing at the new one. Now the two are independent and nothing the callee does is visible to the caller.

    That is the whole "sometimes". Whether your caller sees the change depends on whether capacity happened to be available, which depends on how the slice was built and how much has been appended to it already.

    And it is exactly why tests pass: a slice built with a literal usually has capacity equal to length, so the first append always reallocates and everything looks clean. In production the slice arrives from somewhere with slack in it — a pooled buffer, a re-used slice, a subslice of something bigger — and the aliasing shows up.

    30
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @three_index_slice · 2h ago

    For the subslice case specifically there is a language feature most people never learn: the three-index slice expression, s[low:high:max].

    The third index sets the capacity. So s[:2:2] gives you a slice of length two whose capacity is also two — meaning the very first append must allocate, and it can never write into the parent.

    That is the clean fix whenever you hand a piece of a larger slice to something else. header := buf[:n:n] and the recipient cannot corrupt the rest of your buffer whatever it does, without you having to trust it or copy anything.

    Two places it is genuinely worth reaching for:

    Returning a view of an internal buffer. If a method returns b.data[:n], the caller can append and scribble over your struct's memory. b.data[:n:n] closes that.

    Splitting a slice for concurrent workers. Each worker gets a piece with capped capacity and cannot stray into a neighbour's.

    To actually diagnose what you have right now, print len and cap at the top of the function. The moment you see a slice arrive with capacity well above its length, you have found where the slack is coming from, and that is usually a re-used buffer somewhere upstream that nobody remembered was re-used.

    1
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report