Agentic AI Engineering with Python: Live Course
GoVariables and Data Types

Constants and iota

Go has no enum keyword. It also has no final, no readonly, and no frozen objects. What it has is const and a small counter called iota, and between them they cover the ground that those features occupy in other languages. The combination is unusual enough that it is worth a page of its own.

Declaring constants

const Pi = 3.14159
const AppName = "orders-api"
const MaxRetries = 3

Grouped, which is how you will usually see them:

const (
    StatusActive   = "active"
    StatusInactive = "inactive"
    StatusPending  = "pending"
)

A constant is fixed at compile time. That is a stronger statement than "cannot be reassigned", and it leads directly to the main restriction.

What can and cannot be a constant

The value has to be computable by the compiler, which rules out anything that only exists while the program runs.

const good = 10 * 60          // arithmetic on literals, fine
const alsoGood = "a" + "b"    // string concatenation of constants, fine
const stillGood = len("hello") // len of a string constant, fine

const bad = time.Now()        // compile error, a function call at runtime
const alsoBad = []int{1, 2}   // compile error, slices are runtime values
const nope = os.Getenv("KEY") // compile error, same reason

Constants can only hold booleans, numbers, strings, and runes. There are no constant slices, maps, or structs. When you want a fixed collection, use a package level var and rely on convention:

var defaultPorts = []int{80, 443, 8080}    // not truly constant, but conventional

The absence of constant composite values is a real gap, and Go programmers work around it with unexported package level variables plus an accessor function when immutability actually matters. Most of the time, a comment and a capital letter convention is considered enough.

Typed and untyped constants

This is the part that makes Go constants more useful than they first appear.

const timeout = 30              // untyped
const timeoutTyped int = 30     // typed

An untyped constant has no fixed type until you use it. At the point of use it adopts whatever the context needs, and it is evaluated at arbitrary precision along the way.

const factor = 3

var a int     = factor      // becomes int
var b float64 = factor      // becomes float64
var c int64   = factor      // becomes int64

Do the same thing with a typed constant and two of those lines stop compiling:

const factor int = 3

var a int     = factor      // fine
var b float64 = factor      // compile error, int is not float64

The practical guidance is simple: leave your constants untyped unless you have a specific reason to pin the type. Untyped constants slot into more places without conversions cluttering the call sites.

Arbitrary precision is not a detail

const huge = 1 << 62          // fine, no type yet
const bigger = huge * 4       // still fine, the compiler uses big numbers

fmt.Println(bigger / 1000)    // works, the result fits in an int
var x int = bigger            // compile error, this one does not fit

Intermediate constant expressions are not limited by machine word size. Only the final assignment to a variable has to fit. This lets you write constant arithmetic naturally without worrying about overflow at each step.

iota, the constant counter

iota is a compiler-managed counter that resets to zero at the start of every const block and increments by one for each line in it.

const (
    Sunday = iota      // 0
    Monday             // 1
    Tuesday            // 2
    Wednesday          // 3
    Thursday           // 4
    Friday             // 5
    Saturday           // 6
)

Notice that only the first line mentions iota. Inside a const block, a line with no expression repeats the previous one, so every subsequent line is implicitly = iota and picks up the incremented value.

That is the entire mechanism. Everything below is a variation on it.

Skipping values

const (
    _  = iota          // discard 0
    KB = 1 << (10 * iota)   // 1 << 10 = 1024
    MB                      // 1 << 20
    GB                      // 1 << 30
    TB                      // 1 << 40
)

fmt.Println(MB)        // 1048576

The blank identifier absorbs the zero value so the first real constant starts at one. This particular block appears in a great many Go codebases.

Starting from something other than zero

const (
    StatusOK = iota + 200    // 200
    StatusCreated            // 201
    StatusAccepted           // 202
)

Several constants per line

iota increments per line, not per constant:

const (
    a, b = iota, iota * 10   // a=0, b=0
    c, d                     // c=1, d=10
    e, f                     // e=2, f=20
)

Bit flags

Shifting iota gives you a set of values that can be combined:

type Permission uint8

const (
    Read Permission = 1 << iota    // 00000001
    Write                          // 00000010
    Execute                        // 00000100
    Delete                         // 00001000
)

perms := Read | Write

fmt.Println(perms&Read != 0)     // true
fmt.Println(perms&Delete != 0)   // false

Four permissions in a single byte, with set membership tested by a bitwise and. This is how file modes, feature flags, and protocol options are usually represented.

Building a real enum

Constants plus a named type plus a String method is Go's answer to enumerations, and it produces something quite pleasant to use.

type OrderStatus int

const (
    StatusPending OrderStatus = iota
    StatusPaid
    StatusShipped
    StatusDelivered
    StatusCancelled
)

func (s OrderStatus) String() string {
    switch s {
    case StatusPending:
        return "pending"
    case StatusPaid:
        return "paid"
    case StatusShipped:
        return "shipped"
    case StatusDelivered:
        return "delivered"
    case StatusCancelled:
        return "cancelled"
    default:
        return fmt.Sprintf("OrderStatus(%d)", int(s))
    }
}

Because OrderStatus is its own type, the compiler stops you passing a plain int where a status is expected. Because it has a String method, fmt uses it automatically:

s := StatusShipped
fmt.Println(s)                  // shipped
fmt.Printf("status is %v\n", s) // status is shipped

Add a validity check and the type starts defending itself:

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

Writing String methods by hand gets tedious and they drift out of sync when someone adds a constant. The stringer tool generates them for you: install it with go install golang.org/x/tools/cmd/stringer@latest, add //go:generate stringer -type=OrderStatus above the const block, and run go generate ./....

When to leave iota alone

iota gives you values that depend on their position in the block, and that is exactly wrong in two situations.

When the numbers are stored somewhere. If these constants get written into a database or sent over the wire, inserting a new one in the middle silently changes the meaning of every value after it. Write the numbers explicitly instead:

const (
    StatusPending   OrderStatus = 1
    StatusPaid      OrderStatus = 2
    StatusShipped   OrderStatus = 3
)

When the values are strings. String constants carry their meaning in logs, JSON, and error messages, which is often worth more than the compactness of an integer:

type Environment string

const (
    Development Environment = "development"
    Staging     Environment = "staging"
    Production  Environment = "production"
)

The named type still gives you compile time checking, and now a misconfigured value is obvious the moment you print it.

Constants take care of the values that never change. Next, let's deal with the values that need to change form, which in Go means being explicit about every single conversion.

How is this guide?

Last updated on