Goto Hell
Language
- unknown
by James Smith (Golang Project Structure Admin)
It’s one of the oldest control structures, dating back to early programming languages like FORTRAN and Assembly.
But despite its historical significance, the goto
keyword has developed a controversial reputation over the years.
In fact, it’s often associated with spaghetti code — a tangled mess of jumps in logic that’s nearly impossible to maintain.

So why is the goto
keyword still alive and kicking in modern languages like Go? And more importantly, why should you avoid using it?
In this post, we’ll explore some of the problems that can arise when using goto
in Go and why steering clear of it may well save your code from a trip to debugging hell.
Table of Contents
What Is Goto?
The goto
statement allows us to transfer control to another part of the program, usually to a specific label, skipping over intermediary lines of code.
Here’s an example in Go:
package main
import "fmt"
func main() {
fmt.Println("This is before the goto.")
goto Label
fmt.Println("This will be skipped.")
Label:
fmt.Println("This is after the goto.")
}
In this example, the goto
statement jumps directly to the label, completely bypassing the line of code that would have been printed otherwise.
On the surface, it looks like a fairly convenient way to handle control flow.
But dig deeper and you’ll see why this seemingly innocent feature is something you should avoid.
Who Criticized the Use of Goto?
The use of the goto
statement was most famously and vehemently condemned by the eminent Dutch computer scientist Edsger Dijkstra.
He condemned it in his 1968 letter to the editor of the journal Communications of the ACM.
The letter was given the following blunt title: “Go To Statement Considered Harmful”.
If you have the time, it’s worth a read, even after more than five decades have passed since it was originally written. It’s only three pages long.
Why Is Using Goto Bad?
In theory, goto
offers flexibility. You can jump around your code freely, breaking out of loops or arbitrarily skipping over large blocks of code.
But in practice, this freedom often comes at a steep price: using goto
invariably reduces readability and maintainability.
It Obscures the Program Flow
The primary problem with goto
is that it inherently breaks the natural, structured flow of your program.
Go, like most modern programming languages, is designed to be structured and readable. Control flows like if
, for
, and switch
clearly indicate how your program executes step-by-step. The goto
keyword, on the other hand, makes it harder to trace the execution path.
In large codebases, following a series of goto
jumps is a nightmare. It disrupts the linear flow, forcing the reader to jump back and forth in the code just to understand what’s going on.
This can make the process of debugging, rewriting and adding new features much more difficult.
It Introduces Hard-to-Find Bugs
One of the core principles of Go is simplicity. The goto
keyword complicates that by introducing subtle, hard-to-detect bugs.
When code jumps from one point to another, it’s easy to overlook which variables have been modified, which functions have been called or which conditions have been skipped.
This can — and often does — result in bugs that are hard to track down because the flow of execution is no longer simple and intuitive.
For instance, what if your goto
skips a key part of a loop or exits a block that was meant to clean up resources?
Have a look at the example below:
package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
if i == 5 {
goto SkipCleanup
}
fmt.Println("i:", i)
}
fmt.Println("This is a cleanup section.")
SkipCleanup:
fmt.Println("Having skipped cleanup, the code execution continues.")
}
In this scenario, the goto
skips right over the "cleanup section" once the value of the iterating variable reaches five.
As your code grows more complex, managing and predicting these kinds of skips becomes nearly impossible without introducing bugs.
It Violates Principles of Code Readability
Code should tell a story, and every part of that story should be easy to follow.
However, jumping around with goto
makes that story disjointed — as though a reader were flipping between various chapters in a novel.
When another developer (or a future-you) comes across your code, they will have to piece together what is happening and in what order. And this task will be made all the more difficult by these seemingly random jumps in the program’s flow.
One of Go’s design philosophies is to keep things as readable and easy to reason about as possible. Using goto
undermines this principle.
Well-structured loops, conditionals, and even defer
statements give your program a much clearer and cleaner structure, making it easier for others (and you) to follow.
When Is Using Goto Acceptable?
While relying on goto
is generally frowned upon, there are, nonetheless, a few very specific cases where its use may be perfectly acceptable, or even justified, in Go.
The Go designers themselves included the keyword for the sake of these rare cases — but, even in these situations, using goto
should be done as sparingly as possible.
Breaking out of Deeply Nested Loops
Go doesn’t have a break
or continue
statement that can break out of multiply nested loops at once.
These keywords only affect the deepest loop that's currently in scope.
So, in such cases where we need to exit from multiple loops at once, goto
can sometimes provide a cleaner solution than introducing complicated flags or otherwise restructuring your code.
Let's look at an example:
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
for j := 0; j < 5; j++ {
if i*j == 6 {
goto BreakLabel
}
fmt.Println("i:", i, "j:", j)
}
}
BreakLabel:
fmt.Println("We have broken out of the loop.")
}
The example program above uses nested for
loops to iterate over a 5×5 grid of integers.
If the product of the loop variables i
and j
equals 6, the goto
statement transfers control to the labelled statement BreakLabel
, thereby breaking out of both loops.
Until that condition is met, the values of i
and j
are printed on each iteration of the inner loop.
The program concludes by printing a message indicating that both loops have been exited prematurely.
So, in a case like this, goto
helps us to break out of multiple loops at once, but, even here, it may not always be the best solution.
Refactoring our code, or moving the logic into a function and using return
to exit the loop, could prove to be a better and more readable approach.
Handling Errors in C-Like Code
In systems programming, particularly when interacting with C code or when performance is critical, goto
can be used for error-handling — just as it is in legacy code.
However, Go already has excellent support for error-handling with the if err != nil {}
idiom, which means that we're unlikely to need to rely on goto
in Go-specific applications.
Looking at an Example of Using Goto in C
So now let's look at some code written in the C programming language to see how goto
can sometimes come in handy:
#include <stdio.h>
#include <stdlib.h>
int read_data() {
FILE *file = NULL;
char *buffer = NULL;
file = fopen("data.txt", "r");
if (!file) {
perror("Failed to open file");
goto cleanup;
}
buffer = (char *)malloc(1024);
if (!buffer) {
perror("Failed to allocate memory");
goto cleanup;
}
// simulating doing stuff with the file and buffer
printf("Processing file...\n");
cleanup:
if (buffer)
free(buffer);
if (file)
fclose(file);
return (file && buffer) ? 0 : -1;
}
int main() {
if (read_data() != 0) {
printf("An error occurred during processing.\n");
} else {
printf("Work completed successfully.\n");
}
return 0;
}
In the example above, the goto
statement is used for centralized cleanup within a function that opens a file and allocates memory. Both operations are prone to failure, and handling each failure individually with duplicated cleanup code would make the function unnecessarily cluttered.
Instead, the goto cleanup;
statement provides an elegant and structured way to jump to a common exit point, where all necessary cleanup steps (like freeing memory or closing a file) can be performed safely.
Importantly, the use of goto
here does not reduce readability. The control flow is simple and predictable: if something fails, we jump to the cleanup:
label, otherwise we proceed normally.
It avoids the unnecessary replication of resource-release logic and thereby makes the code more maintainable, while still easy to reason about.
Alternatives to Using Goto in Go
While the goto
keyword may be used more often in older languages like C, the Go programming language offers a rich set of structured control-flow mechanisms that render the use of that keyword almost entirely unnecessary.
Using Break and Continue to Manage Iteration Within Loops
One of the most effective ways to avoid goto
is simply through the use of break
and continue
statements within loops.
These allow for fine-grained control over iteration, enabling the premature termination of a loop or the skipping of specific iterations without resorting to unstructured jumps.
For example, if a loop needs to exit upon encountering a certain condition, a break
statement generally suffices, as shown below:
for _, value := range values {
if value == target {
break
}
fmt.Println(value)
}
Likewise, continue
can be employed to skip the remainder of the current iteration and proceed directly to the next cycle, again as seen below:
const threshold = 10
for _, value := range values {
if value < threshold {
continue
}
fmt.Println(value)
}
Placing Code Within a Function and Exiting It Early
Another idiomatic alternative is to encapsulate logic within functions and use explicit return
statements to exit early.
This approach improves maintainability by promoting modularity, as well as reducing the complexity of control flow.
So, rather than using goto
to jump out of nested logic, we can simply return from a function whenever a particular condition is met, as in the example below:
func handleValues(values [][]int) {
for _, innerValues := range values {
for _, value := range innerValues {
if value < 0 {
log.Println("Encountered an invalid value.")
return
}
fmt.Println(value)
}
}
}
The function above traverses a two-dimensional slice of integers, printing out each value, unless it encounters a negative number, in which case it prints an error message and exits immediately.
This will pull the control flow out of both loops immediately, returning to the outside scope.
Cleaning Things up With Defer
Finally, for resource management and cleanup tasks, Go provides the defer
keyword, the use of which ensures that any specified operations are executed at the conclusion of a function’s execution, irrespective of how control exits the function.
This eliminates the need for manually handling cleanup via goto
in error-prone sections of code.
Let's look at an example:
func handleFile(path string) (error) {
file, err := os.Open(path)
if err != nil {
return err
}
// the next line ensures cleanup even if there's an error later in the function
defer file.Close()
// process the file here
return nil
}
The loadFile
function above attempts to open a file based on its path within the file system, and the function returns any error encountered.
If the file cannot be opened, it returns early with the error. Otherwise, it defers the file’s closure, ensuring that it is always properly released when the function exits, even if an error were to occur later within the function.