Agentic AI Engineering with Python: Live Course
GoControl Flow

For Loops

Go has one loop keyword. There is no while, no do while, no foreach, and no loop. Everything is for, and the keyword changes behaviour depending on how much you write after it.

This is not minimalism for its own sake. Every loop in Go looks like a for, so you never have to work out which of five constructs you are reading.

The four forms

// 1. Three clause: init, condition, post
for i := 0; i < 5; i++ {
    fmt.Println(i)
}

// 2. Condition only, which is a while loop
for balance > 0 {
    balance -= payment
}

// 3. No clauses at all, an infinite loop
for {
    conn := listener.Accept()
    go handle(conn)
}

// 4. Range, covered fully on the next page
for i, v := range items {
    fmt.Println(i, v)
}

The first three are the same statement with pieces omitted. Drop the init and post clauses and you get a while loop. Drop everything and you get an infinite loop. There is no separate syntax to learn, just less of the same one.

The three clause form

for i := 0; i < 10; i++ {
    // body
}
   for  init  ;  condition  ;  post  {  body  }
        ↓         ↓            ↓         ↓
      runs      checked      runs      runs when
      once      before       after     condition
                each pass    each pass  is true

i is scoped to the loop and does not exist afterwards. That is usually what you want, and when it is not, declare it outside:

i := 0
for ; i < 10; i++ {
}
fmt.Println(i)     // 10, still in scope

Notice the leading semicolon. When you omit the init clause but keep the others, the semicolon stays.

Counting down and stepping by more than one work as you would expect:

for i := 10; i > 0; i-- { }
for i := 0; i < 100; i += 10 { }

Multiple variables need parallel assignment, since ++ is a statement and cannot be combined:

for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
    s[i], s[j] = s[j], s[i]
}

That is an in-place reverse, and the i, j = i+1, j-1 post clause is the standard way to advance two counters at once.

The while form

Omit init and post and Go stops requiring the semicolons:

count := 10
for count > 0 {
    fmt.Println(count)
    count--
}

This is the loop you want whenever the number of iterations is not known in advance:

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    process(line)
}
for !queue.IsEmpty() {
    job := queue.Pop()
    run(job)
}

The infinite form

for {
    // runs until something breaks out or returns
}

Written like this deliberately, not as for true {}, which gofmt leaves alone but every Go programmer will read as unusual.

Infinite loops are ordinary in Go rather than a warning sign, because servers and workers genuinely do run until told to stop:

func worker(jobs <-chan Job, quit <-chan struct{}) {
    for {
        select {
        case job := <-jobs:
            process(job)
        case <-quit:
            return
        }
    }
}

They also power retry logic:

func fetchWithRetry(url string, attempts int) (*Response, error) {
    var lastErr error
    delay := 100 * time.Millisecond

    for i := 0; i < attempts; i++ {
        resp, err := fetch(url)
        if err == nil {
            return resp, nil
        }
        lastErr = err
        time.Sleep(delay)
        delay *= 2                 // exponential backoff
    }

    return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}

Ranging over a plain number

Since Go 1.22 you can range over an integer, which removes the noise from loops that only need a count:

for i := range 5 {
    fmt.Println(i)          // 0, 1, 2, 3, 4
}

for range 3 {
    fmt.Println("hello")    // runs three times, no variable needed
}

This is the newest addition to Go's loop syntax and it is already the natural way to write a fixed repetition. If you are on an older Go version, the three clause form does the same job.

Nested loops

Nothing special here, but the indentation adds up quickly:

for i := 1; i <= 3; i++ {
    for j := 1; j <= 3; j++ {
        fmt.Printf("%d x %d = %d\n", i, j, i*j)
    }
}

Two levels is common, three is worth a second look, and four almost always means the inner part should be its own function. Pulling the body out also gives you a return to escape with, which is often clearer than labelled breaks.

func findPair(grid [][]int, target int) (int, int, bool) {
    for i, row := range grid {
        for j, v := range row {
            if v == target {
                return i, j, true      // returns from the whole function
            }
        }
    }
    return 0, 0, false
}

Performance notes that actually matter

Two habits are worth forming early, and neither is premature optimisation.

Hoist expensive work out of the condition. The condition is evaluated on every single pass:

for i := 0; i < len(expensive()); i++ { }    // calls expensive() every time

n := len(expensive())
for i := 0; i < n; i++ { }                   // calls it once

len() on a slice is free, so i < len(items) is fine. A function call that does real work is not.

Preallocate when you know the size. Appending to a slice inside a loop reallocates as it grows:

results := make([]int, 0, len(items))     // capacity reserved up front
for _, item := range items {
    results = append(results, transform(item))
}

The slices page explains why this matters and roughly how much it saves.

Go has no loop unrolling directives, no parallel for, and no way to hint to the compiler about iteration counts. When a loop is genuinely too slow, the answer is either a better algorithm or splitting the work across goroutines. The concurrency section covers the second option.

A loop that does something real

Pulling several of these forms together, here is a small worker that processes a queue with a timeout:

func drain(jobs []Job, budget time.Duration) (done int, err error) {
    deadline := time.Now().Add(budget)

    for i := 0; i < len(jobs); i++ {
        if time.Now().After(deadline) {
            return done, fmt.Errorf("time budget exceeded after %d jobs", done)
        }

        if err := jobs[i].Run(); err != nil {
            return done, fmt.Errorf("job %d failed: %w", i, err)
        }
        done++
    }

    return done, nil
}

One loop, an early return for the timeout, an early return for the failure, and the count carried out either way.

Next, let's look at range, which is the form you will actually type most often.

How is this guide?

Last updated on