Last Updated: May 22, 2026
A slice in Go can be built in several ways, and the form you pick affects readability, performance, and whether the slice is nil or empty out of the gate. This lesson walks through every way to create a slice, from the simplest literal to slicing an existing array or slice, and shows when each form is the right choice. By the end, the choice between each form should be clear, and why pre-sizing with make matters for performance.
The shortest way to create a slice with values already in it is a slice literal. The syntax is the element type wrapped in square brackets, followed by the values in braces.
[]string{"apple", "milk", "bread"} tells the compiler two things at once: the element type is string, and the slice starts with these three values. Go allocates a backing array of length 3, stores the values, and gives you a slice header that points at it with len == 3 and cap == 3. The square brackets are empty because a slice has no fixed length, which is what separates []string{...} from the array literal [3]string{...}.
Slice literals work for any element type, including structs, other slices, and maps.
A trailing comma after the last element is required when the closing brace is on its own line. This is a gofmt rule, not a feature. The compiler enforces it so that you can add or reorder lines without touching neighbouring commas during a code review.
Use a slice literal when you already know the contents at the point of creation. Configuration values, fixed lookup tables, and test data all fit this pattern.
[]T{}You can also write a slice literal with no elements. The result is a slice of length 0 backed by a real (but empty) array.
[]string{} is an empty slice: length 0, capacity 0, but a non-nil pointer to a zero-sized backing array. You can call len, cap, and append on it just like any other slice. The short version is: empty slices and nil slices both have len == 0, but they're not identical when it comes to JSON encoding and direct nil comparisons.
This form is handy when you want to return "no items" from a function and have the caller iterate without a nil check. It also makes the intent obvious at a glance: "a slice that starts empty, ready to grow."
var s []T DeclarationDeclaring a slice variable without initializing it gives you a nil slice. The zero value for any slice type is nil.
var orders []string doesn't allocate a backing array. The slice header exists, but its pointer is nil, and both len and cap are 0. The trick is that append handles a nil slice gracefully: it allocates a backing array for you on the first call and returns the new slice. So even though you started with nil, you can build up the slice without any special-case code.
The one-line summary: prefer var s []T when you're declaring a slice you'll grow with append and don't need to distinguish "no result" from "empty result" in any external interface.
make([]T, len)When you want a slice of a specific length, every element pre-initialized to the zero value, use make with two arguments.
make([]int, 5) allocates a backing array of 5 int values, sets every slot to 0 (the zero value for int), and returns a slice that views the whole array. Length is 5, capacity is 5. You can index any of the five positions immediately without an append, which you can't do with a nil slice or an empty literal.
Use this form when you know the exact number of slots up front and want to fill them by index.
The strings case is similar, except the zero value is the empty string "".
Use make([]T, n) when:
make([]T, len, cap) and Why Pre-Sizing Mattersmake accepts an optional third argument: the capacity of the backing array. Length and capacity can differ, which is the whole point of slices.
make([]string, 0, 5) allocates space for 5 strings but starts with the slice viewing 0 of them. As you append, the length grows and the capacity stays at 5. No new backing array is needed until you push past the fifth element.
This is the standard form for "I'm going to build a slice with append, and I have a good estimate of the final size." Growing forces an allocation and a copy of everything you've already added, and pre-sizing the capacity avoids that work entirely.
Cost: Each time append outgrows the backing array, Go allocates a new (larger) array and copies the existing elements over. For a slice you append to N times without sizing, you pay for several reallocations and copies. make([]T, 0, N) avoids all of them when N is known.
Here's a side-by-side that makes the cost concrete.
The first slice reallocates four times (capacities 1, 2, 4, then 8). The second never reallocates. For 8 elements that's barely noticeable, but in a loop that builds a slice of a million records, pre-sizing turns dozens of allocations into one.
The second and third arguments to make have distinct meanings:
| Call | Length | Capacity | Indexable Now? |
|---|---|---|---|
make([]int, 5) | 5 | 5 | Yes, all 5 positions are zeroed |
make([]int, 0, 5) | 0 | 5 | No, you must append first |
make([]int, 3, 5) | 3 | 5 | Yes, first 3 positions, but append won't reallocate until you cross index 4 |
The form you want depends on whether you're filling by index (use length-based) or by append (use capacity-based).
Every array can be sliced. The slice doesn't copy the array; it points at a window of the existing storage.
The expression products[low:high] produces a slice that starts at index low (inclusive) and ends at index high (exclusive). Either bound can be omitted: products[:3] is the same as products[0:3], products[2:] runs to the end, and products[:] covers the whole array.
The important thing for this lesson is that slicing an array is a way to create a slice. The resulting slice shares storage with the array.
Writing through the slice changed products[1] because the slice and the array share storage. This sharing is a feature, not a bug, but it's why you should think twice before handing a slice of an internal array to outside code: that code can mutate your private data.
If you need an independent copy, allocate a fresh slice and copy into it.
Cost: Slicing an array is O(1). No data is copied, just three words (pointer, length, capacity) are filled in. The trade-off is the shared backing array, which keeps the entire array alive in memory for as long as the slice exists.
The same low:high syntax works on a slice as well. The result is another slice that views part of the same backing array.
Both pageOne and pageTwo are real, independent slices: they have their own length, but they share the backing array of allItems. The capacity of each sub-slice is the distance from its starting index to the end of the original backing array. So pageOne starts at index 0 with capacity 5 (the whole array), and pageTwo starts at index 2 with capacity 3 (positions 2, 3, 4).
The pattern comes up frequently: paging through results, taking a prefix, trimming the last element, breaking a buffer into chunks.
These two slices don't allocate or copy anything. They reuse the same backing array, which makes them effectively free.
Sometimes you have a pointer to an array (not the array itself) and you want a slice over it. The cleanest, safe way is to dereference the pointer and slice the result.
(*ptr)[:] reads as "take the array ptr points to and slice the whole thing." The resulting slice shares storage with the original array, just like slicing the array directly would.
Since Go 1.17, you can also slice an array pointer without the explicit dereference. ptr[:] is shorthand for (*ptr)[:] when ptr is a pointer to an array.
Both forms produce the same slice. The shorthand is the current idiomatic form.
This conversion comes up most often when a function returns a pointer to an array, or when you're working with a fixed-size buffer (like a network packet header, or a hash output) and you want to pass a generic []byte to a function. From this chapter's point of view, the only thing to remember is that converting *[N]T to []T is a slice expression away.
All of the constructors do the same fundamental thing (produce a slice header pointing at some backing storage), but each is best for a different situation.
| Form | Length | Capacity | Backing Array | Best When |
|---|---|---|---|---|
[]T{a, b, c} | 3 | 3 | New, sized to fit | You know the values up front |
[]T{} | 0 | 0 | New, zero-sized | You want a non-nil empty slice |
var s []T | 0 | 0 | None (nil) | You'll append and don't care about nil vs empty |
make([]T, n) | n | n | New, all zero values | You'll fill by index |
make([]T, 0, n) | 0 | n | New, n slots reserved | You'll append and know the final size |
make([]T, m, n) | m | n | New, first m zeroed | You want some initial zeroed slots plus room to grow |
arr[i:j] | j-i | len(arr)-i | Shared with arr | You want a view into an existing array |
s[i:j] | j-i | cap(s)-i | Shared with s | You want a view into an existing slice |
(*ptr)[:] or ptr[:] | len | cap of array | Shared with *ptr | You hold a pointer to an array |
A few rules of thumb:
append in a loop and n is known. This is the single biggest performance win for slice construction.append. Simple and idiomatic.