The goto Statement
Understand the goto statement in Go—its syntax, the restrictions that keep it safe, and the few situations where using it makes code clearer rather than worse.
The goto statement performs an unconditional jump to a labelled line within the same function. It has a reputation that precedes it: in many languages, goto is the quickest way to turn readable code into an untraceable mess. Go keeps goto in the language but wraps it in strict rules that prevent the worst abuses. Most Go programs never need it. When they do, the use is precise, local, and often the clearest way to express a control flow that would otherwise require flag variables or deeply nested logic.
How goto Works in Go
A goto statement transfers execution to a label that appears elsewhere in the same function. Labels are covered in detail in the next section, but the short version is that a label is an identifier followed by a colon placed before a statement. You cannot jump into another function, and you cannot jump across function boundaries.
Think of goto as a one-way teleport. When the program hits goto, it immediately resumes execution at the target label, skipping everything in between. There is no condition attached to the jump itself—though in practice, goto almost always sits inside an if statement, because an unconditional jump on every execution would create an infinite loop or dead code.
Syntax and Basic Usage
package main
import "fmt"
func main() {
i := 0
loop:
fmt.Println(i)
i++
if i < 3 {
goto loop
}
fmt.Println("done")
}
This program prints the numbers 0, 1, and 2, then "done". The label loop: marks a point in the function, and goto loop jumps back to it while i < 3. The logic is a hand‑rolled loop, though in real code you would use a for statement for this. The example exists to show the mechanics: a label, a condition, and a jump.
Do not use goto to build loops:
Go’s for statement is clearer, safer, and the compiler optimises it better. Building a loop with goto makes the code harder to read without any performance benefit. Reserve goto for the few patterns where structured alternatives are genuinely more complex.
The label must appear inside the same function as the goto, and it must be attached to a statement—you cannot put a label on an empty line or a closing brace. The label name follows identifier rules and is case‑sensitive.
Restrictions That Prevent Chaos
Go’s designers kept goto but added compile‑time checks that eliminate the most dangerous patterns. If you try to use goto in a way that could leave variables in an inconsistent state, the compiler refuses to build the program. These restrictions are not stylistic advice; they are enforced by the language.
Jumping Over Variable Declarations
A goto statement cannot skip over a variable declaration that introduces a new variable into scope at the point of the jump. This rule prevents the program from landing at a place where a variable exists but was never initialised.
func bad() {
goto skip
x := 42 // declared here
skip:
fmt.Println(x) // compile error: goto jumps over declaration of x
}
The compiler rejects this code because x would be in scope after the label but would never have received its value. The same error occurs if the declaration is a var block that introduces new names.
func fine() {
var x int
goto skip
x = 42
skip:
fmt.Println(x) // prints 0
}
Here the variable already exists before the goto, so the jump is allowed. The assignment x = 42 is skipped, and x retains its zero value. The rule guards against accessing uninitialised memory, not against skipped assignments.
Jumping Into Blocks
You cannot jump into a block—a region enclosed by curly braces—from outside that block. A goto must target a label that is in the same block or an enclosing block, but never a nested one that has not yet been entered.
func broken() {
goto inside
{
inside:
fmt.Println("hello") // compile error
}
}
This fails because inside: lives in a nested block that the outer scope has not entered. The compiler treats this the same as jumping over a variable declaration: the label introduces a new scope that would be entered illegally.
The rule keeps the scope chain consistent. When execution reaches a label, the set of visible variables must be exactly what the surrounding code expects. Entering a block from outside would mean some variables are suddenly in scope with no prior lifetime.
Scope and Shadowing Interactions
Go’s block scoping means a goto can interact with variable shadowing. If an inner block declares a variable with the same name as an outer one, a goto that jumps from the inner block to a label in the outer block must not skip the point where the inner variable goes out of scope in a way that confuses the compiler. In practice, the same rule applies: you cannot jump past a declaration that brings a new variable into scope, even if that variable shadows an outer one.
These restrictions mean goto in Go is a far safer construct than in C, where you can jump into the middle of a loop or past initialisations and rely on the programmer to avoid undefined behaviour.
Compiler errors are your friend here:
When the compiler rejects a goto, read the error message carefully. It will name the specific variable or block scope violation. The fix is usually to move the declaration before the goto or to restructure the code so the jump does not cross a scope boundary.
When goto Is the Right Tool
Idiomatic Go uses goto sparingly. There are, however, a handful of patterns where goto is the most readable option—and in each case, the alternatives involve extra boolean flags, duplicated cleanup code, or contorted loop structures.
Breaking Out of Deeply Nested Loops
A break statement exits only the innermost enclosing for, switch, or select. To leave multiple nested loops at once, the cleanest solution is often a goto to a label placed just after the outer loop.
func findPair(matrix [][]int, target int) (int, int, bool) {
for i, row := range matrix {
for j, val := range row {
if val == target {
goto found
}
}
}
return 0, 0, false
found:
return i, j, true
}
The alternative would be a boolean flag like found := false that each loop checks, or using a labelled break (Go supports break label to exit a specific enclosing loop). The labelled break is often just as readable, but it requires the outer loop to have a label and the break must be directly inside that loop. If the logic that determines when to exit is spread across multiple levels or interspersed with other checks, goto can be easier to follow.
Labelled break is usually preferred:
For straightforward multi‑level loop exits, a labelled break is the more idiomatic choice in Go. Use goto when the exit logic cannot be expressed neatly with a single break—for example, when you need to jump from inside a switch that is itself inside a loop, where a break would only exit the switch.
Centralised Error Handling and Cleanup
Functions that acquire multiple resources (files, network connections, locks) must release each one if an error occurs partway through. Without goto, the code either nests deeply or duplicates cleanup logic in each error branch.
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("read: %w", err)
}
// ... process data ...
return nil
}
Go’s defer solves most of these cases cleanly. The goto pattern becomes relevant when cleanup must happen conditionally or in a specific order that defer cannot express, or when you are in a hot path where defer’s overhead is unacceptable (rare, but real in some systems code). In those situations, a single goto to a cleanup label can centralise resource release.
func allocate() error {
a := acquireA()
if a == nil {
return errors.New("A failed")
}
b := acquireB()
if b == nil {
releaseA(a)
return errors.New("B failed")
}
c := acquireC()
if c == nil {
releaseB(b)
releaseA(a)
return errors.New("C failed")
}
// ... use a, b, c ...
releaseC(c)
releaseB(b)
releaseA(a)
return nil
}
With goto:
func allocate() error {
a := acquireA()
if a == nil {
return errors.New("A failed")
}
b := acquireB()
if b == nil {
goto cleanupA
}
c := acquireC()
if c == nil {
goto cleanupB
}
// ... use a, b, c ...
releaseC(c)
cleanupB:
releaseB(b)
cleanupA:
releaseA(a)
return nil
}
The goto version keeps the happy path unindented and avoids repeating release calls. Each label falls through to the next, so the cleanup cascades in reverse acquisition order. This is the same pattern used in C error handling, but Go’s scope restrictions ensure you cannot accidentally skip a resource release by jumping over its declaration.
Defer is almost always better:
Go’s defer is simpler, safer, and the standard way to handle cleanup. The goto‑based cleanup pattern should only appear in codebases that have measured a genuine need to avoid defer, or in functions where defer cannot capture the exact cleanup logic required. If you are writing typical application code, stick with defer.
State Machines and Parser Loops
A hand‑written lexer or parser sometimes benefits from goto to jump between states without function call overhead. Each state is a label, and the main loop uses goto to transition.
func lex(input string) {
pos := 0
if pos >= len(input) {
return
}
switch input[pos] {
case ' ':
pos++
goto skipSpace
case '+':
pos++
goto handlePlus
default:
pos++
goto handleDefault
}
skipSpace:
// consume remaining spaces
for pos < len(input) && input[pos] == ' ' {
pos++
}
// fall into next state
goto dispatch
handlePlus:
fmt.Println("plus")
goto dispatch
handleDefault:
fmt.Printf("char: %c\n", input[pos-1])
goto dispatch
dispatch:
if pos < len(input) {
switch input[pos] {
case ' ':
pos++
goto skipSpace
case '+':
pos++
goto handlePlus
default:
pos++
goto handleDefault
}
}
}
This style can be faster than calling functions for every token because it avoids stack pushes. However, it sacrifices readability. In most Go programs, a for loop with a switch and function calls is easier to maintain and fast enough. The goto‑driven state machine appears in the standard library—the text/template package uses it—but it is an expert‑level optimisation.
Common Misconceptions and Mistakes
“Goto is always harmful.” The blanket rule exists because beginners tend to overuse goto when they should learn structured constructs. Go’s compiler prevents the genuinely dangerous jumps, so the remaining cases are about readability, not safety. A well‑placed goto can make code flatter and clearer, but that judgment requires experience.
Jumping into an if block is allowed if no variables are declared. The restriction is about block entry, not just variable declarations. A goto cannot jump into any nested block from outside it, regardless of whether that block contains declarations. The compiler treats the block boundary as a scope change.
You can use goto to exit a function early. While technically possible, return is the correct tool for that. Using goto to jump to a label just before a return adds indirection with no benefit.
Watch out for dead code:
Labels placed after a return statement are unreachable. The Go compiler will flag such labels as unused, which can be confusing if you placed a goto that intends to target them. Always keep labels in the main flow path.
Summary
The goto statement in Go is a surgical instrument, not a general‑purpose control flow tool. Its syntax mirrors goto in C, but Go’s scope‑aware restrictions prevent jumping over variable declarations or into blocks, removing the most common sources of bugs. In practice, goto earns its keep in three scenarios: exiting deeply nested loops when a labelled break is insufficient, centralising error cleanup in functions that cannot use defer, and constructing tight parser state machines. Everywhere else, structured control flow is clearer and safer.
If you find yourself reaching for goto, first ask whether a labelled break, a defer, or a helper function solves the problem.