Skip to content
gohacks.dev
Basics

Goto in Go and the One Case Where It Reads Very Well

Go has goto, with rules that stop it jumping over variables or into blocks. See the compile error, a retry loop and when a label reads well.

4 min read
Summarize in AI
Flat illustration of an editor window full of code.

People are surprised that Go has goto at all, and the standard library itself uses it sparingly. The language kept it with restrictions that remove most of what made it dangerous, and the result is a statement that is almost never the best choice and occasionally the clearest. This tip shows the rules by breaking them, and the one pattern where I would write it. It belongs with labels for loops in the Go basics path, because both use the same label syntax.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

What goto does and what it refuses

goto Label continues execution at the statement marked Label: in the same function. A simple use looks like this:

main.goGo
package main

import "fmt"

func main() {
	i := 0
loop:
	if i < 3 {
		fmt.Print(i, " ")
		i++
		goto loop
	}
	fmt.Println("done")
}
Text
0 1 2 done

That is a for loop written by hand, and a real for is clearer. The point is the rules. Jumping over a variable declaration is an error, because the variable would be in scope at the label without ever having been initialized:

main.goGo
package main

import "fmt"

func main() {
	goto done
	x := 1
	fmt.Println(x)
done:
	fmt.Println("finished")
}
Text
# example.test/lab
./main.go:6:7: goto done jumps over declaration of x at ./main.go:7:4

The compiler reports goto done jumps over declaration of x, with the line of the declaration. Jumping into a block is also refused, with a message saying the jump goes into a block, since the variables of that block would not exist. A label that is defined and never used is an error as well, as with loop labels. Together those rules mean that every goto goes to a place where the set of live variables is the same or smaller than where it started, so the control flow is easy to follow even without the usual objection to the keyword.

The case where it reads well

C programmers use goto cleanup to share one block of cleanup code between many failure exits. Go's defer covers that for resources, and one situation remains where a label is neat: a function that retries from the top after a recoverable failure, where wrapping the whole body in a loop would indent it all by a level.

main.goGo
package main

import (
	"errors"
	"fmt"
)

var attempts int

func fetch() (string, error) {
	attempts++
	if attempts < 3 {
		return "", errors.New("temporary failure")
	}
	return "payload", nil
}

func load() (string, error) {
	tries := 0

retry:
	data, err := fetch()
	if err != nil {
		tries++
		if tries < 5 {
			goto retry
		}
		return "", fmt.Errorf("giving up after %d tries: %w", tries, err)
	}
	return data, nil
}

func main() {
	fmt.Println(load())
	fmt.Println(attempts)
}
Text
payload <nil>
3

The jump back to retry passes over the declaration data, err := fetch() and the compiler accepts it. A backward jump is fine because, at the label, those variables are not yet declared, so the jump brings nothing new into scope. The restriction is about jumping forward past a declaration into code that may use it, which is why this compiles and the earlier example did not. The function succeeds on the third attempt and returns the payload.

I would still write that function as a for loop with a break, and most reviewers would ask for it. The loop version has a visible bound, a place to add a delay and no label. The goto form is defensible only when the code before the label is long and you do not want to re-indent it. The retry logic you will actually ship, with backoff and jitter, belongs in its own function, as retry with exponential backoff and jitter shows.

Where goto still appears

You will find it in generated code, in parsers and state machines where each state is a label, and in a few places in the standard library where it avoids duplicating an error-handling tail. In application code, I have not needed it once the habit of returning early and using defer set in. If you find yourself reaching for it, try these first: a for loop with continue, a helper function that returns, or a switch inside a loop for a state machine. The specification on goto statements is short and states the two restrictions, and Effective Go mentions it only in passing, which tells you where it ranks.

Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.