Sunday, October 25, 2026
Why Does Go Have No Built-In Generic Map And Filter?
Posted by

Introduction
Go has no built-in generic Map or Filter because the standard library leaves one-off element transforms to an explicit loop, and generics, added in Go 1.18, made a type-safe version of each function short enough to write in your own code. This article explains what Map and Filter mean, why they were hard to write before generics, why adding them to the standard library still forces a set of choices, what the standard library did add instead, and how to write Map and Filter cleanly yourself, including when a plain loop is the better choice.
Before You Start
- Slice: an ordered sequence of elements of one type, backed by an array. A slice value has a length, a capacity, and a pointer to its backing array. Map and Filter in this article operate on slices, not on the map type.
- Map type: a built-in Go type written
map[K]V, which stores key-value pairs. The word map in this article means two different things, and the article says which one each time: themaptype, or the Map function that transforms elements. - Map function: a function that takes a slice and a transform function, applies the transform to every element, and returns a new slice of the results. The input and output element types can differ.
- Filter function: a function that takes a slice and a predicate, a function that returns true or false for one element, and returns a new slice holding only the elements for which the predicate returned true.
- Generics: the ability to write a function or type that works with a set of types, using type parameters, instead of writing one copy per type.
- Type parameter: a placeholder for a type, declared in square brackets after a function name, such as
TinMap[T any, U any]. At a call site, the compiler replaces the placeholder with a concrete type. - Type argument: the concrete type supplied at a call site for a type parameter, such as
intinMap[int, string]. Type arguments can often be inferred from the ordinary arguments, so they can be omitted at the call site. - Constraint: the rule that limits which types a type parameter can accept. A constraint is written as an interface, and it can list methods, types, or both.
- any: a predeclared constraint that accepts every type. It is an alias for the empty interface type.
- comparable: a predeclared constraint that accepts types whose values support
==and!=. Map keys must be comparable, but Map and Filter over slices do not need this constraint, because they never compare elements with==. - Function value: a value that holds a function, which can be passed as an argument. A function literal, also called an anonymous function, is a function written inline, without a name.
- Predicate: a function that takes one value and returns a bool. Filter uses a predicate to decide which elements to keep.
- Allocation: memory set aside for a new slice or value. Map and Filter in this article allocate a new slice, they do not change the input slice in place.
Prerequisites
- Go 1.20 or later, because the article uses generics, which require Go 1.18 or later, and the example stays inside that baseline.
- A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.
Project Layout
mapfilter/
├── go.mod # module declaration, Go version
├── main.go # Map, Filter, and main function
└── main_test.go # Map, Filter, and chaining tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the two generic functions and the demonstration, so the signatures and their use can be read in order.
- main_test.go: holds tests that prove Map transforms every element, Filter keeps only matching elements, and chaining the two gives the expected result.
The Missing Functions, And What They Would Mean
In many languages, map and filter are built-in operations on a collection. Go does not provide them as built-in functions, and the standard library slices package does not provide them either. That absence is not a gap in the language that generics failed to close. Generics made the functions possible to write, and the standard library chose not to add them.
Map takes a slice of one element type and a transform, and returns a slice of results:
doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
// doubled is []int{2, 4, 6}
Filter takes a slice and a predicate, and returns a slice of the kept elements:
evens := Filter([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 })
// evens is []int{2, 4}
Both functions return a new slice. Neither changes the input slice. That return-a-new-slice rule is a design choice, not a law of nature, and it is the first of several choices a standard library version would have to lock in. The next sections work through those choices, because they explain the absence better than any single reason does.
A naming note matters here, because the word map causes real confusion in Go. The built-in type is spelled map, lowercase, and it is a keyword-like type name. A Map function would be spelled Map, capitalised, and would live in a package, such as slices.Map if it existed. The two do not collide in code, but they do collide in conversation, in search results, and in a reader's first reading of a function signature. That confusion is a cost, not a reason on its own, and the article keeps the two apart by writing Map function with a capital M and the map type in code form.
Why A Loop Was Enough Before Generics
Before Go 1.18, a generic Map or Filter could not be written in a type-safe way at all. The only general version used interface{}, now spelled any, and type assertions at run time:
func MapAny(in []any, fn func(any) any) []any {
out := make([]any, len(in))
for i, v := range in {
out[i] = fn(v)
}
return out
}
That version loses the element type at the call site. The caller gets []any back, and must assert each result back to its real type, with a run-time failure if the assertion is wrong. The alternative was one copy of Map per element type, which is duplication, not a library.
An explicit loop, by contrast, never had that problem. A loop over []int with a transform written inline is type-safe, readable, and needs no helper:
numbers := []int{1, 2, 3}
doubled := make([]int, len(numbers))
for i, n := range numbers {
doubled[i] = n * 2
}
That loop is the Go idiom that Map and Filter would replace, and it is a strong idiom. It shows the input, the transform, and the output in one place, with no function call to look up. A standard library Map would have to be clearly better than that loop to earn its place, and for a single transform, it often is not.
Why Generics Did Not Automatically Add Them
Go 1.18 added generics using type parameters, as described in the official generics tutorial. A generic Map became a short function, and a generic Filter became a short function. That change removed the technical barrier, but it did not remove the design choices, and the standard library does not add a function for every short function that becomes possible.
Each choice below would be locked in for every Go program if a standard Map or Filter existed, and reasonable code disagrees on several of them:
- New slice, or in place: a Map that returns a new slice is safe and predictable, but allocates on every call. A Map that changes the input in place avoids that allocation, but surprises a caller who still holds the input.
- Nil, or empty: a Map over a nil slice can return nil, preserving the distinction between nil and empty, or it can return an empty non-nil slice. Code that tests for nil, serialises the result, or passes it to another function can behave differently for each choice.
- Errors: a transform that can fail needs a version that returns an error, such as
MapErr, or the caller must handle errors inside the transform and lose them. A standard Map with no error return cannot cover that case, and a standard Map with an error return is a different function. - Named slice types: a Map written for
[]Treturns[]U, and a named slice type, such astype Scores []int, becomes a plain[]inton the way through. A version that preserves named types needs a second type parameter with a~[]Tconstraint, which makes the signature longer and harder to read. - Chaining: a chain such as Filter then Map allocates an intermediate slice for the filtered result, then another slice for the mapped result. A loop that filters and maps in one pass allocates once. A standard library that encouraged chaining would encourage that extra allocation.
- Readability: a short transform reads well as a function value, but a long transform inside a Map call hides the loop structure. A reader must find the transform, the input, and the output across a call, instead of reading them top to bottom in a loop.
None of these choices is wrong. That is the point. A standard library function must pick one answer for all callers, and Go's standard library tends to add a function only when one answer is clearly better than the alternatives, or when the idiom is hard to get right by hand. Map and Filter do not meet that bar in the same way as sorting, cloning, or deleting from a slice, where a hand-written version is longer and easier to get wrong.
What The Standard Library Did Add Instead
Go 1.21 added the slices and maps packages, with generic helpers for slices and maps. Those packages cover operations where a hand-written version is repetitive or error prone, and they do not include a general Map or Filter over slices.
From the slices package, the operations that replace common hand-written helpers include sorting, cloning, checking for a value, finding an index, deleting a range, and removing consecutive duplicates. From the maps package, the operations that replace common hand-written helpers include cloning a map, copying entries from one map to another, deleting entries that match a predicate, and comparing two maps for equality.
That pattern is instructive. The standard library added generic helpers for operations on the collection itself, where the operation is the same for every element type and the hand-written version is boilerplate. It did not add helpers for arbitrary element transforms, where the transform is the interesting part and the loop around it is three lines. The complete example in this article stays on the Go 1.20 baseline, so it does not import slices or maps, but a reader on Go 1.21 or later should check those packages first before writing a helper for sorting, cloning, deleting, or comparing, because those helpers already exist.
Writing Map And Filter Cleanly
A clean Map has two type parameters, one for the input element type and one for the output element type, and it preserves the input length:
func Map[T any, U any](in []T, fn func(T) U) []U {
if in == nil {
return nil
}
out := make([]U, len(in))
for i, v := range in {
out[i] = fn(v)
}
return out
}
The nil check at the start preserves the distinction between a nil input and an empty input. Without it, make would turn a nil input into an empty non-nil output, and code that distinguishes nil from empty would change behaviour silently. That check is a policy, stated in the function, not a rule of the language, and a different project can choose the opposite policy, as long as it states it.
A clean Filter has one type parameter, and it builds its result with append, because the result length is not known in advance:
func Filter[T any](in []T, keep func(T) bool) []T {
out := make([]T, 0, len(in))
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
The capacity argument, len(in), avoids repeated growth of the result slice when most elements are kept, while still returning a short slice when few are kept. The result is an empty non-nil slice when nothing matches, because make with length zero still allocates a slice header with a non-nil backing array pointer in the usual case. Code that needs a nil result for no matches must handle that case explicitly, and the tests in this article check length, not nil, for the no-match case, so that policy is visible.
Type arguments can usually be inferred, so call sites stay short:
doubled := Map(numbers, func(n int) int { return n * 2 })
The compiler infers T as int from numbers and U as int from the function value's return type. Explicit type arguments, such as Map[int, int](numbers, ...), are available when inference fails or when a call site needs to be explicit for a reader, but they are not the normal form.
A named slice type is the one place where the simple signature shows its limit. A function written for []T accepts a named slice type as an argument, because a named slice type has []T as its underlying type, but it returns a plain []U, not the named type. A version that preserves the input type needs a slice type parameter:
func MapNamed[S ~[]T, T any, U any](in S, fn func(T) U) []U {
out := make([]U, len(in))
for i, v := range in {
out[i] = fn(v)
}
return out
}
That version is longer, and its benefit only matters when the named type carries meaning that must survive the transform. The complete example uses the simple []T form, because its inputs are plain slices, and the named type form is shown here so a reader who needs it can recognise the tradeoff.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Plain loop | One transform, one place, or a transform with errors, early exit, or side effects | Repetitive when the same transform shape appears in many places |
| Own Map function | The same transform shape appears for several element types, with no errors | A new slice allocation per call, and a helper to maintain |
| Own Filter function | The same keep rule shape appears for several element types | A new slice allocation per call, and an empty result that is non-nil, not nil |
| Map that returns nil for nil input | Callers distinguish nil from empty, for example in serialisation | An extra branch, and a policy every caller must know |
| Map that always returns a new slice | The input must stay unchanged for other readers | An allocation even when the caller could reuse the input |
| Map that changes the input in place | Allocation matters and no other reader holds the input | Surprising for a caller who still holds the input, and a different function name is needed to avoid confusion |
| Chained Filter then Map | Each step is clear on its own, and the data set is small | An intermediate slice allocation between the two steps |
| Single loop that filters and maps | The data set is large, or allocation matters | The filter rule and the transform rule live in one loop, so each step is less reusable |
| Standard slices or maps package | The operation is sorting, cloning, deleting, copying, or comparing, on Go 1.21 or later | Those packages do not provide a general Map or Filter, so a transform still needs a loop or a helper |
| MapErr variant | The transform can fail | A second function, and a result that must be checked before use |
A design in this article has a deliberate compromise, stated openly. The complete example uses plain slices and the simple []T signature, not the named slice type form, so the two functions can be read without a second type parameter. In a codebase with named slice types that carry meaning, the named form in the previous section is the better choice. The allocation and nil policies themselves are not compromised: Map preserves nil, Filter returns an empty slice for no matches, and both are stated in the code and in the tests.
Complete Example
The complete example defines Map and Filter, then uses them on numbers and on names. The main function prints each result, so the output can be checked by eye.
// go.mod
module mapfilter
go 1.20
// main.go
package main
import (
"fmt"
"strings"
)
func Map[T any, U any](in []T, fn func(T) U) []U {
if in == nil {
return nil
}
out := make([]U, len(in))
for i, v := range in {
out[i] = fn(v)
}
return out
}
func Filter[T any](in []T, keep func(T) bool) []T {
out := make([]T, 0, len(in))
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6}
doubled := Map(numbers, func(n int) int { return n * 2 })
fmt.Printf("Doubled: %v\n", doubled)
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
fmt.Printf("Evens: %v\n", evens)
names := []string{"Alice", "Bob", "Carol"}
lengths := Map(names, func(s string) int { return len(s) })
fmt.Printf("Name lengths: %v\n", lengths)
aNames := Filter(names, func(s string) bool { return strings.HasPrefix(s, "A") })
fmt.Printf("Names starting with A: %v\n", aNames)
}
Commands to run it, from the mapfilter folder:
go run .
Expected output:
Doubled: [2 4 6 8 10 12]
Evens: [2 4 6]
Name lengths: [5 3 5]
Names starting with A: [Alice]
Why this program behaves as shown, in order:
- Map over the numbers doubles every element, preserving length, so six inputs give six outputs.
- Filter over the numbers keeps only the elements where the predicate returns true, so six inputs give three outputs.
- Map over the names changes the element type from string to int, so the output is a slice of lengths, not a slice of names.
- Filter over the names keeps only the name that starts with A, so three inputs give one output.
Testing Map And Filter
Test a generic helper with more than one element type, because a helper that works for ints and fails for strings is not generic. Test the edge cases that the design choices create: nil input, empty input, no matches, and all matches.
// main_test.go
package main
import (
"reflect"
"testing"
)
func TestMapInts(t *testing.T) {
got := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
want := []int{2, 4, 6}
if !reflect.DeepEqual(got, want) {
t.Errorf("Map ints = %v, want %v", got, want)
}
}
func TestMapChangesType(t *testing.T) {
got := Map([]string{"Alice", "Bob"}, func(s string) int { return len(s) })
want := []int{5, 3}
if !reflect.DeepEqual(got, want) {
t.Errorf("Map strings to lengths = %v, want %v", got, want)
}
}
func TestMapNilInput(t *testing.T) {
var in []int
got := Map(in, func(n int) int { return n * 2 })
if got != nil {
t.Errorf("Map nil input = %v, want nil", got)
}
}
func TestFilterEvens(t *testing.T) {
got := Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool { return n%2 == 0 })
want := []int{2, 4, 6}
if !reflect.DeepEqual(got, want) {
t.Errorf("Filter evens = %v, want %v", got, want)
}
}
func TestFilterNoMatches(t *testing.T) {
got := Filter([]int{1, 3, 5}, func(n int) bool { return n%2 == 0 })
if len(got) != 0 {
t.Errorf("Filter no matches length = %d, want 0", len(got))
}
}
func TestFilterAndMapChained(t *testing.T) {
numbers := []int{1, 2, 3, 4, 5, 6}
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
got := Map(evens, func(n int) int { return n * 2 })
want := []int{4, 8, 12}
if !reflect.DeepEqual(got, want) {
t.Errorf("Filter then Map = %v, want %v", got, want)
}
}
The nil test checks the policy stated in Map, not a language rule: a nil input returns a nil output. The no-match test checks length only, because Filter returns an empty non-nil slice in this design, and a test that asserted nil there would fail for a policy reason, not a logic reason. The chaining test checks that the two helpers compose, and that the intermediate slice does not change the final result.
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test. go vet reports suspicious constructs. It does not prove that a generic signature is the right design choice, so do not treat a clean vet run as proof that a helper belongs in a shared package. The tests are the proof for behaviour, and the decision guide below is the proof for placement.
Common Mistakes
- Confusing the Map function with the map type: a reader who sees
Mapin a signature can mistake it for the built-in type, or a writer who names a variablemapcan shadow the type name. The fix is to name the function clearly at its definition, to use it with its package name when it lives in a package, and to never name a variablemap. - Expecting Map or Filter to change the input in place: both functions in this article return a new slice, and the input is unchanged. The fix is to assign the result, and to write a separate in-place function with a name that says so, if in-place behaviour is needed.
- Forgetting to assign the result: a call such as
Map(numbers, double)with no assignment discards the new slice, and the input is unchanged. The fix is to assign every Map and Filter call, or to use a loop when the result is not needed. - Treating an empty Filter result as nil: Filter in this article returns an empty non-nil slice when nothing matches. Code that checks
result == nilto mean no matches will be wrong. The fix is to check length, or to change Filter to return nil for no matches and to state that policy. - Using Map for a transform that can fail: a transform that returns an error cannot be expressed by a Map that returns only a slice. A writer who ignores the error inside the transform loses it silently. The fix is a separate variant that returns the error, for example
func MapErr[T any, U any](in []T, fn func(T) (U, error)) ([]U, error), which stops at the first error and returns it. - Using Filter for side effects: a predicate that writes to a database, sends a message, or changes shared state makes Filter hard to reason about, because the predicate runs once per element and its order is the slice order. The fix is a loop for side effects, and Filter only for selection.
- Writing a generic helper for one type: a Map used for ints only, in one place, adds a generic signature, a function call, and a new slice allocation for no reuse. The fix is a loop, until the same shape appears for a second type or in a second place.
- Chaining without thinking about allocation: Filter then Map allocates an intermediate slice, then a final slice. For a large data set in a hot path, that cost matters. The fix is a single loop that filters and maps in one pass, when the data set is large enough for the allocation to matter.
- Assuming type inference always works: inference fails when the transform's return type cannot be determined from the arguments, for example when the function value is nil or its type is ambiguous. The fix is explicit type arguments at that call site, such as
Map[int, string](numbers, ...). - Losing a named slice type through Map: a named slice type passed to the simple Map returns a plain slice, so methods or meaning attached to the named type are lost. The fix is the named slice type form shown earlier, with a
~[]Tparameter, when the named type must survive the transform.
Production Notes
- Placement: a Map or Filter used in one package belongs in that package, near its use. A Map or Filter used across many packages belongs in a small internal utilities package, with the nil and allocation policies written in its documentation, not left for callers to discover.
- Naming: a shared helper named Map in a utilities package reads as
util.Mapat call sites, which is clear. A helper named Map in package main, or in a package that already deals with the map type, invites confusion, and a more specific name, such asTransformorSelect, can be the better choice. - Allocation: a Map or Filter call in a request handler or a loop over a large data set allocates a new slice on every call. For a small data set, that cost is negligible. For a large data set in a hot path, measure before chaining, and prefer a single loop when the measurement shows the allocation matters.
- Errors: a transform that can fail needs an error-returning variant, and the variant must be used consistently. Mixing a plain Map that ignores errors with a MapErr that returns them, in the same package, invites a caller to pick the wrong one.
- Version: the example needs Go 1.18 or later for generics, and the article sets the minimum at Go 1.20. Pin that version in
go.mod, rungo vet ./...andgo test ./...in continuous integration, and do not deploy a change that fails the Map or Filter tests. A vet pass alone is not enough for a helper, because vet does not check the nil or allocation policy. - Standard library first: before adding a helper for sorting, cloning, deleting, copying, or comparing, check the
slicesandmapspackages on Go 1.21 or later. Those helpers already exist, with documentation and tests, and a duplicate helper in your own code is a second standard library to maintain.
Decision Guide
- When one transform appears once, for one type: use a plain loop, with the transform written inline.
- When the same transform shape appears for several types, with no errors: use your own Map or Filter, in the package that uses them, with the nil and allocation policies stated.
- When the transform can fail: use a MapErr variant that returns an error, and check that error at every call site.
- When the data set is large and allocation matters: use a single loop that filters and maps in one pass, instead of chaining Filter then Map.
- When the operation is sorting, cloning, deleting, copying, or comparing: check the standard
slicesandmapspackages first, on Go 1.21 or later, before writing a helper. - When the input is a named slice type that must survive the transform: use the named slice type form, with a
~[]Tparameter, instead of the simple[]Tform. - When the transform has side effects, early exit, or complex control flow: use a loop, because a function value hides the control flow that a reader needs to see.
- When a helper is used across many packages: put it in a small internal utilities package, with its policies documented, and do not copy it into each package that needs it.
Conclusion
Go has no built-in generic Map or Filter because a loop already covers the common case well, and generics made the helper version short enough that the standard library did not need to choose one signature, one nil policy, and one allocation policy for every program. That absence is a design choice, not a missing feature waiting for a version number.
Summary Of Key Points
- Loop first: a plain loop is type-safe, readable, and needs no helper for a single transform.
- Generics removed the barrier: Go 1.18 made a type-safe Map and Filter short to write, which reduced the need for a standard version, rather than creating one.
- Standard choice is the cost: a standard Map or Filter would lock in a nil policy, an allocation policy, an error policy, and a named type policy for all callers.
- Standard library added neighbours, not Map and Filter: Go 1.21 added
slicesandmapshelpers for sorting, cloning, deleting, copying, and comparing, but no general Map or Filter over slices. - Own helper, stated policies: a clean Map preserves nil and returns a new slice, a clean Filter returns an empty slice for no matches, and both policies belong in the code and the tests.
- When not to write them: a single use, a failing transform, a side effect, or a large data set in a hot path all point back to a loop, or to a MapErr variant.
Write Map and Filter when the same shape repeats, keep them small, state their policies, and leave the single transform as a loop. That is the Go answer to the missing built-ins: not a package to import, but a loop to read, and a helper to write when the loop starts to repeat.
Related Posts
- Go Generics Comparable Constraint
- Why Wont My Generic Type Satisfy This Interface?
- Why Cant I Take The Address Of A Map Value In Go?
- Go Slice Vs Array
Learn more:
Additional Resources
- Go Documentation: Tutorial: Getting started with generics - type parameters, type arguments, inference, and constraints, with a worked example
- Go Blog: Go 1.18 Beta 1 is available, with generics - generics as the first preview of parameterized types in Go 1.18
- Go Packages: slices package - generic helpers for slices, added in Go 1.21, covering sorting, cloning, deleting, and searching
- Go Packages: maps package - generic helpers for maps, added in Go 1.21, covering cloning, copying, deleting by predicate, and equality
- Go Packages: golang.org/x/exp/maps package - the experimental maps helpers whose function list mirrors the standard maps package, including Clone, Copy, DeleteFunc, Equal, and EqualFunc
- Go Documentation: Go 1.21 release notes, slices - the slices package as a standard solution for common slice tasks
References
- Tutorial: Getting started with generics. Go team, Go Documentation. https://go.dev/doc/tutorial/generics. Accessed 2026-10-25.
- Go 1.18 Beta 1 is available, with generics. Go team, Go Blog. https://go.dev/blog/go1.18beta1. Accessed 2026-10-25.
- slices package. Go team, Go Packages. https://pkg.go.dev/slices. Accessed 2026-10-25.
- maps package. Go team, Go Packages. https://pkg.go.dev/maps. Accessed 2026-10-25.
- golang.org/x/exp/maps package. Go team, Go Packages. https://pkg.go.dev/golang.org/x/exp/maps. Accessed 2026-10-25.
- Go 1.21 release notes. Go team, Go Documentation. https://go.dev/doc/go1.21#slices. Accessed 2026-10-25.