DEV Community

Cover image for iota in Go: Enums, Bit Flags, and the Traps Beginners Hit
Gabriel Anhaia
Gabriel Anhaia

Posted on

iota in Go: Enums, Bit Flags, and the Traps Beginners Hit


You add a new status to an enum. You put it in the middle of the
list because that's where it belongs alphabetically. Tests pass.
Then a support ticket comes in: a user who was Suspended is now
showing as Banned, and nobody wrote code to ban them.

That is iota doing exactly what you told it to. The problem is
most Go developers never learn what they told it.

What iota actually is

iota is a compile-time counter that resets to 0 at the start of
every const block and increments by one for each ConstSpec line
in that block. That's the whole rule. Everything else is a
consequence of it.

const (
    A = iota // 0
    B = iota // 1
    C = iota // 2
)
Enter fullscreen mode Exit fullscreen mode

Go lets you drop the repeated = iota. When a const line has no
expression, it reuses the expression from the line above, and iota
still counts the line. So this is identical:

const (
    A = iota // 0
    B        // 1
    C        // 2
)
Enter fullscreen mode Exit fullscreen mode

The counter tracks the line position in the block, not the number
of names you actually assigned. Hold onto that, because it's where
the gotcha comes from.

Pattern 1: a typed enum with a Stringer

A bare int enum is fine, but you lose type safety. Give the enum
its own type and the compiler starts catching mistakes for you.

type Status int

const (
    StatusActive Status = iota // 0
    StatusSuspended            // 1
    StatusBanned               // 2
)
Enter fullscreen mode Exit fullscreen mode

Now a function that takes a Status won't accept a raw int, and
go vet can reason about the type. The next thing you want is a
readable name instead of 1 in your logs. Implement String():

func (s Status) String() string {
    switch s {
    case StatusActive:
        return "active"
    case StatusSuspended:
        return "suspended"
    case StatusBanned:
        return "banned"
    default:
        return fmt.Sprintf("Status(%d)", int(s))
    }
}
Enter fullscreen mode Exit fullscreen mode

The default case matters. If someone hands you a Status(7), you
want Status(7) in the log, not a silent empty string. Print it and
the fmt package finds the method automatically:

fmt.Println(StatusSuspended) // suspended
Enter fullscreen mode Exit fullscreen mode

For enums with many values, the hand-written switch gets tedious.
The stringer tool generates it for you:

//go:generate stringer -type=Status
Enter fullscreen mode Exit fullscreen mode

Run go generate ./... and it writes a status_string.go with the
String() method. It uses the constant names, so StatusActive
becomes "StatusActive" unless you strip the prefix with the
-linecomment flag. Generated or hand-written, the contract is the
same: every valid value has a name, and unknown values are visible.

Pattern 2: skipping and offsetting

Sometimes value 0 should mean "unset" so a zero-valued struct
field doesn't accidentally look like a real enum member. Use the
blank identifier to burn the first count:

type Priority int

const (
    _               = iota // 0, discarded
    PriorityLow            // 1
    PriorityMedium         // 2
    PriorityHigh           // 3
)
Enter fullscreen mode Exit fullscreen mode

Now the zero value of Priority is not a valid priority, which is
usually what you want. A struct with a Priority field defaults to
0, and your validation can reject it.

You can also do arithmetic on iota. The classic example is the
ByteSize type from Effective Go, where each line shifts by ten
bits:

type ByteSize float64

const (
    _           = iota // ignore 0
    KB ByteSize = 1 << (10 * iota)
    MB                 // 1 << 20
    GB                 // 1 << 30
    TB                 // 1 << 40
)
Enter fullscreen mode Exit fullscreen mode

Line two computes 1 << (10 * 1). The lines below reuse that same
expression, and because iota keeps counting, MB evaluates
1 << (10 * 2). The expression is copied down; the counter is what
changes.

Pattern 3: bit flags

iota and a left shift give you a clean set of bit flags. Each flag
is a distinct power of two, so you can combine them with OR and test
them with AND.

type Perm uint8

const (
    PermRead Perm = 1 << iota // 1  (0b001)
    PermWrite                 // 2  (0b010)
    PermExec                  // 4  (0b100)
)
Enter fullscreen mode Exit fullscreen mode

1 << iota is 1 << 0, 1 << 1, 1 << 2 down the block. Combine
and check them like this:

p := PermRead | PermWrite // 0b011

if p&PermWrite != 0 {
    fmt.Println("can write") // prints
}
if p&PermExec != 0 {
    fmt.Println("can exec")  // does not print
}
Enter fullscreen mode Exit fullscreen mode

Adding a flag is safe as long as you append it at the bottom, so the
existing shifts don't move. Once these values are persisted anywhere
(a database column, a wire format), their positions are a contract.
The os package uses this shape for file-mode bits (os.ModeDir,
os.ModeSymlink, and the rest).

The gotcha: gaps and shifts across a reorder

Back to the support ticket. Here's the enum before the change:

const (
    StatusActive    Status = iota // 0
    StatusSuspended               // 1
    StatusBanned                  // 2
)
Enter fullscreen mode Exit fullscreen mode

Someone adds StatusPending and, wanting the list tidy, inserts it
in the middle:

const (
    StatusActive    Status = iota // 0
    StatusPending                 // 1  (was Suspended)
    StatusSuspended               // 2  (was Banned)
    StatusBanned                  // 3
)
Enter fullscreen mode Exit fullscreen mode

Every value below the insertion point shifted up by one. A row in
the database written as 1 used to mean StatusSuspended. Now the
code reads 1 as StatusPending. The old 2 rows now read as
StatusBanned. Nothing errors. The compiler is happy, the tests
that use the constant names still pass, and the meaning of your
stored data changed underneath you.

iota couples the name of a constant to its line position. That
coupling is fine while the values live only in memory. The moment
they cross a persistence or network boundary, the numbers are the
contract, not the names.

Two habits keep you safe. First, if the values are persisted, pin
them explicitly and stop letting position decide:

const (
    StatusActive    Status = 0
    StatusSuspended Status = 1
    StatusBanned    Status = 2
    StatusPending   Status = 3 // new, appended
)
Enter fullscreen mode Exit fullscreen mode

Second, when you do keep iota, only ever append at the bottom, and
add a compile-time guard so a reorder can't slip past review:

func init() {
    if StatusBanned != 2 {
        panic("Status values changed; check DB mapping")
    }
}
Enter fullscreen mode Exit fullscreen mode

A blunt check, but it turns a silent data corruption into a loud
startup failure. That is a trade you want.

The other gap: iota resets per block

iota restarts at 0 in every new const block. Split an enum
across two blocks and the second block silently overlaps the first:

const (
    RoleUser  = iota // 0
    RoleAdmin        // 1
)

const (
    RoleGuest = iota // 0 again — collides with RoleUser
    RoleBot          // 1 again — collides with RoleAdmin
)
Enter fullscreen mode Exit fullscreen mode

Now RoleGuest == RoleUser, and any comparison between them passes
when it shouldn't. Keep a single enum in a single const block. If
you need a break in the middle, use the blank identifier to skip a
count rather than opening a second block.

The one rule that saves you

Everything above collapses to a single habit: pin the numbers by
hand the moment those values get written to a database or a wire. Up
to that point iota is a convenience. After it, the integer is your
contract, and a tidy reorder is a breaking change.

Enums are small, but they sit on the boundary between your Go code
and everything that stores or reads it — which is exactly the kind
of detail The Complete Guide to Go Programming digs into when it
covers constants, the type system, and how Go's compile-time
machinery works. Hexagonal Architecture in Go takes the next step:
keeping those enums pinned at the edges of a service so persisted
values stay stable while the domain code changes freely.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (2)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Small thing on the init() panic guard, and I might be nitpicking the wording rather than the idea: it's described as a compile-time guard, but init() runs at program start, so a bad reorder still compiles cleanly and only blows up when the binary boots. For most services that's fine since it fails on the next deploy, but it means a broken build can pass CI if nothing actually runs the binary. If you want it to fail before that, a tiny test that asserts StatusBanned == 2 moves the check into go test, so a reorder trips in CI instead of at runtime. Genuinely liked the framing that names couple to line position until the number crosses a boundary, that's the sentence I'd hand a junior.

Collapse
 
marcusykim profile image
Marcus Kim

The support-ticket example is the right way to teach this: iota isn't dangerous until the number escapes memory and becomes somebody else's data contract. I like the two practical guardrails here, especially pinning persisted StatusActive = 0, StatusSuspended = 1, etc., and keeping a default String() branch that prints Status(7) instead of hiding bad input. The founder/engineer angle is that enum values deserve the same migration discipline as schema columns: once customers' rows or integrations depend on them, tidying the list alphabetically is a breaking change, even if every unit test using named constants still passes.