When you only need a piece of the data
You are reading a stream of bytes from a network socket. The first ten bytes are a protocol header. The next fifty are the actual payload. You only care about the payload right now, but you do not want to allocate a new buffer and copy fifty bytes into it. You just want to look at that chunk. Go gives you a way to carve out a view of the data without touching the underlying memory. That view is a sub-slice.
How a slice actually works
A Go slice is not an array. It is a lightweight descriptor that points to an array, tracks how many elements it currently sees, and tracks how much space is available behind the scenes. Think of it like a movable window on a long wall of tiles. The window has a left edge and a right edge. When you create a sub-slice, you are not painting new tiles. You are just sliding the window edges to focus on a smaller section. The tiles underneath stay exactly where they are.
This design makes slicing incredibly fast. It takes constant time because the runtime only copies the three header fields. The tradeoff is that the new slice and the original slice share the same backing array. Change a value through the sub-slice, and the original sees it too. The runtime does not track ownership of the array elements. It trusts you to know what you are looking at.
The basic syntax
Here is the simplest way to carve out a range. The syntax uses two indices separated by a colon. The left index is inclusive. The right index is exclusive.
// numbers holds ten integers backed by a single array
numbers := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// low is 2, high is 5. The slice sees indices 2, 3, and 4.
sub := numbers[2:5]
// prints [2 3 4]
fmt.Println(sub)
You can drop either side of the colon. Omit the left side to start at zero. Omit the right side to extend to the current length of the slice. If both sides match, you get an empty slice that still points to the original array.
// starts at index 3, runs to the end of the slice
rest := numbers[3:]
// starts at zero, stops before index 4
firstFour := numbers[:4]
// empty slice, length zero, but capacity matches the original
empty := numbers[2:2]
Slicing is cheap. The window moves instantly.
What happens at runtime
Under the hood, every slice carries a pointer to the backing array, a length, and a capacity. When you write numbers[2:5], the compiler generates code that copies the pointer, sets the length to 5 - 2, and sets the capacity to original_capacity - 2. The new slice header points to the exact same memory address.
This shared backing array is where most confusion starts. Because the data is shared, mutations are visible across all slices that overlap that region. If you need a slice that lives completely apart from the source, you must allocate new memory and copy the values yourself. The standard library provides copy for exactly this purpose. It returns the number of elements actually transferred, which is the minimum of the two slice lengths.
// original points to an array of five integers
original := []int{10, 20, 30, 40, 50}
// sub points to the same array, starting at index 1
sub := original[1:3]
// modifies the second element of the shared array
sub[0] = 999
// prints [10 999 30 40 50]
fmt.Println(original)
If you need isolation, allocate fresh space and copy the data. The community convention is to ignore the return value of copy when you already know the lengths match, using the blank identifier to signal intentional discard.
// allocate a new slice with the exact length needed
independent := make([]int, len(sub))
// copies elements from sub into independent
// _ discards the return count since lengths are guaranteed equal
_ = copy(independent, sub)
// changes here stay isolated from original
independent[0] = 123
Copying breaks the shared memory link. You trade speed for safety.
Controlling capacity with three indices
Sometimes you want to limit how far a sub-slice can grow. The two-index form low:high sets the length, but the capacity extends all the way to the end of the original backing array. Go provides a three-index form low:high:max to cap the capacity explicitly. This is useful when you hand a slice to a function that might call append and you want to guarantee it allocates new memory instead of overwriting adjacent data.
// source has length 5 and capacity 5
source := []int{1, 2, 3, 4, 5}
// length is 2, capacity is capped at 3 (indices 1, 2, 3)
bounded := source[1:3:4]
// append triggers a reallocation because capacity is exhausted
bounded = append(bounded, 99)
// prints [1 2 3 4 5]
fmt.Println(source)
The third index tells the runtime where the usable capacity ends. If you omit it, capacity defaults to the end of the backing array. Use the three-index form when you need to enforce a hard boundary on growth.
Real-world buffer processing
Real code rarely slices static arrays. It slices buffers that grow, shrink, or arrive from external systems. Consider a simple log parser that receives a raw byte buffer and needs to extract a timestamp and a message. The buffer might contain multiple log lines, but you only process one at a time.
// raw holds a single log line as bytes
raw := []byte("2024-01-15 10:00:00 ERROR: disk full")
// timestamp ends at the first space after the date
// we slice up to index 19 to grab the date and time
timestamp := raw[:19]
// skip the space and grab the rest of the line
message := raw[20:]
// prints 2024-01-15 10:00:00
fmt.Printf("%s\n", timestamp)
// prints ERROR: disk full
fmt.Printf("%s\n", message)
This pattern avoids allocating new byte slices for every log line. The parser hands off timestamp and message to different handlers, and both handlers read from the same underlying buffer. As long as the handlers finish before the buffer is reused or overwritten, the shared memory is a performance win. Go conventions favor passing slices by value in function signatures. The slice header is only 24 bytes on 64-bit systems, so copying the header is cheaper than passing a pointer to a pointer.
Pitfalls and runtime checks
The runtime enforces slice bounds strictly. If you ask for an index that exceeds the slice length, the program crashes immediately. The panic message tells you exactly which index broke the rule.
// data has length 3
data := []int{1, 2, 3}
// high index 5 exceeds the length
bad := data[1:5]
Running this triggers runtime error: slice bounds out of range [1:5] with length 3. The runtime checks low and high against the slice length before creating the header. You cannot slice past the end of the current view.
The trickier trap involves capacity and append. A sub-slice inherits the remaining capacity from the original slice. If you append to the sub-slice, it writes into the unused space of the shared array. That space might still be visible to the original slice.
// nums has length 5 and capacity 5
nums := []int{1, 2, 3, 4, 5}
// sub starts at index 1, length 1, capacity 4
sub := nums[1:2]
// append writes into the shared array at index 2
sub = append(sub, 99)
// prints [1 2 99 4 5]
fmt.Println(nums)
The append call did not allocate a new array because the sub-slice had spare capacity. It overwrote the 3 that nums still considers part of its data. This is not a bug in the language. It is a consequence of shared backing arrays. If you need to append to a slice without risking collateral damage, copy it first or create a fresh slice with make.
Go conventions favor explicit memory management here. The community accepts the extra make and copy calls because they make data ownership obvious. Hiding allocation behind a clever slice trick usually leads to subtle corruption in production.
When to use what
Use slice[low:high] when you need a fast, zero-allocation view of a contiguous range and you control the lifetime of the backing array. Use slice[low:high:max] when you want to limit the capacity of a sub-slice to prevent accidental overwrites during append. Use copy into a pre-allocated slice when you need an independent copy and you already know the exact size. Use make followed by copy when you want a clean break from the original data and you are okay with the allocation cost. Use append on a sub-slice only when you intentionally want it to mutate the shared backing array or when you have verified that the capacity is isolated.
Slicing is a window. Copying is a photograph. Pick the right tool for the job.