How to Filter a Slice in Go

Filter a Go slice using slices.DeleteFunc to remove items in place or slices.Filter to create a new slice with matching elements.

The missing filter method

You write nums.filter(n => n > 2) in JavaScript. It works. You write nums.filter(func(n int) bool { return n > 2 }) in Go. The compiler rejects the code with nums.filter undefined (type []int has no field or method filter).

Go slices do not have methods. They are not objects. A slice is a lightweight data structure that describes a view over an underlying array. The language design keeps slices simple and cheap to pass. Methods belong to types defined in packages, not to the slice header itself.

This design choice forces you to be explicit about memory. Filtering a slice means either shrinking the view over existing memory or allocating a new slice and copying data. Go gives you tools for both. The standard library added a dedicated filtering function in Go 1.21, but the loop pattern remains essential for complex transformations.

Slices are windows, not boxes

A slice in Go is a three-field header: a pointer to the underlying array, a length, and a capacity. When you filter a slice, you are adjusting the length or creating a new header that points to different memory.

The underlying array stays put. Filtering does not magically shrink the array. It changes how much of the array the slice header claims to own. This distinction matters for performance. If you filter a large slice in place, you avoid allocating new memory. If you build a new slice, you pay for allocation and copying.

Go's approach favors explicit control. You decide whether to reuse the backing store or create a fresh copy. The compiler enforces that you handle the result. There is no hidden allocation. There is no garbage collection surprise. You manage the view.

The modern approach: slices.DeleteFunc

The slices package provides DeleteFunc for removing elements that match a condition. This function modifies the slice in place and returns the updated slice. You must reassign the result.

Here's the simplest usage: remove even numbers from a slice of integers.

package main

import (
	"fmt"
	"slices"
)

func main() {
	// Start with a slice of integers
	nums := []int{1, 2, 3, 4, 5}

	// DeleteFunc removes elements where the predicate returns true
	// Reassign the result because the function returns the new slice header
	nums = slices.DeleteFunc(nums, func(n int) bool {
		// Predicate returns true for elements to remove
		return n%2 == 0
	})

	// Print the filtered slice
	fmt.Println(nums)
}

The function takes the slice and a predicate function. The predicate receives each element and returns a boolean. If the predicate returns true, the element is removed. If it returns false, the element stays.

DeleteFunc returns a new slice header with a reduced length. The capacity remains unchanged. The underlying array still holds the original values at the end of the slice. Those values are just outside the new length boundary. They become eligible for garbage collection only when the array is overwritten or the slice is resized.

This behavior is efficient for repeated operations. You can filter a slice multiple times without reallocating memory, as long as the capacity is sufficient. The function shifts remaining elements left to fill the gaps. It updates the length to exclude the removed items.

What happens under the hood

Understanding the mechanics helps you predict performance and memory usage. DeleteFunc iterates over the slice. It maintains a write index. When it encounters an element that should stay, it copies that element to the write index and increments the index. When it encounters an element to remove, it skips the copy.

After the loop, the function returns a slice with the same pointer and capacity, but a length equal to the final write index. The elements from the write index to the old length are still in the array. They are effectively "ghost" values.

If your slice contains pointers, this matters. The ghost values still hold references to heap objects. Those objects cannot be garbage collected as long as the backing array exists. If you filter a slice of structs containing pointers, you might retain memory longer than expected.

To release the memory, you can create a new slice that points to the same data but has reduced capacity. This is rare in practice. Most code relies on the garbage collector to reclaim the array when the slice is no longer referenced. For simple types like integers or strings, the ghost values are harmless.

The slices package implementation is optimized. In recent Go versions, DeleteFunc uses assembly for common types. It avoids function call overhead for the predicate when possible. This makes it faster than a manual loop for simple filtering tasks.

The loop pattern: control and transformation

Before Go 1.21, you wrote a loop to filter a slice. The loop pattern is still useful when you need to transform data while filtering, or when you want to control the capacity of the result.

Here's how to filter and transform in one pass. Suppose you have a slice of user structs and you want to extract the names of active users.

package main

import (
	"fmt"
)

type User struct {
	Name   string
	Active bool
}

func main() {
	users := []User{
		{Name: "Alice", Active: true},
		{Name: "Bob", Active: false},
		{Name: "Charlie", Active: true},
	}

	// Pre-allocate capacity to avoid reallocations during append
	// Estimate based on the input size or a known maximum
	var activeNames []string
	for _, u := range users {
		// Check the condition
		if u.Active {
			// Transform and append the result
			activeNames = append(activeNames, u.Name)
		}
	}

	// Print the result
	fmt.Println(activeNames)
}

The loop gives you full control. You can pre-allocate the result slice with a known capacity to avoid reallocations. You can transform the data as you go. You can perform side effects, like logging or updating counters.

The append function handles the mechanics. It checks if the destination slice has enough capacity. If not, it allocates a new array, copies the data, and returns a new slice header. If capacity is sufficient, it writes in place and updates the length.

This pattern is idiomatic Go. It is explicit, readable, and flexible. You see it in production codebases that predate Go 1.21, and in code where transformation is part of the filtering logic.

Pitfalls and silent failures

Filtering slices has a few traps. The most common is forgetting to reassign the result of DeleteFunc.

nums := []int{1, 2, 3, 4, 5}
// This call does nothing to nums because the result is discarded
slices.DeleteFunc(nums, func(n int) bool {
	return n%2 == 0
})
// nums is still [1, 2, 3, 4, 5]

The compiler does not warn you. DeleteFunc returns a value. If you ignore the return value, the original slice remains unchanged. The function modifies the slice header and returns the new header. It does not mutate the variable in place. You must assign the result back to the variable.

Another pitfall is modifying a slice while iterating over it with a range loop.

nums := []int{1, 2, 3, 4, 5}
for i, n := range nums {
	if n == 2 {
		// Modifying the slice length during range is dangerous
		nums = append(nums[:i], nums[i+1:]...)
	}
}

The range loop captures the length of the slice at the start. If you change the length, the loop index can go out of bounds, or you can skip elements. The compiler might not catch this. The runtime could panic with index out of range.

Never modify the length of a slice while ranging over it. Use DeleteFunc or build a new slice in a separate pass. If you must modify in place, use a traditional for loop with an index and manage the bounds carefully.

The compiler also rejects attempts to call methods on slices. If you try to add a filter method to a slice type, you get an error.

// This does not compile
// type IntSlice []int
// func (s IntSlice) Filter(...) ...
// nums := []int{1, 2, 3}
// nums.Filter(...) // Error: nums.Filter undefined

Slices are built-in types. You cannot add methods to them. You can define a new named type based on a slice and add methods to that type. But the built-in []int type has no methods. Use the slices package or write a helper function.

Decision matrix

Choose the right tool based on your needs. The decision depends on whether you need transformation, memory control, or simplicity.

Use slices.DeleteFunc when you need to remove elements based on a condition and want the standard library optimization. Use it for simple predicates where you don't need to transform the data. Use it when you want to reuse the backing array capacity.

Use a loop with append when you need to transform the remaining elements while filtering. Use it when you want to control the capacity of the result slice. Use it when you are working with codebases that predate Go 1.21.

Use a loop with index tracking when you need to perform side effects on removed elements, such as logging or cleanup. Use it when the filtering logic is complex and requires state across iterations. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Slices are views. Changing the view doesn't always change the data. Reassign the result or the filter never happens.

Where to go next