Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Golang Set: Why There Isn't One, and the Map Idiom That Replaced It

Sean

Platform Writer

Aug 27, 2026
7 min read

Go does not have a set type and is not getting one. The idiomatic replacement is a map keyed by your element type with an empty struct as the value, which costs zero bytes per element and gives you constant-time membership testing.

Golang Set: Why There Isn't One, and the Map Idiom That Replaced It

Every Go developer coming from Python or Java asks the same question in the first week, and the answers on Stack Overflow are a mix of the map idiom, hand-rolled types, and third-party libraries. All three are reasonable in different places. Here is how to tell which one you are in.

Table of contents

The idiom: map with an empty struct value

A set is a map where you only care about the keys. Go lets you say that precisely by using an empty struct as the value type, which occupies zero bytes.

seen := map[string]struct{}{}

// Add
seen["alpha"] = struct{}{}
seen["beta"] = struct{}{}

// Test membership
if _, ok := seen["alpha"]; ok {
    fmt.Println("present")
}

// Remove
delete(seen, "beta")

// Size
fmt.Println(len(seen))

The empty struct literal is genuinely ugly - it is an empty struct type followed by an empty composite literal - and it is the main reason people reach for a boolean value type instead.

A boolean value is more readable and costs one byte per entry. It also introduces an ambiguity: a key present with value false is not the same as an absent key, and a plain lookup returns false for both. If any code path ever sets a key to false, the two meanings collide. With an empty struct that mistake is unrepresentable.

Practical rule: use a boolean value for small local sets where the readability wins, and an empty struct for anything long-lived, large, or exported.

A generic Set type

If you use sets in more than a couple of places, wrapping the idiom is worth it. Generics make this about thirty lines.

type Set[T comparable] map[T]struct{}

func New[T comparable](items ...T) Set[T] {
    s := make(Set[T], len(items))
    s.Add(items...)
    return s
}

func (s Set[T]) Add(items ...T) {
    for _, i := range items {
        s[i] = struct{}{}
    }
}

func (s Set[T]) Has(item T) bool {
    _, ok := s[item]
    return ok
}

func (s Set[T]) Remove(items ...T) {
    for _, i := range items {
        delete(s, i)
    }
}

func (s Set[T]) Len() int { return len(s) }

Defining the type as a map type rather than a struct wrapping a map is deliberate. It keeps len, range, and delete working, and it means the zero value question is explicit: a nil set can be read from but not written to, exactly like a nil map.

The comparable constraint is what limits element types. Anything usable as a map key works: strings, numbers, booleans, pointers, channels, interfaces, and structs or arrays of comparable types. Slices, maps, and functions do not, because they cannot be compared for equality.

Set operations

Union, intersection, and difference are the three you will actually write. None of them are complicated, but the allocation behaviour is worth getting right.

func (s Set[T]) Union(other Set[T]) Set[T] {
    out := make(Set[T], len(s)+len(other))
    for k := range s {
        out[k] = struct{}{}
    }
    for k := range other {
        out[k] = struct{}{}
    }
    return out
}

func (s Set[T]) Intersect(other Set[T]) Set[T] {
    // Range the smaller side - this is the whole optimisation
    small, large := s, other
    if len(large) < len(small) {
        small, large = large, small
    }
    out := make(Set[T], len(small))
    for k := range small {
        if _, ok := large[k]; ok {
            out[k] = struct{}{}
        }
    }
    return out
}

func (s Set[T]) Difference(other Set[T]) Set[T] {
    out := make(Set[T], len(s))
    for k := range s {
        if _, ok := other[k]; !ok {
            out[k] = struct{}{}
        }
    }
    return out
}

Ranging the smaller set in the intersection matters more than it looks. Intersecting a ten-element set with a hundred-thousand-element one costs ten lookups if you range the small side and a hundred thousand if you range the large one. Both are linear in the abstract; only one of them is fast.

Pre-sizing with a capacity hint avoids repeated rehashing as the map grows. For sets built in a loop from a known-length input, it is free performance.

Ordering, and the trap that comes with it

Map iteration order in Go is deliberately randomised. Not unspecified - actively randomised, so that code cannot accidentally depend on it. Any set built on a map inherits this.

func (s Set[T]) Slice() []T {
    out := make([]T, 0, len(s))
    for k := range s {
        out = append(out, k)
    }
    return out
}

// For stable output, sort it
import "slices"

vals := s.Slice()
slices.Sort(vals) // requires cmp.Ordered, not just comparable

This bites in tests and in anything that serialises a set to JSON, a log line, or a cache key. A test that compares a formatted set against a fixed string will pass locally and fail in CI, or worse, pass ninety times and fail on the ninety-first. Sort before comparing, or compare sets to sets rather than strings to strings.

Note that slices.Sort needs cmp.Ordered, which is narrower than comparable. A set of structs is legal but cannot be sorted without a custom comparison function via slices.SortFunc.

When to use a library instead

Rolling your own is right for the common case. Reach for a library when you need behaviour that is genuinely fiddly to get right.

  • Concurrency - a plain map is not safe for concurrent write. If multiple goroutines mutate the set, you need a mutex around it or a library that provides one. Concurrent map writes are a runtime panic, not a data race you can ignore.
  • Sets of non-comparable types - slices and maps cannot be map keys. Libraries handle this with a hash function you supply.
  • Ordered or sorted sets - if you need iteration in a stable order without sorting on every read.
  • Rich operations - subset, superset, symmetric difference, and power set, if you actually use them.

For most services the answer is a thirty-line generic type in an internal package. Adding a dependency to avoid writing a two-value map lookup is not a trade worth making.

How this fits the rest of the stack

Sets that grow with traffic - deduplicating request IDs, tracking seen keys, caching membership - are a memory story, and memory is where a service plan stops being a detail. The RunxBuild hosting calculator shows RAM per plan alongside the database and storage lines, and autoscaling moves between a floor and a ceiling you pick, so a set that grows faster than expected costs you a plan step rather than an out-of-memory kill.

Useful related references:

FAQ

Why does Go not have a built-in set type?

Because a map already provides everything a set needs - constant-time insert, delete, and membership - and the language team has consistently favoured a smaller standard library over convenience types. With generics, a set is about thirty lines of user code, which weakens the argument for adding one to the language.

Should I use a bool or an empty struct as the map value?

An empty struct uses zero bytes per value and makes it impossible to represent a key that is present but false. A boolean is more readable and lets you write a single-value lookup directly in a condition. Use bool for short-lived local sets, and the empty struct for large, long-lived, or exported ones.

Are Go maps safe for concurrent use?

No. Concurrent reads are fine, but a concurrent write triggers a runtime panic rather than silent corruption. Protect a shared set with a read-write mutex, or use sync.Map if the access pattern is read-heavy with disjoint keys - though it is slower for the common case and is not a general replacement.

Can I put a struct in a Go set?

Yes, as long as every field is comparable. Structs of strings, numbers, and booleans work as map keys. A struct containing a slice, map, or function is not comparable and will not compile as a key. Convert those to a comparable form first, such as a joined string or an array.

Why is my set iteration order different every run?

Go deliberately randomises map iteration order so that code cannot come to depend on it. Any set backed by a map inherits this. If you need stable output, collect the elements into a slice and sort it before use - especially in tests and anything that produces a cache key or log line.

#golang set#go set data structure#go map#go generics#go collections