How to Merge Two Slices in Go

You can merge two slices in Go by creating a new slice with a capacity equal to the sum of both lengths, then using the built-in `append` function to copy elements from the first slice followed by the second.

The batch problem

You pull two lists of identifiers from different parts of your system. One comes from a database query, the other from a cache refresh. You need them combined into a single batch before handing them to a worker pool. In Go, you do not reach for a third-party library. You reach for append. The syntax looks trivial. The underlying mechanics are not. Get the capacity planning wrong, and you trigger unnecessary memory copies that scale quadratically. Get the backing array sharing wrong, and you accidentally mutate data you assumed was isolated. Merging slices is a fundamental operation that reveals how Go manages memory, capacity, and references.

Slices are windows, not boxes

A slice in Go is not a container that owns its data. It is a view into a contiguous block of memory called an array. The slice header holds three values: a pointer to the backing array, a length that says how many elements are visible, and a capacity that says how many elements exist in the array behind the scenes. When you create a slice with make([]int, 3, 5), you get a window showing three items, but the underlying array has room for five.

Merging two slices means building a new window that covers both sets of data. You need to allocate enough backing storage upfront, then copy the elements from the first window, then the second. If you skip the upfront allocation, Go will keep buying larger arrays and repainting them as you go. That works for tiny datasets. It burns CPU cycles and fragments memory for large ones.

Pre-allocate the capacity. Let append do the copying.

The minimal merge

Here is the standard pattern for combining two slices of the same type. It relies on make to reserve space and the variadic spread operator to unpack each source slice.

package main

import "fmt"

func main() {
    // Two independent slices pointing at separate backing arrays
    first := []int{1, 2, 3}
    second := []int{4, 5, 6}

    // Pre-allocate exactly the space needed to avoid reallocation
    merged := make([]int, 0, len(first)+len(second))

    // Spread operator unpacks the slice into individual arguments
    merged = append(merged, first...)
    merged = append(merged, second...)

    fmt.Println(merged)
}

The make call sets the length to zero and the capacity to six. The first append copies three integers into the backing array and updates the length to three. The second append copies the remaining three integers and updates the length to six. No reallocation occurs. The operation completes in linear time.

Pre-allocate the capacity. Let append do the copying.

What happens under the hood

When you call append, the runtime checks whether the destination slice has enough capacity to hold the new elements. If it does, the runtime copies the source elements into the next available slots and increments the length field of the slice header. The function returns a new slice header pointing at the same backing array, but with an updated length.

If the destination lacks capacity, Go allocates a new, larger backing array. The growth strategy is not strictly linear. For small slices, Go often doubles the capacity. For larger slices, it uses a more conservative multiplier to avoid wasting memory. After allocation, the runtime copies all existing elements from the old array to the new one, appends the new elements, and returns a slice header pointing at the fresh allocation. This reallocation cycle is what makes pre-allocation important. Skipping it turns a single copy operation into a series of allocations and copies.

The spread operator ... tells the compiler to treat the slice as a list of individual arguments. Without it, you pass the slice itself as a single element. The compiler rejects that mistake with cannot use second (variable of type []int) as int value in argument to append. The error message is explicit because the type system catches the mismatch at compile time.

Trust the capacity math. Measure twice, allocate once.

Merging structs and pointer semantics

Real code rarely merges plain integers. You usually merge slices of structs, pointers, or interfaces. The mechanics stay the same, but the memory semantics shift.

package main

import "fmt"

type LogEntry struct {
    ID  int
    Msg string
}

// MergeLogs combines two slices of log entries into a new slice.
// It pre-allocates capacity to prevent runtime reallocation.
func MergeLogs(a, b []LogEntry) []LogEntry {
    // Calculate exact capacity needed for both slices
    totalLen := len(a) + len(b)
    result := make([]LogEntry, 0, totalLen)

    // Append first slice elements
    result = append(result, a...)
    // Append second slice elements
    result = append(result, b...)

    return result
}

func main() {
    batch1 := []LogEntry{{1, "start"}, {2, "process"}}
    batch2 := []LogEntry{{3, "end"}}

    combined := MergeLogs(batch1, batch2)
    fmt.Printf("%+v\n", combined)
}

When you merge slices of structs, Go copies the struct values themselves. Each element in the new slice is an independent copy. Modifying combined[0].Msg does not affect batch1[0].Msg. This is usually what you want.

The behavior changes when your slices hold pointers. If you merge []*LogEntry, the append operation copies the pointer addresses, not the underlying structs. The new slice and the original slices point at the same heap objects. Changing a field through the merged slice mutates the original data. This is not a bug. It is a feature of reference semantics. Document it clearly in your function signature or godoc.

The community convention for functions that transform or combine data is to accept interfaces and return structs. In this case, you accept concrete slices and return a concrete slice. Keep the signature explicit. Do not hide pointer semantics behind vague types.

Functions that merge should return the new slice. Keep the caller's data untouched.

Pitfalls and compiler traps

The most common mistake is appending to a slice that shares capacity with one of the source slices. Consider a scenario where you reuse a buffer to avoid allocations. If the buffer's backing array overlaps with a source slice, append may overwrite data it is still reading. Go's append implementation detects this overlap and creates a temporary copy to prevent corruption, but the extra allocation defeats the purpose of reusing the buffer. If you need strict control over memory layout, use copy with explicit indices instead of append.

Another trap involves nil slices. append handles nil slices gracefully. append(nil, 1, 2, 3) allocates a new slice and returns it. You do not need to guard against nil before merging. The compiler accepts it without warning. The runtime handles it without panicking.

Type mismatches surface immediately at compile time. If you try to merge a []int and a []string, the compiler rejects the program with cannot use second (variable of type []string) as int value in argument to append. Go does not perform implicit type conversions. You must convert the elements explicitly or use a generic function that accepts a type parameter.

Forgetting the spread operator is the most frequent syntax error. The compiler complains with cannot use slice (variable of type []int) as int value in argument to append. Add the ... and the error disappears.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. This rule applies to any concurrent merge pattern where you fan out work across workers and collect results into a slice. If a worker blocks forever, the merge never completes.

The worst goroutine bug is the one that never logs.

When to reach for what

Use append with pre-allocated make when you need a clean, performant merge of two or more slices and you know the final size upfront. Use a simple loop with index tracking when you need to transform, filter, or deduplicate elements during the merge. Use copy into a pre-sized slice when you are merging fixed-size arrays or need strict positional control over where each element lands. Use sequential concatenation without pre-allocation only when the slices are tiny and performance profiling shows the allocation cost is negligible. Stick to a single slice and grow it in place when you are streaming data and cannot know the final size upfront.

Pick the tool that matches your data shape. Do not overcomplicate a linear copy.

Where to go next