How to Use goto in Go (And Why You Usually Shouldn't)

Use goto to jump to a label in Go, but prefer structured control flow for readability.

The nested loop escape hatch

You are parsing a configuration file. You have a loop over lines, a loop over key-value pairs, and a loop over validation rules. You find a conflict. You need to stop everything immediately, log the error, and exit the function. You are three levels deep. You reach for goto. It works. The code jumps straight to the error handling. Your teammate reviews the pull request and asks why you used a jump statement from 1960.

Go includes goto because structured control flow cannot express every pattern cleanly. The language designers kept it for rare cases where break, return, or helper functions create more noise than signal. You will see goto in the standard library, usually in low-level code or complex state machines. In application code, you will rarely need it. When you do need it, the compiler enforces strict rules to prevent the jump from corrupting the stack or skipping variable initialization.

What goto actually does

A goto statement transfers control to a labeled statement within the same function. A label is an identifier followed by a colon, placed at the start of a line. The label marks a destination. The goto marks the jump. The compiler checks that the label exists and that the jump is safe before generating the machine code.

Labels are scoped to the function. You cannot jump to a label in a different function. You cannot jump across package boundaries. The jump stays local. This restriction keeps the control flow graph manageable and allows the compiler to optimize the code effectively.

Here is the minimal structure. The label sits at the destination. The goto sits at the source.

func main() {
    // label must be at the start of the line; gofmt enforces this alignment
    // the jump stays within the same function scope
    goto end

    fmt.Println("This line is skipped")

end:
    // execution resumes here after the jump
    fmt.Println("Done")
}

The compiler rejects the program if the label is missing or if the goto references a label that does not exist. You get undefined: label if you typo the name. You get label defined and not used if you define a label but never jump to it. Go treats unused labels as errors, not warnings. This keeps the codebase clean of dead destinations.

Labels anchor to the left margin. gofmt enforces this rule. You cannot indent a label. If you try to indent a label, the formatter moves it to column zero. This visual rule makes labels stand out from regular code. They look like anchors in the flow. Trust gofmt. Argue logic, not formatting.

The compiler's guardrails

Go allows goto, but the compiler restricts where you can jump. These restrictions exist to protect variable initialization and block structure. The compiler prevents jumps that would leave variables in an undefined state or skip over setup code.

You cannot jump over the initialization of a variable. If a variable is declared and initialized between the goto and the label, the compiler stops you. The error message is explicit: goto label jumps over initialization of v. The compiler refuses to generate code that would skip the assignment. This rule ensures that every variable is initialized before use, even after a jump.

func badJump() {
    // this jump is illegal
    // the compiler rejects it with: goto skip jumps over initialization of x
    goto skip

    x := 42
    fmt.Println(x)

skip:
    // x is not defined here because the initialization was skipped
    fmt.Println("Skipped")
}

You cannot jump into the middle of a block. A block is a scope delimited by braces, like an if body or a for body. Jumping into a block would bypass the block's entry logic and potentially skip variable declarations. The compiler rejects this with goto label jumps into block. The jump must land at a point where the scope is already active.

func blockJump() {
    if true {
        // this jump is illegal
        // the compiler rejects it with: goto inside jumps into block
        goto inside
    }

inside:
    // this label is inside the if block
    // jumping here would bypass the if condition
    fmt.Println("Inside")
}

These rules make goto safer than in C. In C, you can jump over initialization and land in uninitialized territory. In Go, the compiler forces you to structure the code so that jumps are safe. You can still write confusing code, but you cannot write code that violates memory safety or variable scoping. The compiler protects you from the worst mistakes.

Realistic use: breaking deep nests

The most common valid use of goto is breaking out of deeply nested loops when a labeled break is not enough. A labeled break exits the loop and continues after the loop block. A goto can jump to any label, including code that performs cleanup or error handling before exiting.

Consider a function that searches for a pair of numbers that sum to a target. You have nested loops. When you find the pair, you want to return true. You can use return here, but what if you need to log the result or update a counter before returning? A goto to a common exit point works.

func findPair(nums []int, target int) bool {
    // search for two distinct indices that sum to target
    // nested loops require a way to exit both levels at once
    for i, a := range nums {
        for j, b := range nums {
            if i != j && a+b == target {
                // found the pair; jump to the success path
                // this avoids adding a flag variable or extracting a helper
                goto found
            }
        }
    }

    // no pair found
    return false

found:
    // common exit point for success
    // you can add logging or metrics here without duplicating code
    return true
}

The goto jumps to the found label. The label is at the end of the function. The code after the label runs only when the pair is found. This pattern is clean when the exit logic is shared. If the exit logic is simple, return is better. If the exit logic is complex, a goto to a common block reduces duplication.

Compare this to a labeled break. A labeled break exits the loop and continues after the loop. It cannot jump to an arbitrary point in the function.

func findPairBreak(nums []int, target int) bool {
    // labeled break exits the loop but cannot jump to arbitrary code
    // this is cleaner when you just need to stop looping
search:
    for i, a := range nums {
        for j, b := range nums {
            if i != j && a+b == target {
                // break exits both loops and continues after the outer loop
                break search
            }
        }
    }

    // this code runs after the break
    // you can check a flag or return directly
    return true
}

The labeled break is often better for loops. It exits the loop structure and lets you handle the result in the normal flow. Use goto when you need to jump to code that is not immediately after the loop. Use break when you just need to stop looping. Labeled breaks exit loops. Goto jumps anywhere. Pick the narrowest tool.

The cleanup trap

Programmers coming from C often reach for goto to handle cleanup. In C, you open a file, check for errors, and jump to a cleanup label if anything fails. The cleanup label closes the file and returns. This pattern avoids duplicating the close call.

Go has defer. The defer statement schedules a function call to run when the surrounding function returns. It handles cleanup automatically, even if the function returns early or panics. You rarely need goto for cleanup in Go.

func readFile(path string) ([]byte, error) {
    // open the file
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }

    // defer ensures the file is closed when the function returns
    // this runs even if ReadAll returns an error
    defer f.Close()

    // read the contents
    data, err := io.ReadAll(f)
    if err != nil {
        return nil, err
    }

    return data, nil
}

The defer call runs f.Close() when readFile returns. It does not matter how the function returns. The cleanup happens. This is safer and clearer than a goto to a cleanup label. The goto pattern requires you to manage the jump manually. The defer pattern is automatic.

If you use goto for cleanup, you risk skipping the cleanup if you add a new return path later. defer is attached to the function return, not a specific code path. It is robust against refactoring. Use defer for resource cleanup. Use goto for control flow jumps. Defer handles cleanup. Goto handles jumps. Keep them separate.

Pitfalls and compiler errors

The biggest pitfall with goto is readability. A jump can land anywhere in the function. The reader must track the jump source and destination. This mental overhead increases with the distance between the goto and the label. If the jump spans many lines, the code becomes hard to follow.

The compiler helps by enforcing scope and initialization rules, but it cannot enforce style. You can write a function with goto statements that jump back and forth like spaghetti. The compiler accepts it. The reader suffers.

Common compiler errors include:

  • goto label jumps over initialization of v: You tried to jump over a variable declaration. Move the declaration before the goto or restructure the code.
  • goto label jumps into block: You tried to jump into an if or for body. Move the label outside the block or use a different control structure.
  • label defined and not used: You defined a label but never jumped to it. Remove the label or add the goto.
  • undefined: label: You referenced a label that does not exist. Check the spelling or add the label.

The compiler errors are precise. They tell you exactly what went wrong. Fix the error by adjusting the code structure. If you cannot fix the error without making the code worse, you probably should not use goto. Reach for a helper function or a labeled break instead.

When to use goto

Go provides multiple ways to control flow. goto is one tool among many. Use the right tool for the job. The decision depends on the structure of the code and the complexity of the exit logic.

Use a goto when you need to jump to a specific point in the function that is not the end of a block, such as shared error handling code before a return. Use a labeled break when you need to exit nested loops and continue execution after the loop block. Use a labeled continue when you need to skip to the next iteration of an outer loop. Use a helper function when the logic inside the nested structure is complex enough to warrant extraction. Use return when the function is done and you just need to exit early. Use defer for cleanup instead of goto to a cleanup label. Use plain sequential code when you don't need concurrency or complex jumps: the simplest thing that works is usually the right thing.

The decision matrix favors structure over jumps. goto is a sledgehammer. Use a screwdriver for loops. Use a wrench for cleanup. Keep goto in the toolbox for the rare case where nothing else fits.

Goto is legal in Go. It is also rare. The compiler keeps it safe. The community keeps it disciplined. Use it when the alternative is worse. Otherwise, let the code flow naturally.

Where to go next