Reading contract: you do not need to know a slice's internal layout. Whenever you see an assignment, argument, return, or channel send, learn to ask two questions: what value was copied here, and does that copy still point to the same underlying data?

We will build intuition with runnable code, then confirm it with three evidence layers. Semantics come from the Go specification sections on representation of values, slice types, and for statements. Implementation claims are pinned to the Go 1.26.0 tag. Observable behavior is captured by the repository's value-semantics tests. Runtime layouts explain this version; they are not a promise that application code should access them through unsafe.

1. Start with a Copy That Still Changes the Original

a := []int{10, 20}
b := a
b[0] = 99

fmt.Println(a) // [99 20]
fmt.Println(b) // [99 20]

b := a really does copy something, but it copies a small instruction card: where the elements begin, how many are currently visible, and how much room is available. At first, both cards point to the same row of elements, so a change made through either card is visible through the other. Copying the card is not copying the warehouse it names.

That is the useful meaning of “Go only passes by value.” Assignment, arguments, returns, and channel sends all transfer values, but a value is not necessarily an entire object graph. The specification distinguishes self-contained values such as booleans, numbers, arrays, and structs from non-nil pointer, function, slice, map, and channel values that contain references to underlying data. An interface depends on its dynamic value.

Static shapeImmediate copyMay remain sharedFirst question
int, boolThe scalarNothingNo alias to follow
Array, structThe full array/struct valueReferences inside fieldsRecurse into fields
stringThe string valueImmutable byte sequenceDid slicing/conversion allocate?
slicePointer, length, capacityBacking arrayRange and capacity
map, channelA value referring to runtime stateThe same map/channelOwner and synchronization
interfaceDynamic type and dataDepends on dynamic valueInspect both parts

The table compresses those two questions. Even “the struct is copied in full” is not a deep-copy guarantee. If the struct contains a []byte, the struct and slice header are copied; both headers may still point at one array. Expand the representation one layer at a time instead of labeling only the outer type.

Keep one test in mind: the variable itself is copied, but if it contains directions to other data, the copy may still reach that same data. We will run this test through fetchd's slice, append, and result channel before returning to runtime names.

2. Find Every Copy in fetchd

The handler from Chapter I is small, yet it crosses four useful boundaries: a query returns a URL slice; range gives a string value to each goroutine; a worker sends a fetchResult through a channel; and the collector appends received elements to a result slice.

targets := r.URL.Query()["url"]

results := make(chan fetchResult, len(targets))
for _, target := range targets {
    go h.fetch(ctx, target, results)
}

batch := make([]fetchResult, 0, len(targets))
for range targets {
    batch = append(batch, <-results)
}

This is not one reference flowing unchanged. targets is a slice value; each target is a string value; the goroutine call gets another string value; the channel copies a fetchResult; and append writes the received element into the batch backing array. Values are copied at every boundary, while isolation depends on their representations.

3. Slices: Copy the Triple, Share the Backing Array

The specification describes a slice as a descriptor for a contiguous segment of an underlying array, with a length and capacity. The current runtime writes that model as three internal fields:

// src/runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
Pinned source: runtime.slice. This is an internal layout, not a public API.

After b := a, b has its own triple. Reslicing b does not change a.len. Initially, however, both array fields match, so indexed writes pass through different headers into the same array.

original := make([]string, 2, 4)
copyOfHeader := original

copyOfHeader[0] = "changed"          // original[0] changes too
copyOfHeader = append(copyOfHeader, "gamma")

len(original) == 2                   // each header owns its len
original[:3][2] == "gamma"           // append still wrote into shared storage
Slice copy and append ownership: two ptr len cap headers address one array; append keeps it when capacity is available, while growslice uses mallocgc and memmove to create a new array when capacity is exhausted

Why append's result must be assigned

append returns a new slice value. With spare capacity it still addresses the old array. When newLen > oldCap, compiler-generated code enters runtime.growslice. The caller must write s = append(s, x): the returned header owns the new length even without growth, and owns the new pointer when growth occurs.

Walk one growth through growslice

func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
    oldLen := newLen - num
    newcap := nextslicecap(newLen, oldCap)
    // Compute capmem by element size and reject overflow.
    p := mallocgc(capmem, et, true)
    memmove(p, oldPtr, lenmem)
    return slice{p, newLen, newcap}
}
Pinned source: growslice and nextslicecap.

The real path also separates pointer-bearing elements, runs race/msan/asan hooks, applies write barriers, and rounds allocator size classes. nextslicecap tends to double small slices and transitions smoothly toward roughly 1.25× for larger ones, but element size and allocator rounding affect the result. The growth formula is an implementation policy, not a language contract.

Alias bugs are ownership bugs in disguise

A function receiving []T gets a header copy. It can update elements in the shared range; local reslicing or appending does not update the caller's header. If an API must own input, say so and clone with append([]T(nil), input...) or slices.Clone. If it borrows, define who may mutate and whether the callee may retain it.

4. Follow Three More Sharing Boundaries

4.1 A Channel Send Copies the Element, Not an Object Graph

results <- result sends one fetchResult value. On the buffered fast path, chansend locates a ring-buffer slot and calls typedmemmove to copy the element into it:

// Suppose a future result adds a []byte field.
type payloadResult struct{ Body []byte }
payloads := make(chan payloadResult, 1)

body := []byte("ok")
payloads <- payloadResult{Body: body}
body[0] = 'N'

got := <-payloads
fmt.Println(string(got.Body)) // "Nk"

The channel copied payloadResult, including its slice instruction card, but both cards still point to the same bytes. Mutating body after the send is therefore visible to the receiver. The source below explains only how this element-value copy reaches the buffer.

if c.qcount < c.dataqsiz {
    qp := chanbuf(c, c.sendx)
    typedmemmove(c.elemtype, qp, ep)
    c.sendx++
    c.qcount++
    unlock(&c.lock)
    return true
}
Pinned source: the buffered path in runtime.chansend. An unbuffered direct handoff uses barriers and memmove in sendDirect.
A fetchResult passes through chansend and typedmemmove into a channel buffer; the struct is copied, while copied byte-slice headers may still point at shared backing storage

Today's fetchResult contains strings and integer fields. Reassigning the sender's result.Status after the send cannot rewrite the buffered copy. If a future version adds Body []byte, however, the channel copies the struct and slice header; mutations to Body elements may race with the receiver. “Transfer ownership through the channel” is a design protocol, not an automatic runtime deep copy.

4.2 Maps and Interfaces: Follow Dynamic State

Map assignment shares one map

The specification defines a map value as a reference to an implementation-specific structure. second := first copies the map value, so both names address one map. Updating through either is visible through the other. Copying a map variable is neither a snapshot nor synchronization; concurrent sharing still needs a mutex, a single owner, or immutable publication.

An interface nil has both a type and data question

Go 1.26 represents a non-empty interface as tab + data and an empty interface as _type + data. This explains a typed nil: after (*fetchResult)(nil) enters any, data may be nil while the dynamic type remains *fetchResult, so the interface itself is not nil.

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

type eface struct {
    _type *_type
    data  unsafe.Pointer
}
Pinned source: runtime.iface and runtime.eface. Application code should use language-level assertions, not inspect this layout.
var pointer *fetchResult
var value any = pointer

value == nil                  // false: a dynamic type is present
typed, ok := value.(*fetchResult)
ok && typed == nil            // true

4.3 Go 1.22+ Range Variables: One Old Trap Changed

fetchd launches goroutines inside for _, target := range targets. Since Go 1.22, iteration variables declared by a range clause with := are new variables for each iteration. This module declares go 1.26.0, so the closures capture distinct target variables; the historical target := target patch is unnecessary here.

Range variables since Go 1.22: target1, target2, and target3 belong to separate iterations and closures capture them separately; references nested inside those values remain a separate ownership question

Two boundaries remain. Variables declared before the loop and assigned with = are reused. And a fresh iteration variable is not a deep copy: ranging over [][]byte yields distinct slice variables that may still address overlapping arrays. The language change repairs variable capture, not ownership.

5. Turn “Copy” into Runnable Experiments

The repository test file separates six observations that are often conflated:

TestControlled conditionProvesDoes not prove
TestSliceHeaderCopySharesBackingArraySpare capacityIndependent headers, shared arrayEvery append shares
TestAppendMaySplitBackingArrayCapacity exhaustedGrowth can separate arraysA fixed growth ratio
TestChannelSendCopiesElementValueBuffered channelStruct copy, shallow slice fieldAutomatic ownership transfer
TestMapAssignmentSharesStateOne map valueUpdates are sharedConcurrent safety
TestInterfaceHoldingTypedNilIsNotNilTyped nil pointerInterface is non-nil with a typeStable reflection internals
TestRangeVariablesArePerIterationGo 1.26 and :=Distinct iteration variablesNo nested aliases
cd go-runtime/examples/fetchd
go test ./...
go test -race ./...
go vet ./...

A clean race run does not make ownership automatically correct; these tests create sharing sequentially. If slice-element reads and writes move to different goroutines without a happens-before edge, the race detector can then report the conflict. The lab's job is to separate byte copying from state sharing.

6. Derive API Rules from Value Semantics

  1. State borrow or ownership for parameters. Do not retain a read-only borrow; clone data that must outlive the call and expose the cost.
  2. Document mutability of returned slices and maps. Exposing an internal collection makes the caller an alias owner; copy or offer operations when needed.
  3. Audit channel messages recursively. A struct-by-value message does not deep-copy mutable slice, map, or pointer fields.
  4. Always keep the result of append. Do not depend on spare capacity or a runtime growth ratio.
  5. Read range semantics with the module version. Check go.mod and := versus = before diagnosing captures.

Keep one reusable question set: What immediate value is copied here? Which references live inside it? What do they reach? Who may mutate that state, for how long, under which synchronization? Chapter III is now available and applies the same discipline to goroutines: instead of calling one a “lightweight thread,” it follows newproc → runq → schedule → execute to show when work becomes runnable, where it runs, and where runnable latency comes from.

Source and Documentation References