The permission check
You're writing a middleware function that guards an API endpoint. The request carries a list of roles like ["admin", "editor", "viewer"]. You need to verify if the current user's role appears in that list before letting the request through. A linear scan feels obvious, but Go gives you a few ways to do this, and picking the right one depends on how often you check and how big the list gets.
Slices and linear search
Slices in Go are contiguous blocks of memory with a length and capacity. To check if a value exists, you generally have to look at the elements one by one until you find a match or run out of items. This is a linear search. The time complexity is O(n), meaning the time grows with the number of elements.
Go 1.21 introduced the slices package in the standard library, which provides optimized, generic functions for common operations. slices.Contains is the standard tool for a simple existence check. It replaces the manual loop pattern that older codebases rely on. The function is idiomatic, communicates intent clearly, and avoids off-by-one errors.
Slices are views. The search respects the length, not the capacity.
Minimal example
Here's the standard approach using the slices package: import the package, call Contains, and handle the boolean result.
package main
import (
"fmt"
"slices"
)
func main() {
// Slice of integers representing valid port numbers.
ports := []int{80, 443, 8080}
// slices.Contains returns true if the value is found.
// It performs a linear scan under the hood.
if slices.Contains(ports, 443) {
fmt.Println("Port 443 is allowed")
}
// False case: value not in slice.
// The negation operator flips the boolean result.
if !slices.Contains(ports, 9999) {
fmt.Println("Port 9999 is blocked")
}
}
What happens at runtime
When you call slices.Contains(slice, value), the compiler generates a loop specific to the element type. For basic types like int or string, it uses direct comparison. For structs, it compares fields. The function stops immediately upon finding the first match, so it doesn't waste time scanning the rest of the slice. If the slice is empty, it returns false without entering the loop.
This behavior is consistent and predictable. You don't need to worry about index out of range panics; the function handles bounds checking internally. The slices package is part of the standard library, so gofmt will organize the import automatically. Most editors run gofmt on save, so you never argue about import order.
Generics and type constraints
The slices package leverages Go's generics, introduced in Go 1.18. slices.Contains is defined with a type constraint that ensures the elements can be compared. For ordered types like numbers and strings, the constraint is cmp.Ordered. For types that are comparable but not ordered, like structs, the constraint relaxes to cmp.Comparable.
This generic definition allows the compiler to generate type-safe code without runtime overhead. You get the flexibility of a generic function with the speed of a hand-written loop. The compiler rejects invalid types at build time. If you try to pass a slice of a type that cannot be compared, such as a slice containing function values, you get an error like invalid operation: == applied to func type. The error message points directly to the type mismatch, making debugging straightforward.
Realistic example: structs and custom logic
Real-world data often lives in structs. You might have a list of users and need to check if a specific user ID exists, or if a user has a specific status. slices.Contains works on structs by comparing all fields, which can be too strict. Use slices.ContainsFunc when you need a custom matching rule.
package main
import (
"fmt"
"slices"
)
// User represents a system account with an ID and role.
type User struct {
ID int
Role string
}
// hasAdminRole checks if any user in the slice has the admin role.
// It uses ContainsFunc to inspect individual fields rather than comparing the entire struct.
func hasAdminRole(users []User) bool {
// ContainsFunc takes a predicate function that returns true for a match.
// The loop stops as soon as the predicate returns true.
return slices.ContainsFunc(users, func(u User) bool {
return u.Role == "admin"
})
}
func main() {
team := []User{
{ID: 1, Role: "viewer"},
{ID: 2, Role: "editor"},
}
// Check for admin using the custom predicate.
// The predicate captures the comparison logic inline.
if hasAdminRole(team) {
fmt.Println("Admin found")
} else {
fmt.Println("No admin in team")
}
}
Case sensitivity and strings
String comparison in Go is byte-by-byte and case-sensitive. "Admin" is not equal to "admin". This catches developers coming from languages where string comparison might be looser. slices.Contains respects case. If you need case-insensitive matching, you must use slices.ContainsFunc with a helper like strings.EqualFold.
package main
import (
"fmt"
"slices"
"strings"
)
func main() {
roles := []string{"admin", "editor", "viewer"}
// Direct comparison fails on case mismatch.
// "Admin" does not match "admin".
if slices.Contains(roles, "Admin") {
fmt.Println("Found Admin")
}
// Use ContainsFunc with strings.EqualFold for case-insensitive checks.
// This normalizes both strings before comparison.
if slices.ContainsFunc(roles, func(r string) bool {
return strings.EqualFold(r, "Admin")
}) {
fmt.Println("Found admin (case-insensitive)")
}
}
Pitfalls and edge cases
Floating point comparison is tricky. NaN is never equal to NaN. If your slice contains NaN, slices.Contains will return false even if you search for NaN. Use math.IsNaN in a ContainsFunc if you expect NaN values.
Comparing structs compares all fields. If your struct contains a slice, map, or function field, the == operator is not defined. The compiler rejects the code with invalid operation: == applied to struct containing slice field. You must use slices.ContainsFunc for those cases. Writing a manual comparison function is the only way to handle non-comparable fields.
One subtle trap involves slice headers. Slices are views over underlying arrays. If you pass a sub-slice to Contains, it only searches the sub-slice range. The function respects the length field in the slice header. It won't access elements beyond the length, even if the underlying array has more capacity. This prevents accidental reads of stale data. However, it also means you must ensure your slice bounds are correct before calling the function. The function itself won't panic on bounds, but passing a malformed slice could lead to logic errors.
NaN breaks equality. Structs with slices break comparison. Handle the edge cases before the compiler does.
Performance characteristics
Slices store elements in contiguous memory. A linear scan benefits from CPU cache prefetching. As the processor reads one element, it loads neighboring elements into the cache line. This makes slices.Contains surprisingly fast for small to medium slices, often outperforming a map lookup for lists under a few hundred items.
Maps require hashing, pointer chasing, and potential collisions. The overhead of map construction and lookup can exceed the cost of a simple scan when the dataset is small. If you check containment thousands of times on a large slice, O(n) adds up. For frequent lookups on large datasets, convert the slice to a map. The compiler will complain if you try to use slices.Contains on a map; it only accepts slices. You'll get an error like cannot use m (variable of type map[string]int) as []int value in argument if you pass the wrong type.
Cache locality favors slices for small data. Maps win on large data with frequent lookups.
Decision matrix
Use slices.Contains when you need a simple existence check on a slice of basic types or comparable structs.
Use slices.ContainsFunc when you need to match a subset of fields or apply a custom condition like case-insensitive string matching.
Use a manual for loop when you need to perform additional actions during the search, such as collecting indices or breaking early based on complex logic.
Use a map when you perform many lookups on a large dataset and need O(1) access time.
Use slices.Index when you need the position of the value rather than just a boolean result.
Pick the tool that matches your data size and lookup frequency.