The lookup problem
You just fetched a list of user roles from the database. Each row contains a username and a role string. Now you need to check if "alice" is an admin without scanning the whole list every time. A slice works for iteration, but it's slow for lookups. You need a map. The data is already in memory as a slice, so you have to bridge the gap.
Go doesn't perform automatic conversions between collection types. There is no built-in function that magically turns a slice into a map. The language requires you to write the loop. This design choice keeps the runtime predictable and forces you to think about the cost of the transformation. You see exactly where the allocation happens and how the data moves.
Slice versus map
A slice is an ordered sequence backed by a contiguous array. Access by index is fast. Iteration is fast. Memory usage is compact. A map is a hash table. Access by key is fast, usually constant time. Insertion is fast. Memory usage is higher because of the hash buckets and overhead. Order is not guaranteed.
When you convert a slice to a map, you are trading iteration speed and memory efficiency for lookup speed. You do this when the downstream code needs to find items by a key repeatedly. If you only iterate once, keep the slice. If you search many times, build the map.
The standard loop
Here's the standard pattern: allocate the map with a capacity hint, range over the slice, and assign each key-value pair.
// ConvertSliceToMap builds a map from a slice of key-value pairs.
// It returns a map where the last occurrence of a key wins.
func ConvertSliceToMap(pairs []struct{ K, V string }) map[string]string {
// Pre-allocate with len to avoid reallocation during the loop.
// The capacity hint tells the runtime to reserve space upfront.
result := make(map[string]string, len(pairs))
for _, item := range pairs {
// Assign the key-value pair directly.
// If the key exists, this overwrites the previous value.
result[item.K] = item.V
}
return result
}
Convention aside: The underscore _ discards the loop index. The Go community uses _ to signal that the index is intentionally ignored. It prevents linters from complaining about unused variables and makes the intent clear to readers.
The make call includes len(pairs) as the second argument. This is a capacity hint. Maps in Go grow dynamically. Without a hint, the map starts with a small number of buckets and resizes as entries are added. Each resize allocates a larger table and copies all existing keys. Pre-allocation avoids these intermediate allocations. It turns a series of memory operations into a single allocation.
Pre-allocate the map. Let the loop fill it.
How the runtime handles it
At compile time, the type checker verifies that the map key and value types match the struct fields. If item.K is an integer but the map expects a string key, the compiler rejects the code with cannot use item.K (type int) as string value in map index. This error catches type mismatches before the program runs.
At runtime, make allocates the hash table structure. The loop iterates over the slice. For each iteration, the runtime hashes the key, finds the bucket, and stores the value. If the key already exists, the value is overwritten. The loop variable item is a copy of the slice element. Modifying item inside the loop does not affect the original slice.
Maps are fast lookups. Slices are fast iteration. Pick the tool for the job.
Real-world: Config parsing with validation
In production code, conversion often includes validation. You might be parsing configuration entries where empty keys are invalid. The conversion function should return an error if the data is malformed.
// ConfigEntry represents a single configuration line.
type ConfigEntry struct {
Key string
Value string
}
// ParseConfig builds a map from raw configuration entries.
// It returns an error if a key is missing or empty.
func ParseConfig(entries []ConfigEntry) (map[string]string, error) {
// Capacity hint prevents map resizing overhead.
result := make(map[string]string, len(entries))
for _, entry := range entries {
// Validate the key before insertion.
// Returning early stops processing and propagates the error.
if entry.Key == "" {
return nil, fmt.Errorf("empty key in config entry")
}
result[entry.Key] = entry.Value
}
return result, nil
}
Convention aside: Functions that return a value and an error should return the value first, then the error. The error is always the last return value. This convention allows callers to check the error immediately after the call. The receiver name convention doesn't apply here since this is a plain function, but if it were a method, the receiver would be named with one or two letters matching the type, like (c *Config) Parse().
Validation during conversion keeps bad data out of the map. Return errors early.
Pitfalls and compiler errors
Nil maps panic. If you declare a map variable without initializing it, the variable holds a nil value. Assigning to a nil map causes a runtime panic with assignment to entry in nil map. This happens frequently when developers forget make or use a composite literal incorrectly. Always initialize the map before writing to it.
Duplicate keys are silent. If the slice contains multiple entries with the same key, the loop overwrites the value each time. The final map contains only the last value. This behavior is often intentional, but it can hide bugs if you expect all entries to be preserved. If you need to detect duplicates, check for key existence before assignment.
Map keys must be comparable. You cannot use slices, maps, or functions as map keys. The compiler enforces this rule. If you try to use a slice as a key, the compiler rejects the code with invalid map key type slice. This restriction exists because map keys require a reliable equality check. Slices and maps use reference equality, which is not suitable for hashing.
Generics keep the type checker happy.
Generics for reusable code
Go 1.18 introduced type parameters. You can write a generic function that converts a slice of pairs to a map for any key and value types. The constraint comparable is mandatory for map keys.
// Pair is a generic struct for key-value pairs.
type Pair[K comparable, V any] struct {
Key K
Value V
}
// ToMap converts a slice of pairs to a map using generics.
// The key type K must be comparable to satisfy map requirements.
func ToMap[K comparable, V any](pairs []Pair[K, V]) map[K]V {
// Pre-allocate capacity based on slice length.
result := make(map[K]V, len(pairs))
for _, p := range pairs {
result[p.Key] = p.Value
}
return result
}
The comparable constraint ensures that the key type supports equality comparison. The compiler verifies this at compile time. If you call ToMap with a slice of pairs where the key is a slice, the compiler rejects it with type []int does not satisfy comparable. This check prevents runtime panics and makes the API safe by construction.
Generics reduce boilerplate. Use them when you need the same logic for multiple types.
Decision matrix
Use a map when you need O(1) lookups by key and the data is accessed randomly. Use a slice when order matters or you iterate sequentially over all elements. Use a slice when memory is tight and you don't need hashing overhead. Use a map when keys can be duplicated in the source and you want the last value to win. Use a map with slice values when you need to group items by key and preserve multiple entries per key. Use a binary search on a sorted slice when you need lookups but want to save memory and the data is static.
Trust the type system. Let the compiler catch key type errors.