for is the only loop keyword in Go. It comes in three forms: the three-clause loop for i := 0; i < n; i++, the condition-only loop for cond, which is Go's while loop, and the infinite loop for { ... }. Add range and the same keyword iterates over slices, arrays, maps, strings, channels, integers and iterator functions. Go has no while, do, foreach or loop keyword (Go spec, For statements).
TL;DR
for init; cond; post { ... }is the counted loop. No parentheses around the clauses, and the braces are required.for cond { ... }is a while loop andfor { ... }loops until abreak,returnorpanic.for i, v := range xiterates over a slice, array, map, string, channel, integer (Go 1.22) or iterator function (Go 1.23). Map order is random.- Since Go 1.22, each iteration gets a new loop variable if
go.moddeclaresgo 1.22or later. Goroutines started in a loop no longer all see the last value. - The range value is a copy. Assign to
items[i]to change the slice. - An unlabeled
breakinside aselectorswitchonly leaves that statement. Use a label to leave the loop.
What are the forms of a for loop in Go?
The spec defines one for statement with three shapes. They differ only in which clauses you write.
The three-clause loop
The init statement runs once, the condition is checked before every iteration, and the post statement runs after every iteration. This loop retries a flaky health check with exponential backoff:
example.gogopackage main import ( "errors" "fmt" "time" ) var errUnavailable = errors.New("503 service unavailable") func fetchStatus(attempt int) error { if attempt < 3 { return errUnavailable } return nil } func main() { backoff := 100 * time.Millisecond for attempt := 1; attempt <= 5; attempt++ { err := fetchStatus(attempt) if err == nil { fmt.Printf("attempt %d: ok\n", attempt) break } fmt.Printf("attempt %d: %v, retrying in %v\n", attempt, err, backoff) time.Sleep(backoff) backoff *= 2 } }
example.texttextattempt 1: 503 service unavailable, retrying in 100ms attempt 2: 503 service unavailable, retrying in 200ms attempt 3: ok
attempt only exists inside the loop. Any of the three clauses can be left out. for ; attempt <= 5; { is legal, and gofmt rewrites it to for attempt <= 5 {.
The C habit of wrapping the clauses in parentheses does not compile:
example.gogofor (i := 0; i < 3; i++) {
example.texttext./main.go:6:9: syntax error: unexpected :=, expected )
The condition-only loop (while)
Drop the init and post statements and for becomes a while loop. This one drains a job queue until it is empty:
example.gogopackage main import "fmt" func main() { queue := []string{"resize:42.png", "resize:43.png", "thumbnail:44.png"} for len(queue) > 0 { job := queue[0] queue = queue[1:] fmt.Println("processing", job) } fmt.Println("queue empty") }
example.texttextprocessing resize:42.png processing resize:43.png processing thumbnail:44.png queue empty
Writing while instead gives a parse error, because while is an ordinary identifier in Go:
example.texttext./main.go:7:8: syntax error: unexpected name pending at end of statement
The compiler reads while as an expression, finds another name after it, and gives up. The message says nothing about loops, which is why it confuses people coming from other languages.
The infinite loop
With no clauses at all, for { ... } runs until something inside it leaves. Servers use this shape for accept loops and pollers:
example.gogofor { conn, err := ln.Accept() if err != nil { if errors.Is(err, net.ErrClosed) { return } log.Printf("accept: %v", err) continue } go handle(conn) }
The loop ends when the listener is closed and Accept returns net.ErrClosed. for {} replaces while (true) and for (;;), and the compiler knows it never finishes on its own. A function that ends in for {} with no break does not need a final return.
How does for range work?
range turns a for loop into an iteration over a value. What you get back depends on the type:
| Range over | First value | Second value |
|---|---|---|
| Slice or array | index | copy of the element |
| String | byte offset | rune (Unicode code point) |
| Map | key | value |
| Channel | received value | none |
Integer n (Go 1.22) | 0 to n-1 | none |
| Iterator function (Go 1.23) | whatever it yields | whatever it yields |
You can drop the second variable (for i := range items) or ignore the first with _ (for _, item := range items). for range n with no variables runs a block n times.
example.gogopackage main import ( "fmt" "maps" "slices" ) func main() { endpoints := []string{"/health", "/orders", "/users"} for i, path := range endpoints { fmt.Println(i, path) } statusCounts := map[int]int{200: 812, 404: 17, 500: 3} for _, code := range slices.Sorted(maps.Keys(statusCounts)) { fmt.Printf("%d: %d\n", code, statusCounts[code]) } for offset, r := range "café" { fmt.Printf("%d:%c ", offset, r) } fmt.Println() for attempt := range 3 { fmt.Print(attempt, " ") } fmt.Println() results := make(chan string, 2) results <- "job 1 done" results <- "job 2 done" close(results) for msg := range results { fmt.Println(msg) } }
example.texttext0 /health 1 /orders 2 /users 200: 812 404: 17 500: 3 0:c 1:a 2:f 3:é 0 1 2 job 1 done job 2 done
The map loop is the one to watch. The runtime randomizes map iteration order on purpose, so code can't depend on it. To print the status codes in order, maps.Keys returns an iterator over the keys and slices.Sorted collects and sorts them. The Go map keyword covers map iteration in more depth.
The string loop yields runes, and the offsets are byte positions, so é starts at offset 3 and takes two bytes. Use for i := 0; i < len(s); i++ with s[i] when you want raw bytes.
The channel loop stops when the channel is closed. If no one closes it, the loop blocks forever once the buffer is empty. The Go chan keyword covers who should close a channel.
Iterator functions (Go 1.23) let any function with the signature func(yield func(K, V) bool) work with range. The standard library returns them from maps.Keys, maps.All, slices.All, slices.Backward and strings.Lines, and you can write your own for paginated API results or database rows. range has more rules than fit here. The range section of the Go keywords guide is the short version.
What changed about loop variables in Go 1.22?
Before Go 1.22, a for loop declared its variables once and reused them on every iteration. A closure or goroutine that captured the variable saw whatever value it held when the closure ran, which was usually the last one. Go 1.22 changed this so each iteration gets a fresh variable (Go blog, Fixing For Loops in Go 1.22).
This health checker starts one goroutine per database host:
example.gogopackage main import ( "fmt" "sync" ) func main() { hosts := []string{"db-1", "db-2", "db-3"} var wg sync.WaitGroup for _, host := range hosts { wg.Add(1) go func() { defer wg.Done() fmt.Println("checking", host) }() } wg.Wait() }
With go 1.21 in go.mod, three runs printed checking db-3 three times each, and go vet reported:
example.texttextmain.go:16:28: loop variable host captured by func literal
With go 1.27 in go.mod, the same code checks all three hosts in whatever order the goroutines finish, and go vet has nothing to say.
The switch is tied to the go line in go.mod, not to the toolchain you build with. A Go 1.27 compiler still builds a module that declares go 1.21 with the old semantics, so a dependency written before 1.22 keeps working the way its authors tested it. The change applies to both the three-clause form and range. The old workaround host := host inside the loop is now redundant. The forvar modernizer in go fix deletes it for you. The Go func keyword covers closures and captured variables in more depth.
Why does modifying the range value not change the slice?
The second range variable is a copy of the element. For a slice of structs, changing a field on the copy leaves the slice alone:
example.gogopackage main import "fmt" type Invoice struct { ID string Amount int Paid bool } func main() { invoices := []Invoice{ {ID: "inv-101", Amount: 4200}, {ID: "inv-102", Amount: 1800}, } for _, inv := range invoices { inv.Paid = true } fmt.Println(invoices[0].Paid, invoices[1].Paid) for i := range invoices { invoices[i].Paid = true } fmt.Println(invoices[0].Paid, invoices[1].Paid) }
example.texttextfalse false true true
The first loop sets Paid on a copy that is thrown away at the end of each iteration. The second loop indexes into the slice and changes the real element. Go 1.22 did not change this. The 1.22 fix gave each iteration its own variable, and that variable is still a copy. For a slice of pointers ([]*Invoice), the copy is a pointer and inv.Paid = true does update the invoice.
Keeping a pointer to an element
Taking &invoices[i] gives you a pointer into the slice's backing array. That pointer goes stale if the slice grows past its capacity later, because append copies the elements to a new array:
example.gogovar large *Invoice for i := range invoices { if invoices[i].Amount > 4000 { large = &invoices[i] } } invoices = append(invoices, Invoice{"inv-103", 900}) large.Amount = 0 fmt.Println(invoices[0].Amount, large.Amount)
example.texttext4200 0
large still points into the old array, so the write never reaches invoices. Keep the index (largeIdx = i) instead of a pointer when the slice might grow.
How do break and continue work in a for loop?
break leaves the innermost for, switch or select. continue skips to the next iteration of the innermost for. Both take an optional label that names an outer loop.
A label is an identifier followed by a colon, placed on the line before the loop. This scan stops at the first error across several log files:
example.gogopackage main import "fmt" func main() { logLines := [][]string{ {"INFO start", "INFO ready"}, {"INFO request", "ERROR db timeout", "INFO retry"}, {"INFO shutdown"}, } files: for file, lines := range logLines { for _, line := range lines { if line[:5] == "ERROR" { fmt.Printf("file %d: first error %q\n", file, line) break files } } } }
example.texttextfile 1: first error "ERROR db timeout"
Without the label, break would only leave the inner loop and the scan would move on to the third file. continue files would skip the rest of the current file and go to the next one.
break inside select
This bug compiles and looks right on a quick read. A worker reads jobs until its context is cancelled:
example.gogofunc worker(ctx context.Context, jobs <-chan string) { for { select { case <-ctx.Done(): fmt.Println("worker stopping:", ctx.Err()) break case job := <-jobs: fmt.Println("handled", job) } } fmt.Println("worker flushed metrics") }
The break leaves the select, not the for. The worker prints worker stopping and goes straight back into the select, where ctx.Done() is still closed, so it prints the same line again in a tight loop. The metrics are never flushed. go vet reports the dead code after the loop:
example.texttextmain.go:20:2: unreachable code
and staticcheck reports the break itself:
example.texttextmain.go:15:4: ineffective break statement. Did you mean to break out of the outer loop? (SA4011)
The fix is a label on the loop:
example.gogofunc worker(ctx context.Context, jobs <-chan string) { loop: for { select { case <-ctx.Done(): fmt.Println("worker stopping:", ctx.Err()) break loop case job := <-jobs: fmt.Println("handled", job) } } fmt.Println("worker flushed metrics") }
example.texttexthandled email:1 handled email:2 worker stopping: context deadline exceeded worker flushed metrics
A return in the ctx.Done() case works too, when there is nothing to do after the loop. The same trap applies to break inside a switch within a loop.
Common for loop mistakes
defer inside a loop
defer runs when the function returns, not when the iteration ends. A loop that opens a file and defers Close keeps every file open until the whole function finishes:
example.gogofor _, p := range paths { f, err := os.Open(p) if err != nil { return 0, err } defer f.Close() // ... }
A handful of paths won't notice, but ten thousand log files will run the process out of file descriptors. Neither go vet nor staticcheck flags it. Move the body into a function so each defer runs at the end of its own call, or call f.Close() directly at the end of the iteration. Common Go mistakes to avoid covers this one with a full example.
Appending to a slice while ranging over it
range evaluates its operand once, before the first iteration. Appending inside the loop does not make it run longer:
example.gogourls := []string{"/", "/about"} for _, u := range urls { urls = append(urls, u+"/sitemap.xml") } fmt.Println(len(urls), urls)
example.texttext4 [/ /about //sitemap.xml /about/sitemap.xml]
The loop ran twice, once for each original element. That makes it safe, but it also means a crawler cannot discover new URLs this way. Use a condition loop over a queue, like for len(queue) > 0 above, when the work list grows as you go.
Changing a map during range
Deleting the current key while ranging over a map is allowed and safe. This loop drops idle sessions:
example.gogofor id, idle := range sessions { if idle > 30 { delete(sessions, id) } }
Adding keys during the loop is also allowed, but the spec says a new entry "may be produced during the iteration or may be skipped". Collect new keys in a separate map and merge them afterward if every one has to be processed.
Capturing the address of the loop variable
ptrs = append(ptrs, &inv) inside for _, inv := range invoices gives you pointers to copies, not to the slice elements. Before Go 1.22 every pointer pointed at the same variable and showed the last invoice. Since Go 1.22 each pointer points at its own copy, which is correct but still disconnected from the slice. Use &invoices[i] if you want to modify the originals, and keep the capacity warning above in mind.
Is there a while or do-while loop in Go?
No. Effective Go says the Go for loop "unifies for and while and there is no do-while". The condition-only form covers what other languages use while for.
A do-while runs the body once before checking the condition. In Go you write it as an infinite loop with the check at the end. Paginated API calls are the usual case, because you always fetch the first page:
example.gogocursor := "" for { page := fetchPage(cursor) fmt.Println("fetched", page.Items) cursor = page.Next if cursor == "" { break } }
example.texttextfetched [order-1 order-2] fetched [order-3]
That is two lines longer than do { } while (cursor != "") would be, and the exit condition sits at the bottom where you'd look for it.
Where LevelUpGo fits
LevelUpGo teaches Go through exercises that run real Go code in the browser. Go Basics has two lessons on for: one on the three loop forms and one on range, break and continue. Concurrency Fundamentals covers the loops that trip people up in concurrent code, such as starting goroutines in a loop, ranging over channels and leaving a for/select loop on cancellation. For the other 24 reserved words, see Go keywords: all 25 explained.
FAQ
Does Go have a while loop?
No, Go has no while keyword. Write for cond { ... } for a while loop and for { ... } for an infinite loop. Typing while gives a syntax error, because the compiler treats it as an ordinary name.
Does Go have a foreach loop?
for ... range is Go's foreach. for _, v := range items visits every element of a slice, and the same form works for arrays, maps, strings, channels, integers and iterator functions.
How do I loop N times in Go?
Since Go 1.22, for i := range n runs with i from 0 to n-1, and for range n runs n times without a variable. The three-clause form for i := 0; i < n; i++ works in every version.
How do I loop over a map in sorted order?
Collect and sort the keys, then range over them. Since Go 1.23, for _, k := range slices.Sorted(maps.Keys(m)) does both in one line. Map iteration order is randomized on purpose, so never depend on it.
How do I loop backwards over a slice in Go?
Use for i := len(s) - 1; i >= 0; i--, or since Go 1.23, for i, v := range slices.Backward(s), which yields index and value from the last element to the first.
Why does my goroutine in a for loop print the same value?
Your module is on a Go version before 1.22, where the loop variable is shared by every iteration. Change the go line in go.mod to go 1.22 or later and each iteration gets its own variable. On an older version, pass the value as an argument (go func(h string) { ... }(host)) or copy it with host := host inside the loop.
Sources
- The Go Programming Language Specification, For statements: https://go.dev/ref/spec#For_statements
- The Go Programming Language Specification, Break statements: https://go.dev/ref/spec#Break_statements
- Effective Go, For: https://go.dev/doc/effective_go#for
- The Go Blog, Fixing For Loops in Go 1.22: https://go.dev/blog/loopvar-preview
- Go 1.22 Release Notes: https://go.dev/doc/go1.22
- Go 1.23 Release Notes: https://go.dev/doc/go1.23
- The Go Blog, Range Over Function Types: https://go.dev/blog/range-functions
- staticcheck SA4011: https://staticcheck.dev/docs/checks/#SA4011
