With Go 1.22 (Q1 2024), you might consider the new func Concat[S ~[]E, E any](slices ...S) S generic function.
See commit 2fd195, which fixes issue 56353
// Join slices into a new slice
a := []int{ 1, 2, 3 }
b := []int{ 4, 5, 6 }
c := slices.Concat(nil, a, b)
// c == int{ 1, 2, 3, 4, 5, 6 }
s := [][]int{{1}, nil, {2}}
c = slices.Concat(s...)
// c == int{1, 2}
This is being followed with "New API changes for Go 1.22" (issue 64343).
As noted by Eric in the comments, slices.Concat() always allocates a fresh slice to hold all elements from the passed-in slices; it does not modify or extend any of them.
So if you need in-place appends (and you’re fine with changing the original slices), you can still rely on append().
Otherwise, slices.Concat() is useful when you want a new independent slice, leaving existing slices unaltered.
See playground
// File name: concat_test.go (for go test)
//
// Demonstrates that slices.Concat() creates an entirely new slice
// without altering the originals, whereas an in-place append will
// modify the original slice if capacity permits.
package main
import (
"fmt"
"reflect"
"slices"
"testing"
)
// TestMyConcatBasic checks a single case to illustrate that
// slices.Concat() returns a fresh slice and does not modify 'a' or 'b'.
func TestMyConcatBasic(t *testing.T) {
a := []int{1, 2}
b := []int{3, 4}
want := []int{1, 2, 3, 4}
// Copy 'a' and 'b' so we can verify they are not changed.
oldA := make([]int, len(a))
copy(oldA, a)
oldB := make([]int, len(b))
copy(oldB, b)
got := slices.Concat(a, b)
if !reflect.DeepEqual(got, want) {
t.Errorf("Concat(%v, %v) = %v; want %v", a, b, got, want)
}
// Check 'a' and 'b' remain unmodified after slices.Concat.
if !reflect.DeepEqual(a, oldA) {
t.Errorf("original slice 'a' changed: got %v, want %v", a, oldA)
}
if !reflect.DeepEqual(b, oldB) {
t.Errorf("original slice 'b' changed: got %v, want %v", b, oldB)
}
}
// TestMyConcatTableDriven uses a table-driven approach to verify
// slices.Concat() in multiple scenarios, confirming each time that
// the source slices remain unmodified.
func TestMyConcatTableDriven(t *testing.T) {
var tests = []struct {
a, b []int
want []int
}{
// Edge cases
{[]int{}, []int{}, []int{}},
{[]int{}, []int{1}, []int{1}},
// Typical usage
{[]int{1}, []int{2, 3}, []int{1, 2, 3}},
{[]int{1, 2}, []int{3, 4}, []int{1, 2, 3, 4}},
// Another example
{[]int{1, 2, 3}, []int{4, 5, 6}, []int{1, 2, 3, 4, 5, 6}},
}
for i, tt := range tests {
testName := fmt.Sprintf("TestCase#%d", i)
t.Run(testName, func(t *testing.T) {
// Make a copy of the original slices.
oldA := make([]int, len(tt.a))
copy(oldA, tt.a)
oldB := make([]int, len(tt.b))
copy(oldB, tt.b)
got := slices.Concat(tt.a, tt.b)
if len(got) == 0 {
got = []int{}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%s: Concat(%v, %v) = %v; want %v",
testName, tt.a, tt.b, got, tt.want)
}
// Confirm that Concat did not mutate the inputs.
if !reflect.DeepEqual(tt.a, oldA) {
t.Errorf("original slice a changed: got %v, want %v", tt.a, oldA)
}
if !reflect.DeepEqual(tt.b, oldB) {
t.Errorf("original slice b changed: got %v, want %v", tt.b, oldB)
}
})
}
}
// TestInPlaceAppend shows how the native append() can modify the
// original slice in place.
func TestInPlaceAppend(t *testing.T) {
a := []int{1, 2}
oldA := make([]int, len(a))
copy(oldA, a)
// In-place append: modifies the underlying array if capacity allows.
a = append(a, 3, 4)
if reflect.DeepEqual(a, oldA) {
t.Errorf("expected 'a' to be changed, but it's still %v", a)
}
if !reflect.DeepEqual(a, []int{1, 2, 3, 4}) {
t.Errorf("in-place append failed; got %v, want %v", a, []int{1, 2, 3, 4})
}
}