Filtering a slice often starts with a small loop: inspect each element, keep the ones you want, and return the shorter result. When changing the original backing array is acceptable, slices.DeleteFunc gives that operation a standard-library name and avoids a hand-written compaction loop.
The useful detail is in “changing the original backing array.” slices.DeleteFunc isn’t a general-purpose immutable filter. It removes matching elements in place and returns the shortened slice, so it works best when the caller owns the input and no longer needs its old contents.
What slices.DeleteFunc removes
slices.DeleteFunc takes a slice and a predicate:
func DeleteFunc[S ~[]E, E any](s S, del func(E) bool) SThe predicate answers a deletion question. When del(element) returns true, that element is removed. Elements for which it returns false stay in the result.
That polarity is easy to reverse accidentally if you’re used to APIs named Filter, where true commonly means “keep this value.”
Here’s a complete example that drops completed jobs:
package main
import (
"fmt"
"slices"
)
type Job struct {
Name string
Done bool
}
func main() {
jobs := []Job{
{Name: "compile", Done: true},
{Name: "test", Done: false},
{Name: "deploy", Done: true},
{Name: "notify", Done: false},
}
jobs = slices.DeleteFunc(jobs, func(job Job) bool {
return job.Done
})
fmt.Println(jobs)
}The result contains the unfinished jobs in their original order:
[{test false} {notify false}]Notice the assignment back to jobs. The returned slice has the new length. Calling slices.DeleteFunc(jobs, ...) and ignoring its return value leaves the variable itself with its old slice header, which is almost never what you want.
Filtering happens in the existing slice storage
A common append-based filter can also reuse a slice’s storage:
kept := jobs[:0]
for _, job := range jobs {
if !job.Done {
kept = append(kept, job)
}
}slices.DeleteFunc packages that kind of in-place compaction behind a predicate. Surviving elements are moved toward the front as necessary, and the returned slice describes the shorter result.
This means aliases deserve attention. Suppose another variable refers to the same backing array before deletion:
all := []string{"debug", "info", "trace", "error"}
alias := all
all = slices.DeleteFunc(all, func(level string) bool {
return level == "debug" || level == "trace"
})
fmt.Println(all) // [info error]
fmt.Println(alias[0]) // infoalias hasn’t preserved a snapshot. Its length is still four, but its backing storage has been modified by the deletion. If another part of the program needs the original sequence, clone it first or build a separate result instead of filtering in place.
That ownership question is a better way to choose the API than focusing only on whether the helper saves a few lines.
The predicate means delete, not keep
For a condition such as “keep active users,” it’s tempting to write the condition directly:
users = slices.DeleteFunc(users, func(user User) bool {
return user.Active
})That code removes the active users. The function name helps if you read the callback as a sentence: delete this user when the callback returns true.
Writing the deletion condition directly is usually clearer:
users = slices.DeleteFunc(users, func(user User) bool {
return !user.Active
})For more complicated rules, give the condition a name rather than stacking negations inside the callback:
func shouldRemove(user User) bool {
return user.Disabled || user.Email == ""
}
users = slices.DeleteFunc(users, shouldRemove)A named predicate also makes it easier to test domain-specific removal rules independently from the slice operation.
The result may keep the original capacity
Deleting elements shortens the slice, but it doesn’t promise to shrink its backing allocation. For example, a four-element slice can become a two-element slice while retaining capacity for four elements.
That’s normally desirable. In-place filtering avoids allocating a second backing array just to discard some values. It also means slices.DeleteFunc isn’t a memory-compaction tool when the backing array itself is much larger than the result.
If you filter a very large temporary slice down to a small result that will live for a long time, consider whether retaining the larger backing allocation is acceptable. When you specifically want to reduce excess capacity after filtering, slices.Clip can cap the result’s capacity to its length:
jobs = slices.DeleteFunc(jobs, shouldDelete)
jobs = slices.Clip(jobs)Use that because the lifetime and memory profile call for it, not as a ritual after every deletion. Reusing spare capacity can be useful when the slice will grow again.
Removed slots are zeroed
Since Go 1.22, the slice helpers that shrink a slice, including DeleteFunc, zero the elements between the new length and the old length. This matters most when elements contain pointers, slices, maps, strings, or other values that can keep referenced data reachable.
Consider the earlier Job example. After two of four jobs are removed, the first two positions contain the survivors. The two positions beyond the new length are set to the zero value of Job rather than retaining the removed or moved values.
You usually don’t need to inspect those positions, and normal code shouldn’t depend on their contents. The practical consequence is that you no longer need a cleanup loop solely to clear the vacated tail after slices.DeleteFunc on current Go versions.
This behavior is version-sensitive. If you’re maintaining code that must run with Go 1.21 semantics, don’t assume the same tail-zeroing guarantee. Go 1.22 changed the shrinking slice helpers specifically to provide it.
Empty and nil slices stay unsurprising
DeleteFunc works with an empty slice without special handling. The predicate has no elements to inspect, and the result is empty.
Nilness is also preserved when the result is empty. A nil input remains nil:
var numbers []int
numbers = slices.DeleteFunc(numbers, func(n int) bool {
return n < 0
})
fmt.Println(numbers == nil) // trueFor most application logic, len(numbers) == 0 is the more useful emptiness check because it treats nil and non-nil empty slices the same way. The nilness guarantee mainly matters at boundaries where the distinction is observable, such as code with a deliberate serialization or API convention.
Don’t delete one matching element at a time
If the goal is predicate-based filtering, repeatedly searching for a match and calling slices.Delete is both harder to read and potentially more expensive because each deletion may shift the remaining suffix again.
This pattern is a warning sign:
for i := 0; i < len(values); {
if shouldDelete(values[i]) {
values = slices.Delete(values, i, i+1)
continue
}
i++
}slices.DeleteFunc expresses the operation directly:
values = slices.DeleteFunc(values, shouldDelete)Use slices.Delete when you already know a specific index range to remove. Use DeleteFunc when removal depends on examining each element.
There’s another distinction worth keeping: if the predicate can fail with an error, DeleteFunc can’t return that error for you. A normal loop is clearer when filtering needs error propagation, context cancellation checks, logging per rejected item, or other control flow beyond a boolean decision.
Choose in-place filtering when ownership is clear
slices.DeleteFunc is a good fit when you own a slice, want to remove every element matching a condition, and are comfortable reusing its backing storage. Assign the returned slice, remember that true means delete, and be cautious when aliases to the original slice are still in use.
If callers need the original data unchanged, create a separate result instead. If filtering can fail, write the loop that makes that failure visible. The standard helper is most useful when its mutation model matches the problem; in that case, it turns a routine compaction loop into one line without hiding what the program is doing.