Go gets by with if, one loop keyword called for, and a switch that doesn’t fall through. Learn each form, the Go 1.22 change that gives every loop iteration its own variable, and labelled break.
Go has fewer control-flow keywords than most languages. There’s if, there’s switch, and there’s exactly one loop keyword, for. No while, no do, no foreach. Each of those jobs is a different shape of for.
This post walks through every shape, plus break, continue, labels and the change in Go 1.22 that fixed a classic loop bug. Every program below was run on Go 1.26, and its output is pasted from the run.
if with no parentheses and required braces
A Go if has no parentheses around the condition, and the braces are always required, even for one line.
package main
import "fmt"
func main() {
temp := 31
if temp > 30 {
fmt.Println("hot")
} else if temp > 15 {
fmt.Println("mild")
} else {
fmt.Println("cold")
}
}
It prints:
hot
The condition must be a bool. Go won’t treat 0, an empty string or nil as false, so if count { ... } doesn’t compile when count is an int. You write if count > 0 and say what you mean.
if with a short statement
An if in Go can run a short statement before its condition, separated by a semicolon. You’ll see this shape constantly, because it’s how Go code looks things up and checks the result in one line:
package main
import "fmt"
func main() {
ages := map[string]int{"ana": 31, "ben": 0}
if age, ok := ages["ben"]; ok {
fmt.Println("ben is", age)
}
if age, ok := ages["cai"]; ok {
fmt.Println("cai is", age)
} else {
fmt.Println("no age for cai, got", age)
}
}
It prints:
ben is 0
no age for cai, got 0
age, ok := ages["ben"] runs first. Then ok is the condition. Reading a missing key from a map gives the zero value, so age is 0 for both “ben” and “cai”. Only ok tells you that “ben” is really there with an age of 0 and “cai” isn’t there at all. The part on maps covers this comma-ok form in full.
Notice that age is usable in the else branch too. A variable declared in the short statement lives for the whole if, including every else if and else.
Once the if ends, the variable is gone:
package main
import "fmt"
func main() {
ages := map[string]int{"ana": 31}
if age, ok := ages["ana"]; ok {
fmt.Println("found", age)
}
fmt.Println(age)
}
It fails to build:
./main.go:10:14: undefined: age
That’s the reason to use the short form. The variable exists exactly where it’s useful and can’t leak into the rest of the function, where someone might read a stale value by mistake.
for: the only loop keyword
Go has one loop keyword, for, and it takes three shapes that cover what other languages split across for, while and do.
package main
import "fmt"
func main() {
// Three parts: init; condition; post.
var seen []int
for i := 0; i < 3; i++ {
seen = append(seen, i)
}
fmt.Println(seen)
// Condition only: this is Go's while loop.
n := 1
for n < 100 {
n *= 3
}
fmt.Println(n)
// No condition: loop until something breaks out.
tries := 0
for {
tries++
if tries == 4 {
break
}
}
fmt.Println("tries:", tries)
}
It prints:
[0 1 2]
243
tries: 4
The three-part form is the one from C and Java, minus the parentheses. Drop the init and post parts and you have a while loop. Drop the condition too and the loop runs forever, until a break or a return stops it. A server’s accept loop and a worker waiting for jobs both look like that bare for.
i++ is a statement in Go, not an expression. You can’t write x := i++, and there’s no ++i.
for range over slices and strings
A for range loop walks over the elements of a collection and gives you an index and a value on each pass.
package main
import "fmt"
func main() {
fruits := []string{"fig", "kiwi", "plum"}
for i, f := range fruits {
fmt.Println(i, f)
}
prices := []int{10, 20, 30}
for _, p := range prices {
p *= 2
}
fmt.Println(prices)
for i := range prices {
prices[i] *= 2
}
fmt.Println(prices)
}
It prints:
0 fig
1 kiwi
2 plum
[10 20 30]
[20 40 60]
Use _ for the part you don’t need. Go refuses to compile an unused variable, so _ is how you say “I’m ignoring the index on purpose”.
The second loop is a trap worth seeing once. p is a copy of each element, so doubling p changes nothing in the slice. To change the elements, loop over the indexes and write through prices[i], as the third loop does.
Ranging over a string gives you something slightly different:
package main
import "fmt"
func main() {
for i, r := range "héllo" {
fmt.Println(i, string(r))
}
}
It prints:
0 h
1 é
3 l
4 l
5 o
The index jumps from 1 to 3. A range over a string walks runes (Unicode code points), and the index is the byte offset where each one starts. é takes two bytes in UTF-8, so the next rune starts at byte 3. The part on strings, bytes and runes explains why.
for range over maps: the order is random on purpose
Ranging over a map gives you each key and value, but Go makes no promise about the order, and the runtime deliberately changes it from one loop to the next:
package main
import "fmt"
func main() {
stock := map[string]int{"apples": 5, "bread": 2, "cheese": 7, "dates": 1}
for k, v := range stock {
fmt.Println(k, v)
}
}
One run printed:
cheese 7
apples 5
dates 1
bread 2
Run it again and the lines can come out in a different order. Nothing in the map changed. Each range over a map starts from a randomly chosen position.
The Go team added that randomness so that programs couldn’t quietly depend on an order that was never guaranteed. Code that happens to work because the keys “always” come out in one order breaks the day that order changes. Shuffling every time makes the bug show up in your tests instead.
When you need a stable order, ask for it. maps.Keys returns the keys, slices.Sorted collects them into a sorted slice, and you range over that:
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
stock := map[string]int{"apples": 5, "bread": 2, "cheese": 7, "dates": 1}
for _, k := range slices.Sorted(maps.Keys(stock)) {
fmt.Println(k, stock[k])
}
}
It prints:
apples 5
bread 2
cheese 7
dates 1
This version prints the same thing every time. The part on maps goes deeper.
for range over channels
A for range over a channel receives values until the channel is closed.
package main
import "fmt"
func main() {
jobs := make(chan string, 3)
jobs <- "resize"
jobs <- "upload"
jobs <- "notify"
close(jobs)
for job := range jobs {
fmt.Println("doing", job)
}
fmt.Println("channel closed, loop done")
}
It prints:
doing resize
doing upload
doing notify
channel closed, loop done
There’s only one loop variable here, the value. A channel has no index. If nobody ever closed jobs, the loop would wait forever for a fourth value. Channels get a part of their own later in the series. For now, just know that for range is how you read them.
for range over an integer
Since Go 1.22, for i := range n counts from 0 up to, but not including, n.
package main
import "fmt"
func main() {
var seen []int
for i := range 5 {
seen = append(seen, i)
}
fmt.Println(seen)
total := 0
for i := range 4 {
total += i
}
fmt.Println(total)
}
It prints:
[0 1 2 3 4]
6
That’s the same as for i := 0; i < 5; i++, with less to get wrong. There’s no condition to mistype as <= and no increment to forget. It’s now the normal way to loop a fixed number of times, and you’ll see it throughout this series.
Each iteration gets its own loop variable
Since Go 1.22, every pass through a for loop gets a fresh copy of the loop variable, which matters as soon as a closure captures it.
package main
import "fmt"
func main() {
var prints []func()
for i := 0; i < 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, p := range prints {
p()
}
}
It prints:
0
1
2
The first loop builds three small functions, and each one prints i. None of them runs until the second loop. By then the first loop has finished, yet each function still prints the value i had when that function was made.
That looks obvious. Before Go 1.22 it wasn’t true, and the same program printed 3 3 3. It was one of the most common Go bugs, and it bit hardest when the closure was a goroutine started inside a loop.
Explain it like I’m ten
Imagine you’re handing out three sealed envelopes, and on the outside of each one you write “open me later to see the number on the board”.
The old Go had one whiteboard. You wrote 0 on it and handed out an envelope, then rubbed it out and wrote 1, then 2, then 3. When your friends finally opened their envelopes and looked up at the board, they all saw 3.
The new Go gives every envelope its own little whiteboard, stapled inside. Envelope one has a board with 0 on it, and nothing you do afterwards touches it.
The precise version
A closure doesn’t copy the variables it uses. It keeps a reference to them. So what matters is how many variables there are.
Before Go 1.22, a for loop declared its variable once, and every iteration updated that same variable. Every closure referred to the one i, and they all read its final value, 3.
From Go 1.22, each iteration declares a new variable. In the three-part form, Go copies the value from the previous iteration into the new variable before running the post statement. Each closure refers to its own i, which nobody changes after that iteration ends. The same rule applies to for range loops.
Where the analogy breaks: the little whiteboards aren’t read-only. If code inside one iteration changes i, the closure made in that iteration sees the change. What stops happening is later iterations overwriting it.
Which rule applies is set by go.mod
The new behaviour depends on the go line in your module’s go.mod, not on the version of Go you have installed. Run that same program with Go 1.26, in a module whose go.mod says go 1.21, and you get the old result:
$ go run .
3
3
3
The compiler is new, but it keeps the old loop semantics for old modules so that upgrading Go can’t change what existing code does. If you copy a loop into an old project, check its go.mod. The part on packages and modules covers that file. Closures themselves come up properly in the next part, on functions.
break, continue and labels
break leaves the innermost loop, and continue skips to its next iteration.
package main
import "fmt"
func main() {
var kept []int
for i := range 10 {
if i%2 == 1 {
continue
}
if i > 6 {
break
}
kept = append(kept, i)
}
fmt.Println(kept)
}
It prints:
[0 2 4 6]
Odd numbers hit continue, so they never reach the append. At 8 the loop hits break and ends, so 8 never prints either.
Both keywords only reach the loop they’re directly inside. With nested loops, that’s often the wrong one. Say you’re searching a grid and want to stop the moment you find a match. A plain break in the inner loop only ends the current row, and the outer loop carries on.
A label fixes that. You name the outer loop, then break or continue that name:
package main
import "fmt"
func main() {
grid := [][]int{
{1, 4, 7},
{2, 5, 8},
{3, 6, 9},
}
checked := 0
search:
for r, row := range grid {
for c, v := range row {
checked++
if v == 5 {
fmt.Println("found 5 at", r, c)
break search
}
}
}
fmt.Println("cells checked:", checked)
for r, row := range grid {
cells:
for _, v := range row {
if v%2 == 0 {
fmt.Println("row", r, "first even:", v)
break cells
}
}
}
}
It prints:
found 5 at 1 1
cells checked: 5
row 0 first even: 4
row 1 first even: 2
row 2 first even: 6
break search ends the outer loop, so the search stops after 5 cells instead of checking all 9. The label goes on its own line, followed by a colon, right before the loop it names.
The second loop shows a label on the inner loop. break cells there does exactly what a plain break would. That’s legal but pointless, and real code only labels the loop it needs to reach from further in. continue takes a label too, and continue search would jump to the next row.
Go also has goto, which jumps to a label in the same function, but you’ll rarely meet it outside generated code.
switch: no fallthrough by default
A Go switch runs the first case that matches and then stops. You don’t write break at the end of each case.
package main
import "fmt"
func kind(day string) string {
switch day {
case "sat", "sun":
return "weekend"
case "mon", "tue", "wed", "thu", "fri":
return "weekday"
default:
return "not a day"
}
}
func main() {
for _, d := range []string{"sun", "wed", "moon"} {
fmt.Println(d, kind(d))
}
}
It prints:
sun weekend
wed weekday
moon not a day
A case can list several values, separated by commas, and it matches if any of them does. default runs when nothing else matched. It’s optional, and it can go anywhere in the list.
In C and Java, forgetting break makes a case run straight into the next one. Go turned that around because the forgotten break was a bug far more often than a choice.
switch with no condition
A switch with nothing after the keyword tests each case as a boolean, and it’s the cleanest way to write a long if-else chain.
package main
import "fmt"
func grade(score int) string {
switch {
case score >= 90:
return "A"
case score >= 75:
return "B"
case score >= 50:
return "C"
default:
return "F"
}
}
func main() {
for _, s := range []int{95, 75, 60, 12} {
fmt.Println(s, grade(s))
}
}
It prints:
95 A
75 B
60 C
12 F
Cases are checked top to bottom, and the first true one wins. That’s why 95 gets an A and not a B, even though 95 >= 75 is true as well. Order the cases from most specific to least.
A switch can take a short statement too, just like if: switch n := len(items); { ... } scopes n to the switch.
fallthrough, when you really mean it
The fallthrough keyword makes a case continue into the next case’s body. You have to ask for it explicitly.
package main
import "fmt"
func main() {
level := 2
switch level {
case 3:
fmt.Println("send a page")
fallthrough
case 2:
fmt.Println("send an email")
fallthrough
case 1:
fmt.Println("write to the log")
}
}
It prints:
send an email
write to the log
Level 2 matched case 2, which printed and then fell through to case 1. Note that fallthrough doesn’t check the next case’s value. It runs the next body unconditionally. That’s the surprise that catches people who expect it to keep matching.
fallthrough has to be the last statement in a case, and it can’t be used in the final case. You’ll seldom need it. When a problem looks like it wants fallthrough, a case with several values or a short if usually reads better.
A first look at the type switch
A type switch branches on the dynamic type of a value rather than on the value itself.
package main
import "fmt"
func describe(x any) string {
switch v := x.(type) {
case int:
return fmt.Sprintf("an int, doubled is %d", v*2)
case string:
return fmt.Sprintf("a string of %d bytes", len(v))
case nil:
return "nothing at all"
default:
return fmt.Sprintf("something else: %T", v)
}
}
func main() {
fmt.Println(describe(21))
fmt.Println(describe("go"))
fmt.Println(describe(nil))
fmt.Println(describe(2.5))
}
It prints:
an int, doubled is 42
a string of 2 bytes
nothing at all
something else: float64
any means “a value of any type”. Inside each case, v has that case’s type, so v*2 is legal in the int case and len(v) is legal in the string case. The part on interfaces explains what any really is and when a type switch is the right tool.
What to remember
- An
ifcan start with a short statement,if v, ok := f(); ok. The variable lives for the wholeifandelsechain and nowhere else. foris Go’s only loop: three-part, condition-only as a while loop, bare for “forever”, andfor rangeover slices, strings, maps, channels and integers.- Map iteration order is randomised on purpose. Sort the keys with
slices.Sorted(maps.Keys(m))when order matters. - Since Go 1.22, each iteration gets its own loop variable, so closures see the value from their own pass. The
goline ingo.moddecides which rule applies. breakandcontinuereach the innermost loop. Label the outer loop to reach that one.switchstops after the first matching case. Use several values in one case, orswitchwith no condition for an if-else chain.fallthroughruns the next body without checking it.
Go has one loop and a switch that stops on its own, so there’s less to remember and less to get wrong.