Last Updated: May 17, 2026
Go ships without a built-in set type. The standard idiom is to use a map whose keys are the set members, treating the values as filler. Once you see the pattern, you can build unions, intersections, differences, and deduplication helpers in a few lines, which shows up constantly in cart logic, tag filters, and "who bought today" queries.
A set is a collection of unique elements with fast membership testing. Languages like Python and Java ship one in their standard library. Go doesn't, and the language team's stance is that maps already give you the behavior you need: keys are unique, lookup is O(1) average, and inserting a duplicate key is a no-op.
So Go programmers reach for a map and ignore the value. The two common spellings are map[T]struct{} and map[T]bool. Both work. They differ in memory cost and ergonomics, and we'll compare them in the next section.
Here's the shortest possible set in Go: a collection of distinct customer IDs who placed an order today.
Adding "c-101" a second time changes nothing, which is exactly what a set should do. The whole feature, uniqueness, membership testing, size, falls out of map semantics for free.
The duplicate insert reaches the same key slot and overwrites the existing (empty) value, so the set stays at two members.
Both forms work. Pick based on memory cost and what the call site looks like.
map[T]struct{} uses an empty struct as the value. An empty struct occupies zero bytes, so the map stores only the keys plus its own bookkeeping. This is the form you'll see in standard library internals and in most production Go code.
map[T]bool stores a one-byte boolean per entry. The value is always true by convention. The benefit is ergonomic: if s[x] is a clean membership test because reading a missing key returns the zero value false.
| Aspect | map[T]struct{} | map[T]bool |
|---|---|---|
| Memory per entry | Zero value bytes | One byte per entry |
| Add | s[x] = struct{}{} | s[x] = true |
| Contains | _, ok := s[x] | s[x] |
| Remove | delete(s, x) | delete(s, x) |
| Size | len(s) | len(s) |
| Idiomatic in stdlib | Yes | Less common |
Here are both versions side by side.
The bool version reads cleaner at the call site (if tagsB["sale"]). The struct version saves memory and signals intent: a reader sees map[string]struct{} and immediately knows it's a set, not a flag map.
Cost: For 1 million entries, the bool form uses around 1 MB more than the struct form just for the value cells. Map overhead dominates either way, but the struct form is the safer default.
There's a subtle gotcha with the bool form. If someone writes tagsB["sale"] = false, the key still exists in the map. len(tagsB) counts it. To truly remove, you have to call delete. The struct form sidesteps this because there's only one possible value to assign.
For the rest of this chapter, we'll use map[T]struct{} unless we're showing the bool variant for contrast. The patterns are identical otherwise.
Four operations cover almost every use of a set: add, remove, contains, and size. They map directly to map operations.
Add uses the zero-byte assignment s[x] = struct{}{}. Contains uses the comma-ok form to distinguish "key present" from "key absent". Size is len. Remove is delete. Nothing surprising once you stop expecting a Set type.
A common helper is to wrap these in named functions for readability:
This is the same map underneath, with method names that read more naturally. Defining a named type on a map (type StringSet map[string]struct{}) costs nothing at runtime, and you get method syntax instead of raw map indexing at every call site.
Cost: All four operations are O(1) average. Map lookups can degrade under pathological hash collisions, but for typical string and integer keys, treat them as constant time.
Set algebra gives you composable operations: combine two sets, find what they share, find what's unique to one. These are useful any time you have two collections and want to ask "what do they have in common?" or "what's new?".
The diagram shows the three core operations on two example sets. Union collects every member of either set. Intersection keeps only the members in both. Difference (A - B) keeps members in A that are not in B.
Union of two sets contains every element that appears in either. Build it by copying both sources into a fresh result map.
Notice the sort before printing. Map iteration order is randomized in Go, so any time you print a set, sort the keys first to get reproducible output. The set itself doesn't care about order; only the display does.
Cost: O(|A| + |B|) time and O(|A| + |B|) space. The result can be as large as both inputs combined when they're disjoint.
Intersection keeps only the elements that appear in both sets. Iterate the smaller set and check each key against the larger one. Iterating the smaller set is the trick that keeps the loop short.
The iteration choice matters for performance. If cartA has a million items and cartB has ten, iterating cartA does a million lookups against cartB; iterating cartB does ten lookups against cartA. Same answer, hundred-thousand-fold difference in work.
Cost: O(min(|A|, |B|)) time. The size of the result is bounded by the smaller set.
Difference (A - B) keeps every element of A that is not in B. The pattern is to iterate A and skip any key found in B.
Difference is not symmetric. A - B and B - A produce different results unless the two sets are equal. Here, bought - returned is what the customer kept; returned - bought would be empty (a return must come from a purchase).
Cost: O(|A|) time, since we iterate A once and do one lookup per element.
Symmetric difference is everything in either set but not in both. It equals (A - B) union (B - A). Useful when you want to spot changes between two snapshots.
sku-1 was in Monday's catalog but not Tuesday's. sku-4 is new on Tuesday. sku-2 and sku-3 are in both, so they're excluded.
Writing StringSet, then IntSet, then OrderIDSet gets old. Generics (Go 1.18+) let you write the type once and use it for any comparable element type.
The constraint T comparable is required because map keys must be comparable in Go. That covers integers, floats, strings, booleans, pointers, channels, interfaces, and structs/arrays of comparables. It excludes slices, maps, and functions, which is the same restriction as raw map keys.
Set[T comparable] is the type definition; NewSet[T comparable](items ...T) is a constructor. The methods don't repeat the comparable constraint because the type parameter is already declared at the type level. One generic implementation, reusable for Set[int], Set[string], Set[Order], anything comparable.
A set isn't an end in itself; it's the right data structure for specific questions. Here are four patterns that show up often.
The fastest way to drop duplicates from a slice is to walk it once, tracking what you've seen.
This preserves the order of first occurrence, which is what you usually want for "recently viewed" lists. The set tracks membership; the slice tracks order. Pairing the two is a common pattern.
Cost: O(n) time, O(n) extra space for the set. Faster than sorting (O(n log n)) when n is small or when order matters.
Want to recommend bundles, or check which items two friends both want? Intersect their carts.
We turn one slice into a set (constant-time lookups), then iterate the other slice and collect matches. The naive approach (nested loops) is O(n*m); this is O(n + m), which matters once the carts grow past a handful of items.
Counting unique values is the canonical set use case. Stream the events, drop them into a set, and ask for len.
Eight orders, four distinct customers. Two of them placed multiple orders. The set absorbs duplicates without you having to write a single conditional.
When each key has multiple distinct values, a map[K]Set[V] (or map[K]map[V]struct{}) keeps each value list deduplicated automatically.
The inner set guarantees each product's category list stays unique without an explicit check. Adding "electronics" to "laptop" a second time is a no-op. The add helper hides the lazy-initialization (if _, ok := categories[product]; !ok) that you'd otherwise have to write inline at every call site.
When you use map[T]bool, the zero value is false, which collides with "key not present" if you're not careful.
What's wrong with this code?
The line banned["c-101"] = false does not remove a key. It inserts "c-101" with value false. The set now treats "c-101" as present-but-unbanned, but its length includes it. Any future code that iterates banned and asks "who is in the banned list?" will see "c-101".
Fix:
Switch to map[string]struct{} and the bug becomes impossible to write: there's no false value to confuse with absence. Use delete to remove, comma-ok to test membership, and the API has only one correct shape.
map[T]struct{} for the zero-byte form or map[T]bool when ergonomic membership tests matter more than memory.s[x] = struct{}{} (add), delete(s, x) (remove), _, ok := s[x] (contains), and len(s) (size). All O(1) average.A and skips keys present in B.Set[T comparable] wraps the map pattern in one reusable type. The comparable constraint is required because map keys must be comparable.s[x] = false inserts a key, it does not remove one. The struct form sidesteps this entirely.map[K]map[V]struct{}.The next lesson covers the maps Package (Go 1.21+), which adds standard helpers like maps.Keys, maps.Values, maps.Clone, and maps.Equal so you can skip writing them by hand.