Maps as Sets
Learn how to implement set data structures in Go using maps with bool and empty struct values, covering operations, trade-offs, and real-world examples
A set is a collection of unique elements where the only question that matters is whether an item belongs or not. Go does not provide a dedicated set type in the language or the standard library. The built-in map, with its requirement that every key is distinct, maps directly onto what a set needs—a collection where duplicates are impossible by design.
This section covers two map-based patterns that give you a complete set implementation: map[T]bool and map[T]struct{}. Both rely on the same underlying mechanism but make different trade-offs between expressiveness and memory use.
How a map becomes a set
In a map, the keys form a unique collection. The values you attach to those keys are irrelevant to the set property. A set is effectively a map where the values are placeholders whose only job is to let the key exist. You never read the value itself; you only check whether the key is present.
The choice of placeholder type shapes the API you get when writing membership checks. Two types have become idiomatic:
bool— lets you useif set[key]directly because the zero value isfalse.struct{}— an empty struct that takes zero bytes, making clear that the value carries no meaning.
Zero values and nil maps:
A nil map behaves like an empty set when reading: looking up any key returns the zero value of the value type. Attempting to add a key to a nil map, however, causes a runtime panic. Always initialize a map with make or a map literal before writing to it.
The map[T]bool pattern
This is the most approachable pattern because it exploits Go’s zero-value behavior to simplify membership tests. If you only ever assign the value true to keys that belong to the set, a missing key naturally returns false.
Creating a set with map[T]bool
// An empty set of integers, ready for use.
set := make(map[int]bool)
// A set initialized with some values.
seen := map[string]bool{
"go": true,
"map": true,
"set": true,
}
To add an element, assign true:
set[42] = true
Adding the same key again has no effect—the value is already true. To remove an element, use the built-in delete:
delete(set, 42)
Membership testing without the comma-ok idiom
Because the zero value of bool is false, you can test membership with a plain if:
if set[42] {
fmt.Println("42 is in the set")
}
This works as long as you never store false values explicitly. If someone writes set[42] = false, the membership check becomes unreliable because a missing key also returns false. The convention is to only ever set true and to delete keys that leave the set. That convention makes the zero value genuinely useful—no separate existence check is required.
Storing false values breaks the pattern:
If your logic must store false for a key that is still present (for example, representing a disabled feature flag), the simple if set[key] check no longer distinguishes “absent” from “present but false.” In that case, use the two-value comma-ok form: v, ok := set[key]. The boolean ok tells you whether the key exists regardless of its value.
Iterating over a set
Use range on the map. The value is always true for present keys, so you can discard it.
for key := range set {
fmt.Println(key)
}
Do not write to a nil map:
A var set map[int]bool declaration without initialization produces a nil map. Reads work; set[5] = true will panic. Always use make(map[int]bool) or map[int]bool{} before writing.
Counting with a boolean map
One surprisingly common use of map[T]bool is not for set membership alone, but for counting occurrences with the help of the zero value. Since false evaluates to 0 in numeric contexts, you cannot directly increment a bool. For frequency counting, map[T]int is more natural. But a boolean map still handles the “first time I see this” case cleanly:
visited := make(map[string]bool)
for _, item := range stream {
if !visited[item] {
visited[item] = true
process(item)
}
}
The check !visited[item] is true the first time because the zero value is false. No need for an existence check first.
The zero-value pattern is intentional:
If you only ever assign true, the expression if set[key] unambiguously means “key is in the set.” This is the most common and recommended usage of map[T]bool as a set.
The map[T]struct{} pattern
An empty struct uses no memory. Using it as the value type makes clear to anyone reading the code that the values are pure placeholders—no one will mistake them for carrying meaning. The trade-off is that membership testing requires the comma-ok idiom because struct{} has no meaningful zero value you can test.
Creating a set with map[T]struct{}
set := make(map[string]struct{})
// Adding an element requires an empty struct literal.
set["go"] = struct{}{}
// Initialization with values.
languages := map[string]struct{}{
"go": {},
"rust": {},
}
The notation struct{}{} appears clunky at first, but you quickly get used to it. The first pair of braces denotes the type (struct{}), the second pair is the literal value.
Membership testing with comma-ok
Since struct{} carries no boolean information, the only reliable way to check membership is the two-value assignment:
if _, ok := set["go"]; ok {
fmt.Println("go is present")
}
If you try if set["go"] == struct{}{}, it works but is needlessly verbose and misleading—the comparison implies the value matters, when it doesn’t. The comma-ok form expresses the intent directly: does the key exist?
Deleting and iterating
Same operations as any map:
delete(set, "go")
for key := range set {
fmt.Println(key)
}
When to choose struct{} over bool
The memory difference is rarely the deciding factor for small sets. The real reason to pick struct{} is to signal, at the type level, that no value interpretation should ever be attempted. If another developer sees map[string]bool, they might wonder if the boolean has a purpose (is it a flag? is it ever set to false?). map[string]struct{} removes that question.
In performance-sensitive code with large sets, the zero-allocation property of the empty struct can reduce memory pressure, but the gain is often marginal compared to the clarity gain. Use struct{} when you want to prevent value misuse; use bool when you want the convenience of if set[key].
Common real-world applications
Sets appear in codebases in a handful of recurring situations. Recognizing them helps you know when a map-as-set is the right tool.
Deduplication of a slice. Walk a slice, insert each element into a map, then collect the keys. The map automatically discards duplicates.
func uniqueStrings(input []string) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(input))
for _, s := range input {
if _, ok := seen[s]; !ok {
seen[s] = struct{}{}
result = append(result, s)
}
}
return result
}
The order of elements in result is their first occurrence order, which is often what you want.
Cycle detection in linked structures. As shown in the Go blog, you can store pointers to nodes in a map[*Node]bool. The zero-value default lets you write a clean loop:
func hasCycle(head *Node) bool {
visited := make(map[*Node]bool)
for n := head; n != nil; n = n.Next {
if visited[n] {
return true
}
visited[n] = true
}
return false
}
Tracking processed IDs. Imagine a stream of events identified by a unique ID. You need to ignore events already seen. A map[string]bool or map[string]struct{} acts as a lightweight in-memory filter.
processed := make(map[string]bool)
for event := range eventStream {
if processed[event.ID] {
continue
}
processed[event.ID] = true
handle(event)
}
If the stream is unbounded, this set grows without limit—in production, you might pair it with a TTL-based eviction, but the core membership test stays the same.
Set operations as map operations
The usual set operations (union, intersection, difference) are not provided as built-ins but are trivial to write with a map. They illustrate the flexibility of the underlying representation.
func intersection(a, b map[string]bool) map[string]bool {
result := make(map[string]bool)
for k := range a {
if b[k] {
result[k] = true
}
}
return result
}
Map iteration order is not stable:
When you range over a set to produce output, the order of elements is undefined and can vary between runs. If you need a predictable order, extract the keys into a slice, sort it, and then iterate.
A brief note on concurrency
A regular map is not safe for concurrent use. If one goroutine writes to a set while another reads or writes, the program will likely panic with a fatal error. This applies equally to map[T]bool and map[T]struct{}.
For sets shared across goroutines, you need explicit synchronization—typically sync.RWMutex or sync.Map for specific use cases. The rule is simple: don’t share a map-based set across goroutines without a synchronization mechanism.
Summary
Go does not need a separate set type because a map with ignored values fills that role completely. The two canonical patterns—map[T]bool and map[T]struct{}—give you different trade-offs. bool lets you write if set[key] without an existence check, as long as you only store true. struct{} uses no memory and signals that the values are meaningless, at the cost of requiring the comma-ok idiom for membership tests.
Which you choose depends on the context of the code around it. In a function that performs a quick membership check in a tight loop, the bool pattern can feel lighter. In a large codebase where the map may be passed around, struct{} prevents anyone from accidentally attaching meaning to the value. Both are valid, and both are idiomatic Go.