Go has no while keyword. Drop the init and post statements from a for loop and what remains is a while: for condition { }. The language ships one looping construct that covers every shape, which is a deliberate simplification rather than an omission.
This is one of the clearest examples of Go’s design philosophy — one obvious way to do a thing, even when that means removing keywords other languages have. Once the four forms are in your head there is nothing else to learn about loops in Go, and no debate about which construct to use in a code review.
Table of contents
- The four forms
- do-while, which does not exist either
- range, and what it gives you
- The loop variable change in Go 1.22
- break, continue, and labels
- Loops in concurrent code
- How this fits the rest of the stack
- FAQ
The four forms
// 1. three-component, like C
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// 2. condition only -- this is while
sum := 1
for sum < 1000 {
sum += sum
}
// 3. no condition -- infinite
for {
if done() {
break
}
}
// 4. range -- over slices, maps, strings, channels
for i, v := range items {
fmt.Println(i, v)
}
Form two is the answer to the search that brought you here. Omit the semicolons entirely — for sum < 1000 — and Go treats the single expression as the condition. Writing for ; sum < 1000; { } is legal and gofmt will remove the semicolons for you.
Form three is Go’s while (true). It is idiomatic and appears constantly in servers, workers, and event loops, always paired with a break or a return somewhere inside.
There are no parentheses around the condition, and the opening brace must be on the same line. That second rule is not style preference — Go’s automatic semicolon insertion adds one at the end of a line ending in a condition, so a brace on the next line produces a compile error rather than a formatting complaint.
do-while, which does not exist either
Go has no do-while, so the body-runs-at-least-once pattern is built from an infinite loop with the condition at the bottom.
for {
doWork()
if !shouldContinue() {
break
}
}
There is a more compact variant using a flag, though the explicit break above reads better to most Go programmers:
ok := true
for ok {
doWork()
ok = shouldContinue()
}
The retry loop is the most common real use of this shape, and it is worth having a correct version to hand:
var err error
for attempt := 1; attempt <= 5; attempt++ {
err = doRequest()
if err == nil {
break
}
time.Sleep(time.Duration(attempt) * 200 * time.Millisecond)
}
if err != nil {
return fmt.Errorf("after 5 attempts: %w", err)
}
Note the backoff scaling with the attempt number, and %w to wrap the underlying error so callers can still inspect it with errors.Is. A retry loop that swallows the final error is a retry loop that hides the outage.
range, and what it gives you
range behaves differently depending on what you iterate, and the differences matter.
for i, v := range slice { } // index, copy of the value
for k, v := range m { } // key, value -- random order
for i, r := range str { } // byte offset, rune
for v := range ch { } // values until the channel closes
for i := range 10 { } // Go 1.22+: 0 through 9
Two of these catch people out regularly.
Map iteration order is deliberately randomised. Not merely unspecified — the runtime actively randomises it so you cannot accidentally depend on it. If you need a stable order, collect the keys, sort them, and iterate that:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
Ranging a string yields runes, not bytes. The index is the byte offset and jumps by more than one for multi-byte characters. This is almost always what you want for text. Use for i := 0; i < len(s); i++ when you genuinely need bytes.
The for i := range 10 form is new in Go 1.22 and is a small but welcome addition — it removes the last common use of the three-component form for simple counting.
The loop variable change in Go 1.22
This is the single most consequential loop change in the language’s history, and it silently fixed a bug that nearly every Go programmer wrote at least once.
Before 1.22, the loop variable was declared once and reused across every iteration. A goroutine or closure capturing it saw whatever value it held when the closure eventually ran — usually the final one.
// Go 1.21 and earlier: prints 3, 3, 3 (usually)
for _, v := range []int{1, 2, 3} {
go func() { fmt.Println(v) }()
}
// the fix everyone wrote
for _, v := range []int{1, 2, 3} {
v := v // shadow it
go func() { fmt.Println(v) }()
}
From Go 1.22, loop variables are created fresh on each iteration, so the first version prints 1, 2, and 3 in some order. The v := v shadowing line is no longer needed and is harmless if left in place.
The behaviour is governed by the Go version in your go.mod, not by the compiler you happen to be running. A module declaring go 1.21 keeps the old semantics even when built with a newer toolchain. That is deliberate — it means bumping the toolchain does not silently change your program’s behaviour — and it means the actual fix is bumping the version line in go.mod.
If you maintain older code, that one line is worth understanding before you change it, because a program relying on the old shared-variable behaviour will behave differently afterwards.
break, continue, and labels
outer:
for i := 0; i < 5; i++ {
for j := 0; j < 5; j++ {
if grid[i][j] == target {
break outer
}
if grid[i][j] == 0 {
continue outer
}
}
}
A plain break exits only the innermost loop. A labelled break exits the loop carrying that label, which is Go’s answer to needing to escape nested loops without a flag variable or a goto.
Labels also apply to select and to switch, and the switch case is worth knowing: inside a switch within a loop, a bare break breaks out of the switch, not the loop. That is a genuine trap, and a labelled break is how you actually leave the loop:
loop:
for _, msg := range messages {
switch msg.Kind {
case "stop":
break loop // leaves the for, not just the switch
case "skip":
continue
}
process(msg)
}
Keep labels rare. Two levels of nesting with one labelled break is readable; four levels with several is usually asking to be a function with an early return instead.
Loops in concurrent code
The most common Go loop in production is the worker consuming a channel, and it has a canonical shape worth copying.
for {
select {
case job, ok := <-jobs:
if !ok {
return // channel closed, nothing more coming
}
process(job)
case <-ctx.Done():
return // cancelled or timed out
}
}
Two exits, both explicit: the channel closing and the context being cancelled. A worker loop with only the first is a worker that cannot be shut down, which turns a graceful restart into a kill signal.
The simpler form works when the channel closing is the only exit you need:
for job := range jobs {
process(job)
}
range over a channel yields values until the channel is closed and then ends the loop. It is cleaner than the select version and it is the right choice whenever cancellation is handled elsewhere.
How this fits the rest of the stack
The Go 1.22 loop variable change is a good reminder that the version in go.mod is part of your program’s behaviour, not just metadata — which makes pinning the toolchain in the build a correctness matter rather than a preference. A service that builds from its repository fixes that version, keeps the build log attached to the deploy it produced, and leaves the previous deploy available when a toolchain bump surfaces something unexpected. Services on RunxBuild covers the deploy, environment variable, and rollback model. When you are sizing a Go service alongside a managed Postgres or MySQL, the RunxBuild hosting calculator shows the service, database, storage, and bandwidth as separate figures.
Useful related references:
- Looping in Bash: for, while, until, and the Loop That Eats Your Filenames
- An Error Occurred While Contacting the API: Finding the Real Failure
- NFS Access Denied by Server: 6 Causes and the Right Fixes
- Services on RunxBuild
FAQ
Does Go have a while loop?
No. Go has only for, and omitting the init and post statements gives you a while: for condition { }. This is deliberate — one looping construct covers the three-component form, while, infinite loops, and range.
How do I write an infinite loop in Go?
for { } with no condition at all. It is idiomatic and appears in servers, workers, and event loops, always paired with a break or return inside. Go has no while (true) because it does not need one.
Does Go have a do-while loop?
No. Use an infinite for with the condition at the bottom: for { doWork(); if !shouldContinue() { break } }. That gives you the run-at-least-once behaviour without a dedicated keyword.
Why is Go map iteration order random?
The runtime deliberately randomises it so code cannot come to depend on an order the implementation never promised. If you need deterministic output, collect the keys into a slice, sort it, and iterate that.
What changed with Go loop variables in 1.22?
Loop variables are now created fresh on each iteration rather than shared across the whole loop, which fixes the classic bug where goroutines captured the final value. The behaviour depends on the go directive in go.mod, not the toolchain version, so older modules keep the old semantics until that line is bumped.