How to Flatten a Slice of Slices in Go
You have a batch of user events. Each event is a list of tags. You need a single list of all tags to update a database index. The data arrives as [][]string. The function expects []string. This pattern appears whenever you aggregate nested data, process grids, or merge results from parallel workers. You need to bridge the gap between nested structure and flat consumption.
Concept in plain words
Flattening merges multiple inner slices into one contiguous outer slice. Go treats slices as lightweight headers pointing to backing arrays. A slice of slices is a header pointing to an array of headers. Flattening creates a new backing array and copies all elements from the inner slices into it. The original nested structure remains unchanged.
Think of a library with several book carts. Each cart holds a batch of books. You want to load all books onto a single delivery truck. You take the books off the carts and stack them onto the truck. The carts stay in the library. The truck holds the combined collection. The books are moved, not the carts.
Go copies values during flattening. If you flatten [][]int, the integers are copied into a new array. Modifying the flat slice does not affect the original nested slices. This isolation prevents subtle bugs where changes to the result ripple back to the source data.
Minimal example
Here's the modern way using the standard library. Go 1.21 introduced slices.Concat, which handles flattening in a single expression.
package main
import (
"fmt"
"slices"
)
func main() {
// nested holds three sub-slices with varying lengths
nested := [][]int{{1, 2}, {3, 4}, {5}}
// Concat expands the slice of slices and merges them into one flat slice
flat := slices.Concat(nested...)
fmt.Println(flat) // [1 2 3 4 5]
}
slices.Concat is the standard tool. Use it.
How slices.Concat works
The ... operator unpacks the outer slice. When you write slices.Concat(nested...), the compiler passes each inner slice as a separate argument. The function receives []int{1, 2}, []int{3, 4}, []int{5}.
Inside slices.Concat, the implementation calculates the total length by summing the lengths of all arguments. It allocates a single backing array of that exact size. Then it copies elements from each argument into the new array in order. Finally, it returns a slice header pointing to the new array.
This approach has two benefits. First, it allocates exactly once. No reallocation happens during the copy. Second, it produces a slice with capacity equal to length. The result is a tight, efficient slice ready for use.
The original nested slice is untouched. nested[0] still points to [1, 2]. The flat slice is independent. This behavior is consistent with Go's value semantics. Slices are headers, but the elements they contain are copied when you flatten.
Pre-allocate capacity. Reallocation is the silent performance killer.
Realistic example
Here's a realistic pattern: extracting fields from structs and flattening them. This happens often when preparing data for APIs or batch processing.
package main
import (
"fmt"
"slices"
)
// Post contains a list of tags
type Post struct {
Tags []string
}
// FlattenTags merges tags from multiple posts into a single slice
// FlattenTags returns tags in the order they appear across posts
func FlattenTags(posts []Post) []string {
// Prepare a slice of slices for Concat
tagSlices := make([][]string, len(posts))
for i, p := range posts {
tagSlices[i] = p.Tags
}
// Concat allocates once and copies all elements
return slices.Concat(tagSlices...)
}
func main() {
posts := []Post{{Tags: []string{"go"}}, {Tags: []string{"web"}}}
fmt.Println(FlattenTags(posts)) // [go web]
}
The function extracts Tags from each post into a temporary [][]string. Then slices.Concat merges them. The extraction loop is necessary because slices.Concat expects a slice of slices, not a slice of structs.
Extract, then flatten. Keep the transformation separate from the merge.
Pre-1.21 and pitfalls
Before Go 1.21, slices.Concat did not exist. You flatten using a loop with append and the spread operator. The spread operator ... expands a slice into individual arguments.
package main
import "fmt"
func main() {
nested := [][]int{{1, 2}, {3, 4}, {5}}
// Pre-allocate capacity if you know the total size to avoid reallocations
totalLen := 0
for _, sub := range nested {
totalLen += len(sub)
}
flat := make([]int, 0, totalLen)
// Append each sub-slice using the spread operator to expand elements
for _, sub := range nested {
flat = append(flat, sub...)
}
fmt.Println(flat) // [1 2 3 4 5]
}
The loop iterates over each sub-slice. append(flat, sub...) expands sub and appends its elements to flat. The return value of append must be captured. append may reallocate the backing array if capacity is exceeded, so the slice header can change.
The compiler rejects append(flat, sub...) without assignment with append(flat, sub...) is a value used as a statement. Always write flat = append(flat, sub...).
Pre-allocation matters. If you skip the capacity calculation and start with flat := make([]int, 0), append grows the slice dynamically. Go doubles the capacity when it runs out of space. This causes multiple allocations and copies. For large data, pre-allocation reduces allocation count from logarithmic to constant.
Nil slices are handled gracefully. slices.Concat treats a nil sub-slice as empty. append(flat, nil...) also works and appends nothing. You do not need to check for nil before flattening.
Type mismatches cause compile errors. The compiler complains with cannot use nested (variable of type [][]string) as [][]int value in argument if you pass the wrong type to a function expecting a different element type. Go's type system catches these errors early.
Trust the compiler. Type errors are free fixes.
Decision matrix
Use slices.Concat when you have Go 1.21+ and want a single expression to merge a slice of slices. Use an append loop with pre-allocation when you are on Go 1.20 or earlier and need to flatten a slice of slices. Use a manual index loop when you need to transform elements while flattening, such as filtering or mapping values. Use copy into a pre-allocated slice when you already have the destination buffer and want to avoid allocating a new slice header.
Pick the tool that matches your Go version and your data shape.