---
title: 'Slice Operations'
source: 'https://academia.sh/en/courses/go-fundamentals/slice-operations'
course: 'Go Fundamentals'
language: en
updated: '2026-10-21T05:03:05+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Reads the default values of slicing's unwritten bounds"
    - 'Measures whether the same append call continues or cuts off sharing depending on capacity'
    - "Uses copy and the three-index slice expression as two notations that close sharing's unpredictability"
---

# Slice Operations

The same append call, with the same source text, gives two different results at two different capacities: sharing continues within the capacity limit and writing to the derived slice changes the source, while sharing is cut off once capacity is exceeded and the source stays untouched. Two notations close this gap: an independent copy taken with copy, and the three-index slice expression that explicitly bounds capacity.

The previous lesson closed with a question: the same `append` call, with the same source
text, gives two different results at two different capacities. The Arrays and Slices lesson
showed sharing only in direct assignment — in a line like `shareSlice := sourceSlice`. This
lesson looks at the more commonly encountered form of sharing: another slice derived from a
slice by **slicing**.

Slicing is one of the most frequently seen operations in Go source: walking a file's lines,
separating a network packet's header from its body, taking a particular section of a text all
use the same syntax. This prevalence also raises the cost of not knowing how sharing works —
because every slicing line establishes an invisible bond.

## The Default for an Unwritten Slice Bound

A slicing expression has the form `source[start:end]`, and one or both of the two bounds may
go unwritten. This was exactly one of the fifteen unwritten cases the Zero Values lesson
measured: leaving slice bounds unwritten is **defined** — it corresponds to `0` when the start
is unwritten and to the slice's length when the end is unwritten.

```go
// bounds.go — the unwritten ends of a slicing bound
package main

import "fmt"

func main() {
	source := []int{10, 20, 30, 40}
	fmt.Println("source[:] :", source[:])
	fmt.Println("source[1:]:", source[1:])
	fmt.Println("source[:2]:", source[:2])
}
```

```
source[:] : [10 20 30 40]
source[1:]: [20 30 40]
source[:2]: [10 20]
```

All three are derived from the same `source` and all three point to the same backing array;
none of them takes a copy. `source[:]` leaves both bounds to their default and covers the
whole slice, `source[1:]` writes only the start, `source[:2]` writes only the end. The numbers
these three lines produce are the same as the Zero Values lesson's binding table; there, 8 of
the 15 unwritten cases fell into the **defined** class, and slice bounds were one of them.

An unwritten bound and a **wrongly written** bound are not the same thing. When the end was
left blank, it fell back to the slice's length; when the end is written explicitly and
exceeds capacity, the result is not a defined value, it is a run-time stop.

```go
// overrun.go — an unwritten bound is defined, a bound exceeding capacity is open
package main

import "fmt"

func panics(f func()) (p bool) {
	defer func() {
		if recover() != nil {
			p = true
		}
	}()
	f()
	return false
}

func main() {
	source := []int{10, 20, 30, 40}
	validPanicked := panics(func() { _ = source[1:3] })
	fmt.Println("source[1:3] (valid range)         panic=", validPanicked)
	overrunPanicked := panics(func() { _ = source[1:9] })
	fmt.Println("source[1:9] (end exceeds capacity) panic=", overrunPanicked)
}
```

```
source[1:3] (valid range)         panic= false
source[1:9] (end exceeds capacity) panic= true
```

A bound left blank and a bound written too large fall into two separate classes here: the
first is a defined value the language fills in itself, the second is an error the compiler
cannot catch and that is left to run time. The difference is not whether it is written, it is
whether the written value stays within capacity.

## Writing to a Derived Slice Changes the Source

Slicing does not produce a new backing array; the derived slice points to the **same** backing
array the source points to, with a different length and starting point. This is why writing
to the derived slice also changes the source.

```go
// derived.go — writing to a slice derived by slicing changes the source
package main

import "fmt"

func main() {
	source := []int{10, 20, 30, 40}
	derived := source[1:3]
	derived[0] = 99
	fmt.Println("derived:", derived)
	fmt.Println("source :", source)
}
```

```
derived: [99 30]
source : [10 99 30 40]
```

`derived := source[1:3]` produces a two-element slice covering the second and third elements
(`20`, `30`). When `derived[0] = 99` is written, what changes is not `derived`'s own data, it
is the second element of the backing array shared with `source`; this is why `source[1]` also
becomes `99`. This is the same sharing seen in direct assignment in the Arrays and Slices
lesson — only how the slice was obtained changed, the rule itself did not.

This sharing also continues among more than two slices, because all of them stay bound to the
same backing array. When two slices are derived from the source so that they **overlap**, a
write to the overlapping position is visible from both.

```go
// overlapping.go — two slices overlapping from a source, a write to the shared position shows in both
package main

import "fmt"

func main() {
	source := []int{10, 20, 30, 40, 50}
	left := source[0:3]
	right := source[2:5]
	left[2] = 999 // left's last element, the source's third element, is right's first element
	fmt.Println("left  :", left)
	fmt.Println("right :", right)
	fmt.Println("source:", source)
}
```

```
left  : [10 20 999]
right : [999 40 50]
source: [10 20 999 40 50]
```

`left` covers the first three elements, `right` covers the last three; the two overlap at the
third position (`source[2]`). When `left[2] = 999` is written, the one memory position that
changes is exactly the one all three — `left`, `right` and `source` — share; this is why the
change is visible in all three. It does not matter how many variables point to the same
backing array, what matters is only which positions overlap.

## The Same Call, Two Capacities, Two Results

Sharing up to this point was predictable: as long as a slice existed, every slice derived from
it shared the same backing array with it. `append` breaks this predictability, because it does
two different things depending on whether it exceeds capacity: if it does not, it writes to
the existing backing array and sharing continues; if it does, it allocates a new backing array
and sharing is cut off. The source text can be exactly the same in both.

**CT5.** — Both slices are filled with the same three elements, in the same order; the only
variable that creates the difference is capacity. Capacity is given explicitly as the second
argument to `make` so that the difference is not left to run-to-run chance.

```go
// growth.go — the same append call, two capacities, two results
package main

import "fmt"

func add(s []int, value int) []int {
	return append(s, value)
}

func main() {
	roomy := make([]int, 3, 5) // two extra slots in capacity
	copy(roomy, []int{1, 2, 3})
	full := make([]int, 3, 3) // no extra room in capacity
	copy(full, []int{1, 2, 3})

	roomy2 := add(roomy, 4)
	roomy2[0] = 100
	fmt.Println("roomy (cap=5) after roomy:", roomy, " roomy2:", roomy2)

	full2 := add(full, 4)
	full2[0] = 100
	fmt.Println("full  (cap=3) after full:", full, " full2:", full2)
}
```

```
roomy (cap=5) after roomy: [100 2 3]  roomy2: [100 2 3 4]
full  (cap=3) after full: [1 2 3]  full2: [100 2 3 4]
```

The `add` function was called on both with the **same** source text: `append(s, value)`.
`roomy` carried two extra slots in its capacity; `append` used that room, did not grow the
backing array, and the `roomy2` it returned still points to the same backing array. This is
why `roomy` also changed when `roomy2[0] = 100` was written — sharing continued. `full`, on
the other hand, was at full capacity; `append` allocated a new backing array, and `full2`
pointed to that new backing array. `full2[0] = 100` this time changed only `full2`, `full`
stayed untouched.

This is the course's clearest line falling into the **open** class: the language guarantees
that `append` will allocate a new backing array when capacity is exceeded, but whether the
result will be shared with the source cannot be read from the source code. The same line —
`append(s, value)` — continues sharing in one call, cuts it off in another; a reader cannot
say which it will be without knowing the capacity.

The place where this uncertainty most often causes confusion in practice is functions that
grow a slice. If a function adds elements to the slice it receives as a parameter with
`append` and does not return the result, the caller's variable is **never** updated,
regardless of capacity — even when capacity would have been enough.

**CT6.** — `buffer`'s capacity here is deliberately kept ample (room for ten elements, three
elements will be added); the goal is to remove capacity shortage from the list of possible
explanations. Had capacity been kept tight, why the result did not change could be attributed
to two causes at once, and the measurement would become ambiguous.

```go
// accumulate.go — append that does not return its result never updates the caller
package main

import "fmt"

func appendUnsafely(target []int, source []int) {
	for _, v := range source {
		target = append(target, v)
	}
	fmt.Println("target inside function:", target)
}

func main() {
	buffer := make([]int, 0, 10) // an empty accumulator with ample capacity
	appendUnsafely(buffer, []int{1, 2, 3})
	fmt.Println("buffer at the caller  :", buffer)
}
```

```
target inside function: [1 2 3]
buffer at the caller  : []
```

`buffer`'s capacity was ten, more than enough for three elements — sharing could have
continued as in the previous measurement's `roomy` example. But `appendUnsafely` assigns every
`append` result to its own local `target` variable and returns nothing; when the function
returns, `buffer`'s header has not changed at all, `len(buffer)` is still zero. Even capacity
being sufficient does not rescue sharing in this case, because the problem is not in the
backing array, it is that the updated header is never carried back to the caller.

## copy and the Three-Index Slice Expression: Two Notations That Close the Gap

This unpredictability can be closed with two notations. The first is the `copy` built-in
function: it takes the target and the source as two slices with separate backing arrays,
copies the elements one by one, and returns the number of elements copied.

```go
// copying.go — an independent copy with copy, a bounded capacity with the three-index slice
package main

import "fmt"

func add(s []int, value int) []int {
	return append(s, value)
}

func main() {
	source := []int{10, 20, 30, 40}

	independent := make([]int, len(source))
	count := copy(independent, source)
	independent[0] = 7777
	fmt.Println("copy count:", count, " independent:", independent, " source:", source)

	bounded := source[1:3:3]
	fmt.Println("bounded length=", len(bounded), " capacity=", cap(bounded))
	bounded2 := add(bounded, 999)
	bounded2[0] = 8888
	fmt.Println("bounded2:", bounded2, " source:", source)
}
```

```
copy count: 4  independent: [7777 20 30 40]  source: [10 20 30 40]
bounded length= 2  capacity= 2
bounded2: [8888 30 999]  source: [10 20 30 40]
```

After the call `copy(independent, source)`, `independent` has its own backing array; changing
its first element does not affect `source` at all. **CT7.** — The returned `count` value is
not assumed anywhere, it is printed directly; `copy` copies the **smaller** of the target's or
source's length, and since both are four in this example, it copies four.

`copy`'s target and source do not have to be in two independent backing arrays; it works
correctly even if the two point to the **same** backing array. This is useful for shifting
elements within a single slice.

```go
// shifting.go — copy also works correctly on overlapping ranges within the same backing array
package main

import "fmt"

func main() {
	slice := []int{1, 2, 3, 4, 5}
	shifted := copy(slice[0:], slice[1:4]) // shift the second to fourth elements one position left
	fmt.Println("shifted count:", shifted, " slice:", slice)
}
```

```
shifted count: 3  slice: [2 3 4 4 5]
```

`slice[1:4]` and `slice[0:]` are two overlapping slices of the same backing array; `copy`
shifts the second through fourth elements one position to the left, and the last element
(`5`) stays in place, because the shift covered only the three elements copied. This
behavior is not chance, it is part of `copy`'s own promise: even when source and target
overlap, the result is one where the elements are copied **correctly**, not merely in
order. A loop written by hand, advancing index by index to do the same operation, could
write over elements early depending on the shift direction and corrupt the data; `copy`
does not carry this risk.

The second notation is the three-index slice expression: `source[1:3:3]` specifies not only
the start and end, but also the **capacity** with a third value. `bounded`'s capacity here is
set equal to its length — no extra room is left. This is why the `append` call on `bounded`
**certainly** exceeds capacity and allocates a new backing array; when `bounded2[0] = 8888` is
written, `source` stays untouched. In the earlier section's `full` example, capacity being
exact was a coincidence (the three-element literal was built with exactly three capacity); the
three-index slice expression **removes this from chance** and turns it into a notational
decision — it locks capacity to the desired limit.

**CT8.** — The two closing notations serve two separate purposes: `copy` is used when a
completely separate copy of the data is wanted, while the three-index slice expression is used
when sharing the same backing array with the source is meant to continue, and only wants to
**guarantee** that a later `append` will cut that sharing off. The first ends sharing from the
start, the second decides in advance that growth will cut sharing off.

What these two notations have in common is that neither leaves capacity to the code reader's
guess. `copy` removes capacity from the subject entirely — whatever the target's own capacity
is, that is what gets used, with no relationship left to the source's capacity. The
three-index slice expression, by contrast, does not zero out capacity, it **narrows** it:
normally the notation `source[1:3]` would inherit all the remaining capacity (up to the end of
the source), and the third index cuts off that inheritance, confining the slice to its own
size. Both arrive at the same outcome — making sure a later `append` will not touch the
source — but with different costs: `copy` requires a copying operation, the three-index slice
expression moves no data at all, it only bounds a number.

What the three sections measure comes down to a single sentence: slicing always shares;
`append` shares or does not, depending on capacity; `copy` and the three-index slice
expression turn sharing into a notational-level decision. Looking at the source text, the
answer to "does this line share" is known in advance in the third case; in the first two it is
not known, it is learned only by running.

## Summary

- A slicing expression's unwritten bounds are defined: `0` is used when the start is
  unwritten, the slice's length when the end is unwritten; this is an example that falls into
  the defined class of the Zero Values lesson's binding 8/4/3 distribution.
- A slice derived by slicing points to the same backing array as the source; writing to the
  derived slice also changes the source.
- As long as `append` does not exceed capacity, it writes to the existing backing array and
  sharing continues; once it exceeds capacity, it allocates a new backing array and sharing is
  cut off. The same source text can give two different results at two different capacities.
- `copy` copies elements one by one into a backing array independently owned by the target
  and returns the number of elements copied; it leaves no sharing with the source.
- The three-index slice expression (`source[start:end:capacity]`) explicitly bounds capacity
  and guarantees, at the notational level, that a later `append` will separate from the
  source.

## Next Step

The sharing measured in these two lessons was among ordered structures like arrays and
slices; the next topic looks at key-value matching. A map is also a header and shares an
underlying structure, but the question it asks is different: what happens when a key that
does not exist is accessed, and does that answer arrive as a single value or two values? The
next lesson separates these two — and also why iteration order is never printed directly.
