Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 088 / 100

Enums in Go with iota: a practical guide for developers

Go has no enum keyword — but has iota.

Expona Admin7 min read

Why Go developers need to understand iota

Go has no enum keyword, yet we need type-safe enumerations in nearly every production system we build. The solution lies in combining named types with the iota constant generator, a feature that feels minimal at first glance but becomes powerful once you understand its mechanics. Enums in Go with iota: a practical guide for developers will walk you through building robust, maintainable enumerated types that integrate seamlessly with your APIs, databases, and external systems.

We've encountered this pattern across e-commerce platforms tracking order statuses, ride-hailing services managing vehicle states, and SaaS applications handling subscription tiers. The patterns we'll cover today come directly from production code that's processed millions of transactions.

Define a named type and const block

The foundation of Go enums starts with a named type—typically an integer—and a const block that defines your enumeration values. This approach gives you compile-time type safety that string-based systems lack.

go
package order

type Status int

const (
	StatusPending Status = iota
	StatusConfirmed
	StatusProcessing
	StatusShipped
	StatusDelivered
	StatusCancelled
)

When you declare constants without explicit assignment, iota begins at zero for the first constant and increments by one for each subsequent line. StatusPending becomes 0, StatusConfirmed becomes 1, and so on.

Why start at zero? In many systems, zero is the implicit "not set" value. If you're reading from a database or deserializing JSON, an uninitialized field defaults to zero, which should map to a sensible default state. For order statuses, zero could signal an invalid state. Alternatively, you might shift the values:

go
type Status int

const (
	StatusUnknown Status = iota // 0
	StatusPending               // 1
	StatusConfirmed             // 2
	StatusProcessing            // 3
	StatusShipped               // 4
	StatusDelivered             // 5
	StatusCancelled             // 6
)

Now an uninitialized status becomes StatusUnknown, which is explicit and easier to handle. This is a minor decision with major implications for error handling.

You can also skip values or use bit flags:

go
type Permission int

const (
	PermRead Permission = 1 << iota  // 1
	PermWrite                         // 2
	PermDelete                        // 4
	PermAdmin                         // 8
)

This pattern is useful when you need to combine multiple permissions. A user with read and write access would have value 3 (binary 11).

iota increments automatically

Understanding how iota increments across const blocks is critical to avoiding bugs. The key principle: iota resets to zero at the start of each new const block, and increments only when the constant definition contains an expression using iota.

go
const (
	A = iota // 0
	B        // 1 (iota increments, implicit reference)
	C        // 2
)

const (
	X = iota // 0 (resets for new block)
	Y        // 1
	Z        // 2
)

This reset is useful for organizing related enums separately. However, it creates a risk when managing multiple enum types in the same package. We've seen production bugs where developers didn't realize their status enum and their role enum both started at zero, causing silent mismatches.

You can also use iota with expressions for more complex scenarios:

go
type HTTPMethod int

const (
	GET HTTPMethod = iota + 1 // 1
	POST                        // 2
	PUT                         // 3
	DELETE                      // 4
	PATCH                       // 5
)

Or combine multiple constants within a single const block:

go
type Size int

const (
	Small Size = iota      // 0
	Medium                 // 1
	Large                  // 2
	_ = iota - 1           // Skip to allow future growth
	XLarge = iota + 10 - 4 // Custom value
)

In practice, keep it simple. Complex iota arithmetic is a source of maintenance headaches when team members (or your future self) revisit the code.

Add a String() method

An enumerated type without string representation is painful to debug. Logs will show Status(3) instead of StatusShipped. A String() method transforms your enum into something human-readable.

go
func (s Status) String() string {
	switch s {
	case StatusPending:
		return "pending"
	case StatusConfirmed:
		return "confirmed"
	case StatusProcessing:
		return "processing"
	case StatusShipped:
		return "shipped"
	case StatusDelivered:
		return "delivered"
	case StatusCancelled:
		return "cancelled"
	default:
		return fmt.Sprintf("unknown(%d)", s)
	}
}

This method is called automatically when you pass the enum to fmt.Println(), log it, or include it in error messages. It's a small addition with outsized value for production debugging.

For APIs, you'll often need JSON marshaling. Add a MarshalJSON() method to serialize your enum as a string:

go
func (s Status) MarshalJSON() ([]byte, error) {
	return json.Marshal(s.String())
}

And the inverse, UnmarshalJSON(), to parse incoming JSON:

go
func (s *Status) UnmarshalJSON(data []byte) error {
	var str string
	if err := json.Unmarshal(data, &str); err != nil {
		return err
	}
	*s = statusFromString(str)
	return nil
}

The helper function maps strings back to enum values. We'll cover validation next.

Validate values from external input

When data arrives from HTTP requests, databases, or message queues, you need to validate it before trusting the value. External input is always untrusted.

go
func statusFromString(s string) Status {
	switch strings.ToLower(s) {
	case "pending":
		return StatusPending
	case "confirmed":
		return StatusConfirmed
	case "processing":
		return StatusProcessing
	case "shipped":
		return StatusShipped
	case "delivered":
		return StatusDelivered
	case "cancelled":
		return StatusCancelled
	default:
		return StatusUnknown
	}
}

This approach accepts the string and returns the best match, defaulting to StatusUnknown for unrecognized input. In a strict API, you might return an error instead:

go
func statusFromString(s string) (Status, error) {
	switch strings.ToLower(s) {
	case "pending":
		return StatusPending, nil
	case "confirmed":
		return StatusConfirmed, nil
	case "processing":
		return StatusProcessing, nil
	case "shipped":
		return StatusShipped, nil
	case "delivered":
		return StatusDelivered, nil
	case "cancelled":
		return StatusCancelled, nil
	default:
		return 0, fmt.Errorf("invalid status: %q", s)
	}
}

Now your API can reject invalid input with a clear error message. When unmarshaling JSON, invalid statuses return an error instead of silently defaulting to zero.

For database queries, you'll often need integer validation when reading from a row:

go
func (s Status) Valid() bool {
	return s >= StatusPending && s <= StatusCancelled
}

Use this in database scanning or after any conversion from an untrusted source:

go
var rawStatus int
if err := row.Scan(&rawStatus); err != nil {
	return err
}
status := Status(rawStatus)
if !status.Valid() {
	return fmt.Errorf("invalid status in database: %d", rawStatus)
}

Common mistakes

Mistake 1: Forgetting to handle unknown values. If you're receiving enums from external systems and a new status is added on the producer's side, your consumer will receive an integer that doesn't map to any constant. Always include a StatusUnknown or handle the default case explicitly.

Mistake 2: Relying on implicit iota resets. When two const blocks define enums that should never overlap, explicitly assign starting values instead of trusting iota's reset behavior. This makes intent clear and prevents future bugs when someone moves constants around.

Mistake 3: Hardcoding status strings in business logic. Don't scatter "shipped" throughout your codebase. Always use the enum constant and its String() method. This makes refactoring and auditing trivial.

Mistake 4: Using string enums in performance-critical paths. If you're comparing millions of statuses per second (like in a trading system), comparing integers is faster than comparing strings. Validate once at the boundary, then work with typed constants internally.

Key takeaways

  • Named types with iota provide compile-time type safety that strings or bare integers cannot match.
  • iota resets at each const block and increments implicitly, so keep your usage straightforward and document intentional skips.
  • Always implement String(), MarshalJSON(), and UnmarshalJSON() for APIs; they're not optional for production code.
  • Validate external input explicitly using a conversion function that returns an error or a safe default.
  • Use a Valid() method after reading from databases or deserializing untrusted data to catch invalid values early.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles