Learn Go Series (#2) - Variables, Types, Constants, and iota
Learn Go Series (#2) - Variables, Types, Constants, and iota
What will I learn
- The two ways to declare variables (
varand:=), when each reads best, and what Go's zero value guarantee buys you; - Multiple assignment, the blank identifier
_, and why Go treats an unused variable as a compile error; - Go's basic types -- integers of every size, floats,
bool,string, and thebyte/runealiases -- and why there is no implicit conversion between them; - How integers wrap around, how to convert between numeric types explicitly, and why the compiler makes you say it out loud;
- The difference between a
varand aconst, and why constants are their own little world with untyped, arbitrary-precision values; - The
iotacounter and how it turns aconstblock into a clean, correct enumeration -- including skipping values and building bit flags; - Grouping declarations, and the small idioms that keep all of this readable.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- the code here is written against Go 1.27;
- Episode 1, or at least a Go module you can
go run .in; - Comfort running a program and reading its output;
- The ambition to learn Go programming.
Difficulty
- Beginner
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota (this post)
Learn Go Series (#2) - Variables, Types, Constants, and iota
Last episode we got Go installed and ran a handful of programs to feel the shape of the language -- a slice, a map, a struct, a goroutine, and the famous if err != nil rhythm. Today we slow right down and get precise about the raw material every program is made of: values, the types that describe them, and the names we bind them to. Go's type system is small and strict, and that strictness is the whole point -- it refuses to guess what you meant when two types meet, and in exchange it hands you a compiler that catches a whole family of bugs the moment you build, in stead of three weeks later in production. Let us start, as every episode from here will, by clearing the previous episode's exercises.
Solutions to Episode 1 Exercises
Exercise 1 -- name and date. The task was to print your name and today's date using the time package. The trick with Go's time.Format is its unusual reference layout: instead of %Y-%m-%d codes, you spell out the specific reference date Mon Jan 2 15:04:05 MST 2006 in the shape you want. Here it is asking for a full weekday-and-month date:
package main
import (
"fmt"
"time"
)
func main() {
name := "Ada"
now := time.Now()
fmt.Printf("Hello, %s! Today is %s.\n", name, now.Format("Monday, 2 January 2006"))
}
The layout string looks bizarre the first time, but there is a logic to it: 2006 is the year, 01 the month, 02 the day, 15:04:05 the clock (that is 3:04:05 PM in 24-hour form). You are showing Go an example of the format, not a code for it. Once it clicks you never forget it.
Exercise 2 -- a rectangle's area. A function with a shared parameter type and a two-decimal print:
package main
import "fmt"
func area(width, height float64) float64 {
return width * height
}
func main() {
fmt.Printf("area = %.2f\n", area(3.5, 2.0))
}
When consecutive parameters share a type you write it once: (width, height float64). Small, but it adds up in real code, and it reads beautifully once you are used to it.
Exercise 3 -- two goroutines, one channel. Both goroutines send on the same channel, and main receives twice:
package main
import "fmt"
func main() {
messages := make(chan string)
go func() { messages <- "hello from goroutine 1" }()
go func() { messages <- "hello from goroutine 2" }()
fmt.Println(<-messages)
fmt.Println(<-messages)
}
Receiving twice is exactly enough to collect both messages -- but the order is not guaranteed, because the two goroutines race to the channel. Receiving only once would print one message and let main return, abandoning the other goroutine mid-send. That non-determinism is real concurrency, and we learn to control it properly much later in the series. On to today.
Two ways to declare a variable
Go gives you a full var declaration and a short := form. Inside a function, := is the everyday choice because it infers the type from the value -- you saw it all over episode 1. var earns its keep when you want to state a type explicitly, or declare something without a value yet. And here is the first thing that will feel different if you come from Python: an uninitialised variable is not undefined, not None, not garbage. It is its type's zero value:
package main
import "fmt"
func main() {
var count int // zero value: 0
var ratio float64 // zero value: 0
var name string // zero value: "" (empty, not nil)
var ready bool // zero value: false
greeting := "hi" // short form, inferred as string
count = 42 // assign later with =
fmt.Printf("%d %g %q %t %q\n", count, ratio, name, ready, greeting)
}
Every type has a well-defined zero value: 0 for numbers, false for booleans, "" for strings, and nil for the pointer-like types we will meet in a later episode. This is a quiet but huge safety feature -- there is simply no such thing as a read-of-uninitialised-memory bug in safe Go. A freshly declared int is genuinely 0, always, on every platform, and you can build on that guarantee rather than defending against it.
One rule that surprises newcomers: Go will not let you declare a variable and then never use it. An unused local variable is a compile error, not a warning you can ignore. The same goes for an unused import (we hit that in episode 1). It feels strict for about a day, and then you realise your codebases never accumulate the dead, half-removed clutter that other languages quietly tolerate.
Multiple assignment and the blank identifier
Go lets you declare or assign several variables in one statement, which is how you will unpack those multi-value returns we saw with strconv.Atoi last time. And when a value comes back that you genuinely do not want, you assign it to the blank identifier _, which discards it without creating an unused-variable error:
package main
import "fmt"
func minMax(nums []int) (int, int) {
lo, hi := nums[0], nums[0]
for _, n := range nums { // we want the value, not the index -> discard index with _
if n < lo {
lo = n
}
if n > hi {
hi = n
}
}
return lo, hi
}
func main() {
a, b := 3, 7
a, b = b, a // swap in one line, no temp variable needed
fmt.Println("after swap:", a, b)
low, high := minMax([]int{5, 2, 9, 1, 7})
fmt.Printf("low=%d high=%d\n", low, high)
}
Two things worth pocketing here. First, a, b = b, a swaps without a temporary -- the right-hand side is fully evaluated before anything is assigned. Second, for _, n := range nums uses _ to throw away the loop index because we only care about the value; writing i there and not using it would refuse to compile. The blank identifier shows up constantly in idiomatic Go, and it is not a hack -- it is the language's explicit way of saying "yes, I know there is a value here, and I am deliberately ignoring it".
The basic types, and their sizes
Go's numeric types put their size right in the name, which removes a lot of guesswork. Integers come signed (int8, int16, int32, int64) and unsigned (uint8 through uint64), plus a plain int that is the natural machine word (64-bit on modern hardware). Floats are float32 and float64, and you almost always want float64. There are two aliases worth knowing now: byte is another name for uint8, and rune is another name for int32 and represents a single Unicode code point:
package main
import (
"fmt"
"math"
)
func main() {
var i int = -7
var u uint8 = 255 // the largest a uint8 can hold
var f float64 = math.Pi
var b byte = 'A' // a byte literal; 'A' is 65
var r rune = '\u00e9' // a rune: one Unicode code point (e-acute, 233)
fmt.Printf("int=%d uint8=%d float64=%.5f byte=%d rune=%c (%d)\n", i, u, f, b, r, r)
fmt.Printf("a uint8 holds 0..%d; an int64 holds up to %d\n", math.MaxUint8, math.MaxInt64)
}
Choosing a size matters when you care about memory footprint or about overflow, but for everyday counting and indexing, plain int is the right default -- reach for the sized types only when you have a reason. byte and rune come into their own when we do strings and Unicode properly in a later episode; for now just know that a rune is a code point and a byte is a raw 8-bit value, and that writing '\u00e9' keeps your source file pure ASCII while still naming a non-ASCII character.
When you want to ask a value what type it is, Printf has two verbs that are worth their weight in gold while learning: %T prints the type, and %v prints the value in a sensible default format:
package main
import "fmt"
func main() {
things := []any{42, 3.14, "hello", true, 'A', []int{1, 2, 3}}
for _, x := range things {
fmt.Printf("%-8v has type %T\n", x, x)
}
}
That []any is a slice that can hold a value of any type (we will unpack what any really is when we reach interfaces). For now it is a handy little playground: run it, and you will see int, float64, string, bool, int32 (that is what 'A' is -- a rune), and []int reported back. When you are unsure what inference gave you, %T settles the argument in one line.
Integers wrap, they do not grow
Here is a trap that is worth meeting deliberately rather than in anger. A fixed-size integer has a fixed range, and when you push past the top it wraps around to the bottom -- Go does not promote it to a bigger type and does not panic. This is the same behaviour C has, and it is a deliberate, predictable choice:
package main
import (
"fmt"
"math"
)
func main() {
var u uint8 = 255
u++ // wraps around: 255 + 1 -> 0
fmt.Println("uint8 overflow:", u)
var i int8 = math.MaxInt8 // 127
i++ // wraps to -128
fmt.Println("int8 overflow:", i)
}
Wraparound at run time is silent, so when a value could realistically exceed its type's range you pick a wider type on purpose. But notice the flip side: Go catches overflow at compile time when it can see it. A line like var b uint8 = 256 does not compile at all -- the compiler knows 256 does not fit in a uint8 and tells you so before the program ever runs. Constants get this treatment especially thoroughly, as we will see in a moment.
No implicit conversion (and why that is good)
Here is a rule that trips up newcomers and then becomes something they actively miss in other languages: Go does not implicitly convert between numeric types. You cannot add an int to a float64 without saying so. It feels pedantic for about a day, and then you realise it has quietly removed an entire famly of precision-loss and sign-mismatch bugs:
package main
import "fmt"
func main() {
count := 10 // int
price := 2.5 // float64
// total := count * price // ERROR: mismatched types int and float64
total := float64(count) * price
fmt.Printf("%d items at %.2f = %.2f\n", count, price, total)
// converting back the other way truncates toward zero, it does not round
whole := int(total)
fmt.Println("whole part:", whole)
}
float64(count) is a conversion: you are explicitly asking for the int value as a float64. The commented line shows exactly what the compiler rejects, and the error message even tells you the two types that disagree. Note the truncation on the way back too: int(24.99) is 24, not 25 -- Go never silently rounds, so if you want rounding you call math.Round and mean it. Having said that, once you internalise "conversions are always visible", reading unfamiliar Go gets easier, because you can trust that no type is quietly changing shape behind your back.
Constants and untyped values
A const is a value fixed at compile time. It cannot be assigned to, and it cannot be computed from anything that is only known at run time (so no const now = time.Now() -- that is a compile error). Constants have a neat superpower: numeric constants can be untyped, meaning they take on whatever type the context needs. That is why the literal 3 can serve as an int, a float64, or a byte without a conversion -- it has no fixed type until it is used:
package main
import "fmt"
const (
greeting = "welcome"
maxRetries = 3
pi = 3.14159
secondsInDay = 24 * 60 * 60 // computed at compile time
)
func main() {
var attempts int = maxRetries // untyped 3 becomes an int here
var timeout float64 = maxRetries // and the same 3 becomes a float64 here
fmt.Printf("%s: %d attempts, %.1fs timeout, %d seconds/day, pi=%.2f\n",
greeting, attempts, timeout, secondsInDay, pi)
}
Because maxRetries is an untyped constant, it slots into an int variable on one line and a float64 on the next with no conversion in sight -- something a typed variable could never do (you would need float64(attempts)). Untyped constants are one of Go's quietly elegant corners.
There is a second surprise lurking here: untyped numeric constants have arbitrary precision at compile time. Go can hold and compute with numbers far larger than any machine integer or float while they are still constants, and only checks that the result fits when you finally assign it to a typed variable:
package main
import "fmt"
const big = 1 << 62 // fits in an int64, fine as a constant
func main() {
const huge = 1 << 100 // perfectly legal as an untyped constant
const small = huge >> 98 // shift it back down: 4
fmt.Println("big:", big)
fmt.Println("small:", small)
// var x int = huge // ERROR: 1 << 100 overflows int -- only fails when you try to store it
}
1 << 100 would overflow any integer type on your machine, yet it is a completely valid constant -- the arithmetic happens in the compiler's big-number world. The moment you try to pour it into an int, Go checks the fit and refuses. This is why constant expressions like secondsInDay above are both exact and free at run time: they are folded away entirely before your program starts.
iota: enumerations done right
When you want a set of related named values -- states, kinds, days of the week -- Go gives you iota, a counter that resets to 0 at the top of each const block and increments by one for each line. It turns a fiddly, error-prone list of hand-numbered constants into something the compiler keeps correct for you:
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
func main() {
today := Wednesday
fmt.Printf("Wednesday is day number %d\n", today)
fmt.Println("weekend starts at day", int(Saturday))
}
Each line after the first reuses the = iota expression, so Monday is 1, Tuesday is 2, and so on, with no numbers to keep in sync by hand. Add a day in the middle and everything below renumbers itself automatically -- try doing that reliably with hand-written integers. Defining type Weekday int also gives these values a real name in the type system, so a function that takes a Weekday cannot be handed just any old int by accident. (Making a Weekday print as "Wednesday" instead of 3 needs one more trick, which belongs to the episode on methods -- we will get there.)
iota can do more than count by one. Combined with a shift, it builds power-of-two flags -- the classic pattern for bit sets:
package main
import "fmt"
type Permission uint8
const (
Read Permission = 1 << iota // 1 (binary 001)
Write // 2 (binary 010)
Execute // 4 (binary 100)
)
func main() {
access := Read | Write // combine flags with bitwise OR
fmt.Printf("access bits: %03b\n", access)
fmt.Println("can write:", access&Write != 0)
fmt.Println("can execute:", access&Execute != 0)
}
1 << iota gives 1, 2, 4 -- each a distinct bit -- so you can OR them together into one value and test membership with AND. This is the same idea Unix file permissions use, and Go's iota expresses it in three tidy lines with nothing to keep straight by hand.
Skipping values and starting from one
Two more iota moves you will reach for. You can skip a value with the blank identifier (handy when you want the count to start at 1), and you can build any expression you like out of iota:
package main
import "fmt"
type Priority int
const (
_ Priority = iota // skip 0, so the zero value is "no priority set"
Low // 1
Medium // 2
High // 3
)
type Direction int
const (
North Direction = iota * 90 // 0
East // 90
South // 180
West // 270
)
func main() {
fmt.Println("priorities:", Low, Medium, High)
fmt.Println("bearings:", North, East, South, West)
}
Skipping 0 for Priority is a genuinely useful idiom: it means the zero value of a Priority variable (remember, every variable starts at its zero value) reads as "unset", so an accidentally-undeclared priority is distinguishable from a real Low. And iota * 90 shows that the expression can be anything -- Go evaluates it fresh on each line with the current iota, giving you compass bearings for free. Nota bene: the expression on the first line is the one that gets repeated, so whatever you write there is the template for the whole block.
Grouping declarations, and a little style
You have already seen grouped const blocks; the same parenthesised form works for var and import too, and it is the idiomatic way to keep related declarations together:
package main
import (
"fmt"
"os"
)
var (
appName = "widget"
version = "1.4.0"
maxConns = 100
)
func main() {
fmt.Fprintf(os.Stdout, "%s v%s (max %d connections)\n", appName, version, maxConns)
}
Package-level variables like these live outside any function and are visible across the whole package; notice they use = inside a var block, not := (the short form only works inside functions). As a rule of thumb: prefer := for locals, reach for var when you need an explicit type or a zero-valued start, and group related declarations so a reader sees them as one unit. None of this is enforced by the compiler -- but gofmt will line the columns up for you, and every Go codebase ends up looking the same, which is exactly the relief we talked about last episode. ;-)
The same idea in Python
Python is dynamically typed, so it happily mixes an int and a float and infers everything -- which is convenient right up until a type mismatch surfaces as a run-time surprise. Python's nearest thing to iota is enum.IntEnum:
from enum import IntEnum
class Weekday(IntEnum):
SUNDAY = 0
MONDAY = 1
TUESDAY = 2
# ... and so on, numbered by hand
count = 10
price = 2.5
total = count * price # Python converts int -> float silently
print(f"total = {total:.2f}, Wednesday = {Weekday(3).name}")
Python numbers its enum by hand and mixes numeric types for you; Go makes iota do the numbering and makes you convert types on purpose. The Go way is a little more typing up front and a lot fewer surprises later -- the trade you will see again and again through this series, and one you slowly come to appreciate.
Exercises
Zero values by hand. Declare one variable of each:
int,float64,string, andbool, and print all four with a singlePrintfbefore assigning anything to them. Predict the output first, then run it and check you were right about every zero value. (Hint:%qaround the string will make the empty one visible.)A units enum. Using
iota, define atype ByteSize float64with constantsKB,MB,GBas1 << (10 * (iota + 1))soKBis 1024,MBis 1048576, andGBis 1073741824. Print each. (Hint: the first line's expression is reused on each subsequent line, andiotais0on that first line -- soiota + 1is1there.)Conversion round-trip. Start with a
float64like3.99, convert it to anint, then convert that back to afloat64, and print all three values on one line. Then, in a comment, explain why the final value is not3.99anymore.
What we learned
- Declare variables with
:=(inferred, the everyday form, locals only) orvar(explicit type, or no initial value yet); an uninitialised value is never garbage, it is the type's zero value, and unused locals are a compile error; - Multiple assignment (
a, b = b, a) and the blank identifier_let you unpack multi-value returns and deliberately discard what you do not need; - Go's numeric types name their size (
int8..int64,uint8..uint64,float32/float64), with plainintas the sensible default andbyte/runeas aliases foruint8/int32; fixed-size integers wrap around on overflow rather than growing; - There is no implicit numeric conversion -- you write
float64(x)on purpose, which kills a family of precision and sign bugs, andint(f)truncates rather than rounds; - A
constis fixed at compile time, and numeric constants are untyped with arbitrary precision, slotting into whatever type the context needs and only being range-checked when finally stored; iotais a per-const-block counter that makes enumerations correct-by-construction; with1 << iotait builds bit flags, with_you can skip values, and any expression ofiotais fair game;- Giving a set of constants a named type (
type Weekday int) lets the compiler stop you passing an arbitrary integer where a specific kind is expected.
Next episode we get serious about functions: multiple return values (Go's answer to exceptions), named returns, variadic functions, and the fact that functions are first-class values you can pass around and build closures from. Bring the editor -- there is real code to write. See you there.