Combining several slices often starts as a tiny append expression and ends with an ownership question: did the result reuse one of the input backing arrays, and can a later append or mutation affect data another part of the program still uses?

Since Go 1.22, slices.Concat gives this operation a direct standard-library form. It combines multiple slices into a new slice, which is especially useful when the result should be treated as its own collection rather than as an extension of one input.

What slices.Concat does

The function has this signature:

func Concat[S ~[]E, E any](slices ...S) S

It accepts zero or more slices of the same slice type and returns their elements in argument order.

package main

import (
    "fmt"
    "slices"
)

func main() {
    core := []string{"api", "worker"}
    optional := []string{"scheduler"}
    local := []string{"debug"}

    services := slices.Concat(core, optional, local)
    fmt.Println(services)
}

The output is:

[api worker scheduler debug]

There is no special case at the call site for one, two, or several inputs. That makes Concat a good fit when code is assembling a result from independently produced groups.

slices.Concat returns a new slice

A common alternative is this:

combined := append(first, second...)

That expression is compact, but append may reuse first’s backing array when it has enough spare capacity. Whether it does so depends on the capacity of first. Code that assumes combined is independent can therefore behave differently after an unrelated capacity change.

slices.Concat avoids that ambiguity by returning a new slice. Mutating an input’s top-level elements after concatenation doesn’t rewrite the result.

package main

import (
    "fmt"
    "slices"
)

func main() {
    primary := []string{"api", "worker"}
    extra := []string{"cron"}

    all := slices.Concat(primary, extra)
    primary[0] = "changed"

    fmt.Println(primary) // [changed worker]
    fmt.Println(all)     // [api worker cron]
}

This is often the main reason to choose Concat: the function communicates that the inputs are sources and the returned slice is a separate top-level collection.

The qualifier “top-level” matters. If the elements themselves contain pointers, maps, slices, or other reference-like values, Concat copies those element values; it doesn’t recursively clone what they refer to.

row := []int{10, 20}
rows := slices.Concat([][]int{row}, [][]int{row})

rows[0][0] = 99
fmt.Println(rows) // [[99 20] [99 20]]

Both elements still refer to the same row backing array. Use explicit cloning or construction when nested data also needs independent ownership.

Empty concatenations return nil

slices.Concat has a small edge case worth knowing: if the concatenation contains no elements, the result is nil.

var a []int
b := []int{}

result := slices.Concat(a, b)

fmt.Println(len(result))    // 0
fmt.Println(result == nil) // true

Calling it with no arguments also produces a nil slice:

result := slices.Concat[[]int]()
fmt.Println(result == nil) // true

For most Go code, nil and empty slices can be used interchangeably: both have length zero and can be ranged over or appended to. The distinction can still surface at boundaries such as custom encoders, tests that compare exact representations, or APIs whose contracts deliberately distinguish absent data from an empty collection.

If that distinction matters, normalize the result according to the contract instead of relying on an incidental assumption about empty slices.

Prefer Concat when none of the inputs should own the result

Consider code that builds a command search path from defaults, configured directories, and per-request additions:

func searchPaths(defaults, configured, request []string) []string {
    return slices.Concat(defaults, configured, request)
}

The ownership is easy to read. The function isn’t extending defaults; all three parameters contribute values to a newly returned collection.

Compare that with:

func searchPaths(defaults, configured, request []string) []string {
    result := append(defaults, configured...)
    result = append(result, request...)
    return result
}

The second version may be perfectly valid if modifying or reusing defaults is acceptable. But a reader has to inspect capacity and caller expectations before knowing whether aliasing is harmless. Using Concat removes that question when independence is what the function intends.

Append is still useful when you intentionally extend one slice

slices.Concat isn’t a replacement for every append. If you already own a destination buffer and want to grow it, append expresses that operation directly and can reuse existing capacity.

buffer := make([]byte, 0, 4096)
buffer = append(buffer, header...)
buffer = append(buffer, body...)

Replacing this with slices.Concat(header, body) would discard the reason the buffer was preallocated in the first place. The two operations answer different ownership questions: append grows a destination; Concat constructs a new result from inputs.

This distinction also matters in hot paths. Concat must produce a new slice, so don’t switch allocation-sensitive code merely for stylistic consistency. Measure the path and choose the operation that matches the lifetime and ownership of the data.

Watch the total size when inputs are untrusted

Concatenation needs enough memory for all input elements. A few application-owned slices are straightforward, but code that gathers arbitrarily large user-controlled collections can still request an unreasonable allocation.

For example, a service that merges batches from a request should enforce its batch or element limits before concatenating them. slices.Concat simplifies the mechanics of combining slices; it doesn’t provide an application-level memory budget.

The same caution applies when many slices are generated in a loop and retained before one final concatenation. If streaming the values is possible, holding every intermediate slice plus the final combined result may use more memory than processing each batch as it arrives.

Use slices.Concat when the result is a new collection

Reach for slices.Concat when several slices contribute to a new slice and you don’t want the result’s top-level storage tied to any one input. It makes that ownership decision visible in one expression and handles any number of inputs without a chain of appends.

Keep append when you’re deliberately growing storage you already own. And if the elements contain nested reference-like data, decide separately whether those inner values should be shared or cloned. That ownership check is more useful than choosing between the functions based on syntax alone.