Skip to content
gohacks.dev
Basics

Missing Return at End of Function Error in Go Explained

Go rejects a function with results unless every path ends in a terminating statement. See which statements count, why log.Fatal does not and the fixes.

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

You wrote a function that returns on every path you can think of, and the compiler says missing return at the closing brace. It is almost always right. The rule it applies is mechanical and slightly stricter than human reasoning, which is why a function that looks sure to return can still fail it. This article lists the statements the language counts as terminating, runs the shapes that fail, and shows the three fixes with the one I prefer. It follows the switch article, where the same error appears, and functions with multiple returns. It is part of the Go basics path.

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

Three functions that fail

A function with a result must end in what the specification calls a terminating statement. Here are the three shapes I meet most, each failing for a different reason:

main.goGo
package main

import "fmt"

func sign(n int) int {
	if n > 0 {
		return 1
	} else if n < 0 {
		return -1
	}
}

func firstEven(xs []int) int {
	for {
		for _, x := range xs {
			if x%2 == 0 {
				break
			}
		}
		break
	}
}

func label(n int) string {
	switch n {
	case 1:
		return "one"
	default:
		fmt.Println("other")
	}
}

func main() {
	fmt.Println(sign(1), firstEven(nil), label(1))
}
Text
# example.test/lab
./main.go:11:1: missing return
./main.go:22:1: missing return
./main.go:31:1: missing return

Each error is missing return, reported at the closing brace of the function. The messages come from different causes.

sign has an if and an else if and no final else. Both conditions can be false, n == 0, and then control falls off the end. A human knows n is an int and the three cases cover everything, and the compiler does not reason about values. It looks at the syntax: an if statement terminates only if it has an else and both branches terminate.

firstEven has a for with no condition, which would be terminating, since it loops forever unless broken. But the loop contains a break that targets it, so it can exit normally, and then execution reaches the end. The inner break belongs to the inner range loop. The outer break is the one that counts.

label has a switch with a default, and the default case prints and does not return. A switch is terminating only when it has a default and every case ends in a terminating statement, with no break pointing at the switch. One non-returning branch is enough to fail the whole statement.

What counts as terminating

The specification's list of terminating statements is short, and you can learn it as a table:

StatementTerminating when
returnAlways
gotoAlways
Call to the builtin panicAlways
A block { ... }Its last statement is terminating
if ... else ...Both branches are terminating
for with no condition and no rangeNo break refers to it
switchThere is a default, no break refers to it, and every case list ends in a terminating statement or fallthrough
selectNo break refers to it and every case ends in a terminating statement
A labeled statementThe labeled statement is terminating

And here are functions that satisfy the rule, even though they look like they might not:

main.goGo
package main

import "fmt"

func forever() int {
	for {
	}
}

func ifElse(n int) int {
	if n > 0 {
		return 1
	} else {
		return 2
	}
}

func crash() int {
	panic("never returns")
}

func sw(n int) string {
	switch {
	case n > 0:
		return "positive"
	case n < 0:
		return "negative"
	default:
		return "zero"
	}
}

func main() {
	fmt.Println(ifElse(1), sw(0))
	_ = forever
	_ = crash
}
Text
1 zero

forever has no return statement at all and compiles, because an unconditional for with no break never reaches the end. crash compiles because panic is terminating. sw shows the idiomatic version of the switch: a default that returns. The program prints 1 zero, and the other two functions are never called, only referenced so the compiler does not complain about them being unused.

The functions that end the process

The case that confuses people most is a function that ends with a call that never returns:

main.goGo
package main

import (
	"fmt"
	"log"
	"os"
)

func mustOpen(path string) *os.File {
	f, err := os.Open(path)
	if err != nil {
		log.Fatal(err)
	}
	return f
}

func fail(msg string) int {
	fmt.Println(msg)
	os.Exit(1)
}

func main() {
	_ = mustOpen
	_ = fail
}
Text
# example.test/lab
./main.go:20:1: missing return

mustOpen is fine, because it has a final return f, and I included it for contrast. fail ends with os.Exit(1), which stops the process, and the compiler reports missing return anyway. Only the builtin panic counts as terminating. os.Exit, log.Fatal, t.Fatal and runtime.Goexit are ordinary functions as far as the type checker is concerned, even though they never come back. That is a design choice: making a package function special would mean the language rule depends on a library. The fix is to add a return that will never run, or to panic:

Go
func fail(msg string) int {
	fmt.Println(msg)
	os.Exit(1)
	return 0 // unreachable, satisfies the compiler
}

The unreachable return is the accepted idiom for this case, and go vet does not complain about it, because it cannot tell either. If the function really is about failing, return an error and let main decide to exit. A library function that calls os.Exit takes the choice away from every caller and cannot be tested.

Three fixes, in order of preference

Return on every branch. Restructure so the last statement is a plain return, usually by making the failure case the default:

Go
func sign(n int) int {
	if n > 0 {
		return 1
	}
	if n < 0 {
		return -1
	}
	return 0
}

Early returns with a final return of the "fall through" value read well and are the dominant Go style. They also avoid else after a return, which linters flag.

Add a default to the switch that either returns a value or panics when reaching it means a bug. For an enum, default: panic(fmt.Sprintf("unknown level %d", l)) makes the failure loud instead of returning a wrong value. For input from outside, return an error, or a clear placeholder.

Loops that search. A function that loops looking for something should return the result inside the loop when it finds it, and return the "not found" value after the loop. The firstEven case becomes:

Go
func firstEven(xs []int) (int, bool) {
	for _, x := range xs {
		if x%2 == 0 {
			return x, true
		}
	}
	return 0, false
}

The second result tells the caller whether the search succeeded, which is better than a sentinel such as -1, as functions that return an error or a bool explains.

If a function with no result ends with a return that has a value, you get too many return values. If a function with a result has a bare return and the results are not named, you get not enough return values. And where a function is expected to return a result, a return with the wrong type gives the familiar cannot use value as type. Dead code after a terminating statement does not cause an error, but go vet reports it as unreachable code.

When the rule feels wrong

The rule is syntactic on purpose. Value analysis would make the compile result depend on the checker's cleverness, and a different compiler could accept different programs. The cost is the occasional artificial return. In generated code and in functions whose last branch is a long state machine, a final panic("unreachable") documents the assumption and satisfies the rule. If you find yourself adding it often, it may be a sign that a type, such as an enum, should be handled by a switch with a default that fails loudly. The authoritative text is the language specification on terminating statements, and the Go FAQ discusses the design trade-off behind the compiler's strictness.

Taggederror fix
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.