Merging maps in Go
You're building a service that reads configuration from multiple sources. The binary ships with sensible defaults. The operator provides a file with overrides. A feature flag service injects runtime changes. You need to combine these layers into a single map so the rest of the application can query settings without knowing where they came from. Merging maps is the operation that glues these sources together. Go doesn't attach a .Merge method to the map type. You perform the merge by copying entries from one map into another, and the approach depends on your Go version and whether you care about mutating the inputs.
A map in Go is a reference to a hash table on the heap. When you pass a map to a function, you pass the reference. Everyone sees the same underlying table. Merging means iterating over a source map and inserting its key-value pairs into a destination map. If a key exists in both, the source value overwrites the destination value. This is the default behavior. Go maps are mutable. There is no built-in operation that returns a new map without touching the originals. You always mutate a map, or you create a third map and copy into that.
The modern approach
Here's the standard approach for Go 1.21 and later. The maps package provides Copy to handle the iteration for you.
package main
import (
"fmt"
"maps"
)
func main() {
// Destination map holds the base configuration.
defaults := map[string]string{
"timeout": "30s",
"retries": "3",
}
// Source map contains user overrides.
overrides := map[string]string{
"timeout": "60s",
"debug": "true",
}
// maps.Copy copies all key-value pairs from overrides into defaults.
// Keys in overrides overwrite existing keys in defaults.
// The destination map must be initialized before calling Copy.
maps.Copy(defaults, overrides)
fmt.Println(defaults)
// Output: map[debug:true retries:3 timeout:60s]
}
maps.Copy walks the source map and assigns each entry to the destination. The function returns the destination map, which allows chaining, though the return value is rarely used. The source map stays unchanged. If you pass the same map as both arguments, maps.Copy detects the overlap and does nothing. This prevents accidental corruption from self-copying.
Preserving originals
Real code usually needs to preserve the original data. You don't want a merge operation to mutate the global defaults or the user's input map. Use maps.Clone to create a fresh copy before merging.
package main
import (
"fmt"
"maps"
)
// MergeConfig creates a new map by combining defaults with overrides.
// The original maps are not modified.
func MergeConfig(defaults, overrides map[string]string) map[string]string {
// Create a new map and copy defaults into it first.
// This ensures the caller's defaults map stays untouched.
result := maps.Clone(defaults)
// Now copy overrides into the result.
// Overrides take precedence for shared keys.
maps.Copy(result, overrides)
return result
}
func main() {
base := map[string]string{"port": "8080", "host": "localhost"}
user := map[string]string{"port": "9090"}
merged := MergeConfig(base, user)
fmt.Println("Base:", base)
fmt.Println("Merged:", merged)
// Base: map[host:localhost port:8080]
// Merged: map[host:localhost port:9090]
}
maps.Clone allocates a new map and copies all entries. It's the safe way to start a merge chain when immutability matters.
Pitfalls and runtime traps
The most common error when merging maps is passing a nil destination. A nil map has no underlying hash table. Writing to it crashes the program. The runtime panics with panic: assignment to entry in nil map. Always initialize the destination map with make before calling Copy.
If you're building the destination from scratch, calculate the capacity. make(map[K]V, len(a)+len(b)) tells the runtime to allocate space for all expected keys. This avoids resizing the hash table as entries are added. Resizing copies all existing entries to a new table, which adds latency and memory pressure. Pre-allocation is a small detail that pays off for large maps.
Maps perform a shallow merge. maps.Copy copies the values stored in the map. If the value is a pointer, a slice, or another map, the reference is copied, not the data. Both keys in the result point to the same underlying object. Modifying the value through one key affects the other. This trap catches developers who expect deep cloning behavior. The standard library cannot provide deep merge because it doesn't know how to clone arbitrary types. If you need deep merging, write custom logic to recurse into the values.
package main
import (
"fmt"
"maps"
)
func main() {
// Shallow merge warning: values are copied by value.
// If values are slices, references are shared.
m1 := map[string][]int{"ids": {1, 2}}
m2 := map[string][]int{"ids": {3, 4}}
maps.Copy(m1, m2)
// m1["ids"] now points to the same slice as m2["ids"].
// Modifying m1["ids"] modifies the data m2 sees too.
m1["ids"][0] = 99
fmt.Println(m2["ids"])
// Output: [99 4]
}
Map iteration order is randomized by the runtime. This prevents code from depending on insertion order. Merging respects this invariant. The result contains all keys, but the order you observe when ranging over the result is unpredictable. Don't write code that assumes the merged map preserves the order of the source or destination.
Pre-1.21 and custom logic
Before Go 1.21, you wrote the loop yourself. The logic is identical to maps.Copy. The compiler optimizes range loops aggressively, so the performance difference is negligible. The loop gives you more control. You can filter keys, transform values, or implement custom conflict resolution. maps.Copy always overwrites. If you want to keep the destination value when a key collides, use the loop.
// MergeWithPriority copies keys from source to dest.
// If a key exists in both, the destination value is kept.
func MergeWithPriority(dest, source map[string]int) {
for k, v := range source {
// Check if the key already exists in the destination.
// Only insert if the key is missing.
if _, exists := dest[k]; !exists {
dest[k] = v
}
}
}
The loop is also the place to add logging, validation, or type conversion during the merge. maps.Copy is a blunt instrument. Use the loop when the merge has rules.
Decision matrix
Use maps.Copy when you are on Go 1.21 or later and want to merge maps with standard library support. Use a for range loop when you need custom conflict resolution, such as keeping the destination value or summing numeric entries. Use maps.Clone followed by maps.Copy when you must preserve the original maps and return a new merged result. Use make with a calculated capacity when merging large maps to avoid runtime reallocations. Use a struct with typed fields when the configuration shape is fixed and you want compile-time safety instead of a generic map.
Maps are references. Mutating one affects everyone holding it. Nil maps panic on write. Pre-allocate for performance. Shallow merge copies references, not data.