AlgoMaster Logo

Implementing Sets with Maps

Last Updated: May 17, 2026

8 min read

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.

Why Sets Need a Workaround

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.

map[T]struct{} vs map[T]bool

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.

Aspectmap[T]struct{}map[T]bool
Memory per entryZero value bytesOne byte per entry
Adds[x] = struct{}{}s[x] = true
Contains_, ok := s[x]s[x]
Removedelete(s, x)delete(s, x)
Sizelen(s)len(s)
Idiomatic in stdlibYesLess 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.

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.

Basic Set Operations

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.

Set Algebra: Union, Intersection, Difference

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

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.

Intersection

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.

Difference

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).

Symmetric Difference (Brief)

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.

A Generic Set Type

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.

Practical Patterns

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.

Deduplicate a Slice

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.

Find Common Items Between Two Carts

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.

Distinct Customers Today

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.

Categories Per Product (Set Per Key)

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.

Common Mistake: Confusing Zero Value with Absent Key

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.

Summary

  • Go has no built-in set. Use map[T]struct{} for the zero-byte form or map[T]bool when ergonomic membership tests matter more than memory.
  • The four core operations are s[x] = struct{}{} (add), delete(s, x) (remove), _, ok := s[x] (contains), and len(s) (size). All O(1) average.
  • Set algebra: union copies both sources, intersection iterates the smaller set against the larger, difference iterates A and skips keys present in B.
  • Map iteration order is randomized, so sort the keys before printing if you need reproducible output.
  • A generic Set[T comparable] wraps the map pattern in one reusable type. The comparable constraint is required because map keys must be comparable.
  • The bool form has a footgun: s[x] = false inserts a key, it does not remove one. The struct form sidesteps this entirely.
  • Common uses: deduplicating a slice while preserving order, finding common items between two collections, counting distinct values in a stream, and keeping per-key unique value lists with map[K]map[V]struct{}.
  • Iterating the smaller set in intersection is a constant-factor win that becomes a huge win when set sizes are very different.

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.