How to Use a Map as a Set in Go

Use a map with boolean values to simulate a set in Go for tracking unique items efficiently.

The unique item problem

You are processing a stream of transaction IDs. Network retries cause some IDs to arrive twice. You need to skip duplicates without scanning a growing list every time. A slice would force an O(n) scan for every check. You want O(1) lookup. Go does not have a built-in Set type. You reach for a map.

Maps are key-value stores. Sets are collections of unique items with no associated data. To fake a set, you use the key for your item and pick a dummy value that satisfies the type system. The community standard is bool. You store true for every item. The value is just a placeholder. The key is what matters.

Maps as sets

A map in Go stores pairs: a key and a value. The key must be comparable. The value can be any type. A set is just a collection of keys. By using a map where the value is always true, you get a set backed by a hash table.

Think of a map like a phonebook: Name maps to Number. A set is like a guest list: just Names. To use a phonebook as a guest list, you write "Yes" next to every name. The number does not matter. The name is the entry.

The comma-ok idiom is essential for sets. When you look up a key, the map returns the value and a boolean flag. The flag tells you whether the key exists. This distinguishes between a missing key and a key with a zero value. For a bool set, the zero value is false. If you only check the value, you cannot tell if the key is missing or if the value is false. The comma-ok idiom solves this.

Minimal example

Here is the skeleton: create the map, add items, check existence, remove.

package main

import "fmt"

func main() {
    // map[string]bool uses the string as the set member and bool as a dummy value
    seen := make(map[string]bool)

    // Add items by setting the key to true
    seen["apple"] = true
    seen["banana"] = true

    // Check existence using the comma-ok idiom
    // ok is true if the key exists, false if the key is missing
    if _, ok := seen["apple"]; ok {
        fmt.Println("apple is in the set")
    }

    // Remove an item
    delete(seen, "banana")
}

Maps are reference types. The variable holds a pointer to the underlying hash table. Passing the map to a function shares the set. You do not need to return the map to see changes.

Maps are references. Share the map, share the set.

How the map tracks your set

When you call make(map[string]bool), Go allocates a hash table on the heap. The map variable holds a pointer to that table. Assigning seen["apple"] = true computes a hash for "apple", finds a slot, and stores the key and the value true.

When you look up a key, Go hashes the key and checks the slot. If the key matches, it returns the value and true for the ok flag. If the key is not there, it returns the zero value of the value type and false for ok. For bool, the zero value is false.

The delete function removes a key. If the key does not exist, delete does nothing. It is safe to call delete on missing keys. You do not need to check existence before deleting.

If you declare a map with var but do not initialize it, the map is nil. Writing to a nil map causes a runtime panic: panic: assignment to entry in nil map. Always use make or a map literal.

You can also use a map literal to create and populate a set in one step.

// Map literal creates and populates the set immediately
banned := map[string]bool{
    "spam": true,
    "junk": true,
}

Map keys must be comparable. You cannot use a slice as a key. The compiler rejects map[[]int]bool with invalid map key type []int. Use a string or a struct with comparable fields instead.

Maps are not thread-safe. Concurrent reads and writes cause a panic: fatal error: concurrent map writes. Protect the map with a sync.Mutex if multiple goroutines access it.

Initialize your maps and guard them with locks.

Real-world deduplication

Here is a utility that removes duplicates from a slice of strings using a map-as-set to track what you have already seen.

// Deduplicate returns a slice with unique items, preserving order.
func Deduplicate(items []string) []string {
    // Track seen items to filter duplicates
    seen := make(map[string]bool)
    var result []string

    for _, item := range items {
        // Skip if we have already processed this item
        // Checking seen[item] is safe here because we only ever write true
        // The zero value false correctly indicates absence
        if seen[item] {
            continue
        }

        // Mark as seen and add to result
        seen[item] = true
        result = append(result, item)
    }

    return result
}

In this function, checking seen[item] without the comma-ok idiom is safe. The invariant is that we only write true. If the key is missing, the value is false. If the key exists, the value is true. The zero value matches the "not present" state. This is a valid optimization for bool sets where the invariant holds.

If you break the invariant and write false, the check fails. The comma-ok idiom is more robust because it does not rely on the value. Use the comma-ok idiom when the value might be false or when you want to be explicit.

Order is an illusion in maps. If you need order, keep a slice of keys.

Pitfalls and memory tricks

The bool value consumes one byte per entry. For small sets, this is fine. For millions of items, the memory adds up. The community often uses struct{} as the value type. An empty struct takes zero bytes. map[string]struct{} is the memory-efficient set.

// EfficientSet uses struct{} to save memory on large sets
type EfficientSet map[string]struct{}

// Add marks the item as present in the set
func (s EfficientSet) Add(item string) {
    // struct{} literal is the zero value, so this adds the key with no payload
    s[item] = struct{}{}
}

// Contains checks if the item is in the set
func (s EfficientSet) Contains(item string) bool {
    // Comma-ok idiom is required because the value is always the zero value
    _, ok := s[item]
    return ok
}

With struct{}, you must use the comma-ok idiom for lookups. The value is always the zero value of struct{}, which is indistinguishable from a missing key if you only check the value. The ok flag is the only way to check existence.

Receiver naming follows Go convention. The receiver name is usually one or two letters matching the type. Here, s matches EfficientSet. Do not use this or self.

If you wrap the map in a struct, the map field should be lowercase. This enforces access through methods. Public names start with a capital letter. Private names start lowercase.

// UserSet wraps a map to enforce access rules
type UserSet struct {
    // users is private to prevent direct map manipulation
    users map[string]struct{}
}

The worst map bug is the one that panics under load. Initialize your maps and guard them with locks.

When to use what

Use map[T]bool when you want the most readable code for small to medium sets and memory is not a constraint.

Use map[T]struct{} when you are storing millions of items and want to minimize heap allocation per entry.

Use a slice with linear scan when the set has fewer than ten items and you want to avoid map allocation overhead.

Use a sorted slice with binary search when you need range queries or deterministic iteration order without the randomness of maps.

Use a third-party set library only when you need complex set operations like union, intersection, or difference that you do not want to implement manually.

Pick the representation that matches your constraints. Readability beats micro-optimizations until the profiler says otherwise.

Where to go next