The magic number disaster
You're debugging a production outage. The retry logic fires too many times and hammers the downstream database. You trace the code and find maxRetries := 5 buried deep inside a function. You fix it, but three weeks later, a new developer copies that function for a different service and hardcodes 10 because they didn't know the constant existed. The outage returns.
Or you try to make the retry count configurable. You read an environment variable at startup and assign it to a const. The compiler rejects the program. You can't assign a runtime value to a constant. You need a variable.
Go separates these concerns with const. Constants are values fixed at compile time. They don't exist as variables in memory. The compiler inlines them directly into the binary. When your code runs, the constant is already there, replaced by its literal value.
Think of a constant like a value stamped into a metal part during manufacturing. You can't change the stamp without melting the part down and recasting it. In Go, the compiler is the factory. Once the binary is built, the constants are immutable facts.
Constants are cheap. The compiler inlines them. There is no memory address for a constant.
Minimal declaration
Here's the simplest constant: a single value with a name.
package main
import "fmt"
// MaxRetries is the maximum number of attempts before giving up.
const MaxRetries = 3
func main() {
// MaxRetries is untyped. It adapts to the type of the variable.
var attempts int = MaxRetries
fmt.Println(attempts)
}
The const keyword declares the value. MaxRetries is the name. 3 is the value. Go infers the type, but with a twist: MaxRetries is an untyped integer constant. It has no type until you use it. When you assign it to attempts, Go sees the variable is int and converts the constant to int.
If you assign the same constant to a uint64, Go converts it to uint64. The constant itself never changes. It's a raw value that adapts to context.
Constants must be of a basic type: integer, floating-point, boolean, or string. You can't have a constant slice, map, or struct. Those types require memory allocation at runtime. Constants live only in the compiler's world.
Constants are values, not variables. The compiler inlines them.
The power of untyped constants
Untyped constants are Go's secret weapon for precision. In many languages, 3.14159 is immediately a double or float64. In Go, it's an untyped float constant with infinite precision until you force a type.
This prevents silent precision loss.
package main
import "fmt"
// Pi is an untyped float constant.
const Pi = 3.14159265358979323846
// Tiny is an untyped float constant smaller than float64 can hold.
const Tiny = 1e-100
func main() {
// Pi adapts to float64 here. Precision is preserved.
var radius float64 = 5.0
area := Pi * radius * radius
fmt.Println(area)
// Tiny cannot fit in float64. The compiler catches this early.
// var small float64 = Tiny
// Uncommenting the line above causes a compile error.
}
Tiny is 1e-100. A float64 can only represent values down to about 1e-308, but the exponent range and mantissa precision mean many tiny values lose information or underflow. As an untyped constant, Tiny exists with full precision. When you try to assign it to a float64, the compiler checks if the value fits. If it doesn't, compilation fails.
The compiler rejects the assignment with constant 1e-100 overflows float64. You get the error at build time, not when a calculation goes wrong in production.
You can also use untyped constants for overflow protection on integers. const Big = 1 << 100 is valid. It's an untyped integer constant. Assigning it to an int on a 32-bit system fails with constant 1152921504606846976 overflows int. The constant exists safely until you try to squeeze it into a type that can't hold it.
Untyped constants give you infinite precision until you force a type.
Grouping and iota
When you have related constants, group them with a const block. This keeps the code tidy and enables iota, a built-in identifier that counts lines.
package main
import "fmt"
const (
// LogLevelDebug is the lowest log level.
LogLevelDebug = iota
LogLevelInfo
LogLevelWarn
LogLevelError
)
func main() {
fmt.Println(LogLevelDebug) // 0
fmt.Println(LogLevelError) // 3
}
iota resets to 0 at each const keyword. Inside the block, it increments by 1 for each line. LogLevelDebug gets 0. LogLevelInfo gets 1. LogLevelWarn gets 2. LogLevelError gets 3.
This is the idiomatic way to create enumerations in Go. You don't need a special enum syntax. Integers with names are enough.
You can skip values by using the blank identifier _.
const (
StatusPending = iota
StatusActive
_ // Skip 2. Intentionally leave a gap.
StatusFailed
)
StatusPending is 0, StatusActive is 1, StatusFailed is 3. The blank identifier tells the reader you intentionally left a gap. Maybe 2 was deprecated, or you want to reserve it for a future state.
Use _ to skip an iota value. It tells the reader you intentionally left a gap.
iota also works with expressions. This is common for bit flags.
const (
PermissionRead = 1 << iota // 1 (binary 001)
PermissionWrite // 2 (binary 010)
PermissionExecute // 4 (binary 100)
)
1 << iota shifts 1 left by iota bits. PermissionRead is 1 << 0 which is 1. PermissionWrite is 1 << 1 which is 2. PermissionExecute is 1 << 2 which is 4. You can combine these with bitwise OR: PermissionRead | PermissionWrite equals 3.
iota counts lines, not values. Reset the counter with a blank line or a new block.
Realistic example: Configuration defaults
Constants define the shape of your code. Configuration defines the behavior of your deployment. Don't put database URLs in const. Use constants for defaults, limits, and enums that are part of the code structure.
Here's a package that defines log levels and uses them in a function.
package logger
import "fmt"
// Log levels use iota for sequential values.
const (
LevelDebug = iota
LevelInfo
LevelWarn
LevelError
)
// DefaultLevel is the log level used when none is specified.
const DefaultLevel = LevelInfo
// Log prints a message if the level is high enough.
// Log checks the level against the threshold.
func Log(level int, msg string) {
if level >= DefaultLevel {
fmt.Println(msg)
}
}
DefaultLevel is a constant derived from another constant. This is allowed. The compiler evaluates LevelInfo at compile time and inlines the value 1 into DefaultLevel.
The Log function takes an int for the level. This is a common pattern: use iota values as integers. The type system won't stop you from passing LevelDebug + 5, but the named constants keep the code readable.
You could create a custom type like type Level int to enforce stricter checking, but that requires conversion at every call site. Many Go projects stick with plain integers for enums to keep the code simple.
Constants define structure. Configuration defines behavior. Keep them separate.
Pitfalls and compiler errors
Constants must be evaluated at compile time. If the compiler can't compute the value, the program won't build.
You can't use function calls in constants, except for a few built-in functions that the compiler supports.
// This fails. len is a runtime function.
// const MaxLen = len("hello")
// Error: const initializer len("hello") is not a constant
// This fails. make allocates memory at runtime.
// const EmptySlice = make([]int, 0)
// Error: const initializer make([]int, 0) is not a constant
The compiler rejects these with const initializer ... is not a constant. len and make run when the program executes. Constants exist before execution.
You can use unsafe.Sizeof, cap on arrays, and len on string literals or arrays in constants. These are compile-time operations.
import "unsafe"
const (
// SizeofInt is the size of an int in bytes.
SizeofInt = unsafe.Sizeof(0)
// ArrayLen is the length of a fixed-size array.
ArrayLen = len([5]int{})
)
You also can't take the address of a constant.
// This fails. Constants have no memory address.
// p := &MaxRetries
// Error: cannot take the address of MaxRetries
The compiler complains with cannot take the address of MaxRetries. Since the compiler inlines constants, there is no variable in memory to point to.
Constants inside functions are valid. They are scoped to the function.
func process() {
// LocalConst is only visible inside process.
const LocalConst = 42
_ = LocalConst
}
This is useful for limits or magic numbers that only apply to one function. It keeps the package namespace clean.
If it runs at runtime, it can't be a const.
Decision matrix
Use const when you have a value fixed at compile time that doesn't change between builds. Use var when the value depends on runtime configuration, environment variables, or user input. Use untyped constants for mathematical precision and overflow protection. Use iota for enumerations and bit flags where sequential or shifted values make sense. Use a const block to group related constants and keep the code tidy. Use function-scoped constants for limits that only apply to a single function.
Constants are cheap. The compiler inlines them. There is no memory address for a constant.