Constants in Go serve two distinct purposes: preventing magic numbers scattered through code, and creating enumeration types that the compiler can enforce. The iota keyword makes the second use case particularly elegant — you get auto-incrementing integer values, bit flag patterns, and byte-size constants without maintaining the numbers manually.
Understanding the difference between typed and untyped constants is also important — it affects what assignments are legal and how the type system protects you.
Declaring Constants
Constants are declared with const, either individually or in a grouped block. Unlike variables, they are evaluated at compile time and can appear in any expression:
const Pi = 3.14159
const MaxRetries = 5
const ServiceName = "order-service"
// Grouped declaration — common for related constants
const (
ReadTimeout = 15 * time.Second
WriteTimeout = 15 * time.Second
IdleTimeout = 60 * time.Second
)
// Computed from other constants — resolved at compile time
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
Constants can be any basic type: booleans, integers, floats, complex numbers, or strings. They cannot be slices, maps, or structs.
Typed vs Untyped Constants
This distinction is one of Go’s subtleties. An untyped constant has a “kind” but no fixed type — it’s compatible with any type of the same kind within range:
const untypedX = 42 // untyped integer constant
var a int = untypedX // OK
var b int64 = untypedX // OK — untyped constant converts freely
var c float64 = untypedX // OK
const typedY int = 42
var d int = typedY // OK
var e int64 = typedY // ❌ compile error: cannot use typedY (int) as int64
Untyped constants are more flexible — they let the compiler assign the most appropriate type at each use site. Typed constants are stricter and participate in type checking exactly like variables.
For enumerations, you almost always want typed constants. Typing prevents mixing incompatible enum values:
type Direction int
type Color int
const (
North Direction = iota
South; East; West
)
const (
Red Color = iota
Green; Blue
)
var d Direction = North
d = Red // ❌ compile error: cannot use Red (Color) as Direction
Without the custom types, North and Red would both be plain int and the assignment would silently compile.
iota: Automatic Enumeration
iota is a counter that starts at 0 and increments by 1 for each constant in a const block. It resets to 0 at the start of each new const block:
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
The value iota appears in only needs to be stated once — subsequent lines in the block repeat the same expression with the incremented iota. This is why Monday through Saturday have no explicit expression.
Skipping Values with _
Use the blank identifier to skip a value in the sequence:
type LogLevel int
const (
_ LogLevel = iota // skip 0 — "unset" values won't accidentally match
Debug // 1
Info // 2
Warning // 3
Error // 4
Fatal // 5
)
Skipping 0 is a useful pattern: if someone declares a LogLevel variable without initializing it, the zero value won’t silently mean Debug — it won’t match any named level.
iota in Expressions: Bit Flags
iota becomes powerful in expressions. The bit-shift pattern is idiomatic for permission flags:
type Permission uint
const (
Read Permission = 1 << iota // 1 << 0 = 1 (binary: 001)
Write // 1 << 1 = 2 (binary: 010)
Execute // 1 << 2 = 4 (binary: 100)
Admin // 1 << 3 = 8 (binary: 1000)
)
// Combine permissions with bitwise OR
userPerms := Read | Write // 3 (binary: 011)
// Check a permission with bitwise AND
if userPerms&Read != 0 {
fmt.Println("can read")
}
if userPerms&Execute == 0 {
fmt.Println("cannot execute")
}
// Grant a permission
userPerms |= Execute // add Execute
// Revoke a permission
userPerms &^= Write // remove Write (&^ is bit-clear / AND NOT)
This pattern packs multiple boolean flags into a single integer, which is efficient for storage and comparison. The os package uses exactly this for file mode bits (os.FileMode).
Byte-Size Constants
Another common iota pattern:
const (
_ = iota // skip 0
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1,048,576
GB // 1 << 30 = 1,073,741,824
TB // 1 << 40 = 1,099,511,627,776
)
Adding String() to Enumerations
By default, printing a typed integer enum shows the raw number. Implement fmt.Stringer to get readable output — and this also improves log messages, error strings, and debugging:
type Status int
const (
StatusPending Status = iota
StatusActive
StatusCompleted
StatusCancelled
)
func (s Status) String() string {
switch s {
case StatusPending: return "pending"
case StatusActive: return "active"
case StatusCompleted: return "completed"
case StatusCancelled: return "cancelled"
default: return fmt.Sprintf("Status(%d)", int(s))
}
fmt.Println(StatusActive) // active — fmt calls String() automatically
The default case that returns Status(N) is important: if someone adds a new constant to the enum but forgets to update String(), you get a clear indication like Status(5) rather than a silent zero value or panic.
For large enums, go generate with stringer tool generates this automatically:
go install golang.org/x/tools/cmd/stringer@latest
//go:generate stringer -type=Status
type Status int
Running go generate creates a status_string.go file with the full String() method.
JSON Marshaling for Enums
By default, JSON encoding writes the integer value. Implement MarshalJSON/UnmarshalJSON to serialize as strings:
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *Status) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
switch str {
case "pending": *s = StatusPending
case "active": *s = StatusActive
case "completed": *s = StatusCompleted
case "cancelled": *s = StatusCancelled
default:
return fmt.Errorf("unknown status %q", str)
}
return nil
}
This way, your API sends "status": "active" instead of "status": 1 — far more readable and stable across code changes.
Sentinel Error Values
Constants also appear as sentinel error values — named errors at package scope that callers check with errors.Is:
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrTimeout = errors.New("operation timed out")
)
func findUser(id string) (*User, error) {
if id == "" {
return nil, ErrNotFound
}
// ...
}
// Caller can check without parsing strings
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
}
Note these are var declarations, not const — errors.New returns a pointer, and pointer constants aren’t allowed in Go. The package-level var achieves the same sentinel purpose.
What Not to Do
Don’t use int directly for enum parameters. Without a named type, the compiler can’t prevent passing the wrong enum to a function:
// ❌ Any int can be passed — no type safety
func setDirection(d int) { ... }
setDirection(42) // compiles, likely wrong
// ✅ Only Direction values can be passed
func setDirection(d Direction) { ... }
setDirection(42) // compile error
setDirection(North) // correct
Don’t hardcode magic numbers. Repeated integers with no name are maintenance hazards:
// ❌ What does 3 mean?
if user.Status == 3 { ... }
// ✅ Self-documenting
if user.Status == StatusCompleted { ... }
Summary
- Untyped constants convert freely to compatible types; typed constants enforce exact types — use typed enums for safety
iotastarts at 0 perconstblock, increments by 1 per line; skip 0 with_to avoid zero-value confusion- Bit flags with
1 << iotapack multiple boolean flags into a single integer efficiently - Implement
String() stringon every enum type — it costs little and pays off in every log message and error string - Use
//go:generate stringerfor large enums rather than hand-writing the switch - Sentinel errors are
var, notconst—errors.Newreturns a pointer
Resources
- Effective Go: Constants
- Go specification: Constant expressions
- golang.org/x/tools/cmd/stringer
- Go by Example: Constants
Comments