Multiple Return Values
How Go functions return more than one value, why this pattern replaces exceptions and in-band error codes, and how to use it correctly for errors, map lookups, type assertions, and channel receives.
A Go function can return more than one value. That might sound like a small syntax feature, but it shapes the entire way Go programs handle errors, propagate state, and communicate partial results. Instead of special exception mechanisms or sentinel values tucked inside a single return, Go makes every outcome explicit in the function signature.
How Multiple Return Values Work
A function that returns multiple values declares them as a parenthesized list in its signature, right after the parameter list.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
The (float64, error) part says: this function returns two things—a float64 and an error. Inside the function, the return statement lists values in the same order. The caller receives them as a matched set using multiple assignment.
Fixed return order:
The order of return values matters and cannot be changed. If you write (error, float64) in the signature, every call site must expect the error first. Conventions help here: errors nearly always appear last.
There is no limit on the number of return values, but idiomatic Go rarely goes beyond three. Four or five return values usually signal that a struct would improve readability.
Running the example
Calling divide and printing the result shows both branches:
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result) // Result: 5
_, err = divide(10, 0)
fmt.Println("Error:", err) // Error: cannot divide by zero
}
Two different executions of the same function produce completely different outcomes—one returns a valid float64 and a nil error, the other returns a zero float64 and a descriptive error. The caller is forced to inspect err to know which path was taken. There is no hidden state.
This example demonstrates the core loop of Go error handling: call, check, act.
Why Go Chose Multiple Return Values Over Exceptions
Languages that rely on exceptions separate the normal flow of data from the error flow. In Go, they sit in the same line of code. A function that can fail simply adds an error to its return list, and the caller must handle that error immediately—or explicitly ignore it, which the blank identifier makes obvious.
Before Go, C programmers used "in-band" signaling: a function might return -1 or NULL to indicate failure, and the caller had to check a separate errno variable. That pattern is fragile. You can forget to check errno. You can misinterpret a legitimate -1 as an error. Multiple return values eliminate that confusion. The error is a distinct value, not a magical sentinel smuggled inside the result type.
Go's designers wanted the "happy path" to stay readable while making error handling unavoidable by accident. When every function returns a result and an error, you can’t accidentally use a garbage value as if nothing went wrong. The compiler even helps: if you declare a variable and don't use it, Go refuses to compile. So if you capture an error and never check it, the build fails—unless you deliberately discard it with _.
Basic Syntax and Returning Multiple Values
The simplest form returns a fixed number of values of any types.
func swap(a, b string) (string, string) {
return b, a
}
When called, both values land in variables separated by a comma:
x, y := swap("hello", "world")
fmt.Println(x, y) // world hello
Correct output confirms the swap:
If you see "world hello", the function correctly reversed the arguments. No hidden pointer swapping or reference magic—just ordered values.
The return types don’t have to match the parameter types, and they don’t have to be the same type as each other.
func summarize(name string, age int) (string, int) {
return fmt.Sprintf("%s is %d years old", name, age), age
}
Callers need to know the number and types, but that’s always visible right above the function name.
Receiving Multiple Return Values: Assignment Patterns
Go offers two assignment forms: short variable declaration (:=) and regular assignment (=). Their behavior differs when some variables already exist.
Short variable declaration with all-new variables
When none of the left-side variables exist yet, := declares and initializes all of them.
sum, product := calculate(5, 3)
Short variable declaration when some variables already exist
If at least one variable on the left side is new, := reassigns the existing ones and declares the new one. This is what lets you reuse an err variable across multiple calls without writing var err error at the top of the block.
f, err := os.Open("config.yaml")
// ...
data, err := io.ReadAll(f) // err is reassigned, data is new
The second line compiles because data is a new variable. If you had tried f, err := os.Open("another.yaml"), the compiler would complain that no new variables appear on the left. In that case you’d use = instead:
f, err = os.Open("another.yaml")
Partial redeclaration requires at least one new variable:
If you forget this rule, you’ll hit a compilation error: “no new variables on left side of :=”. That’s the compiler telling you to switch to = or to introduce a genuinely new variable.
Regular assignment with =
Use = when all variables on the left have already been declared. No new variables are created.
var result float64
var err error
result, err = divide(10, 3)
This form works even when assigning to a struct field or an array/slice index, where := cannot appear because those targets are not identifiers.
type Config struct {
Path string
}
var cfg Config
var err error
cfg.Path, err = loadPath() // OK: both targets already declared
A common mistake is attempting cfg.Path, err := loadPath(). The compiler rejects this because cfg.Path is not a plain identifier—it’s a selector expression. The spec allows := only with identifier names (or _) on the left. For non-identifier targets, declare err beforehand and use =.
The Blank Identifier for Ignoring Values
Go requires that every declared variable be used. That rule applies to values captured from a function call too. If you need only a subset of the return values, use the blank identifier _ to discard the rest.
_, err := divide(10, 0)
if err != nil {
log.Fatal(err)
}
Here the result is irrelevant because we know the computation failed. We discard it with _, and the compiler stays silent because the blank identifier is explicitly "used" by being discarded.
You can discard any position, but discarding an error is a deliberate choice that should appear only when you are certain failure is impossible or irrelevant. Its presence in the code acts as a signal to readers: "I have considered this error and decided to ignore it."
Discarding errors silently swallows failures:
Writing result, _ := divide(10, 0) compiles but leaves result as a zero value—with no indication that the division failed. Always check errors unless you can prove they cannot occur in that exact context.
Multiple Return Values and Error Handling
The most common use of multiple return values is pairing a result with an error. This pattern is so pervasive that it has a name: the comma-error idiom.
func fetchUser(id int) (User, error) {
if id <= 0 {
return User{}, fmt.Errorf("invalid user id: %d", id)
}
// ... actual lookup ...
return user, nil
}
At every call site, the pattern is:
user, err := fetchUser(42)
if err != nil {
// handle the error—log, retry, wrap, or return
return fmt.Errorf("fetchUser: %w", err)
}
// use user safely
There is no try/catch. The error travels up the call stack as an ordinary value, wrapped with context at each level using fmt.Errorf and the %w verb. By the time it reaches the top, it carries a traceable chain of what happened.
The error interface:
error is a built-in interface type with a single method Error() string. A nil error means success. Any non-nil value means something went wrong and should be examined. This design keeps the language small—no special syntax for exceptions, no implicit propagation.
A beginner-friendly mental model: think of the error return as a second envelope that always comes back from the function. If the envelope is empty (nil), the main result is safe. If the envelope contains a message, the main result should not be trusted. You must open the envelope first.
The Comma-Ok Idiom Beyond Errors
Multiple return values also power a pattern called comma-ok, where a second boolean signals success or presence. This appears in three built-in operations.
Map lookups
m := map[string]int{"apples": 5}
count, ok := m["oranges"]
if !ok {
fmt.Println("oranges not in map")
}
When the key is absent, count gets the zero value for int (which is 0), but ok is false. Without the ok variable, 0 could mean "found zero apples" or "key not present." The boolean disambiguates.
Type assertions
var i interface{} = "hello"
str, ok := i.(string)
if ok {
fmt.Println("string is:", str)
}
If i does not hold a string, ok is false and str is the zero value. Without the comma-ok form, a failed type assertion panics.
Channel receives
val, ok := <-ch
if !ok {
fmt.Println("channel closed")
}
When a channel is closed and drained, the receive yields the zero value and ok == false. This is how goroutines detect completion without a separate signal.
All three share the same shape: a primary value plus a boolean named ok by convention. The name isn’t mandatory—you can call it found, present, closed—but sticking to ok makes code instantly recognizable.
Common Mistakes and Misconceptions
Assuming order doesn’t matter
A function returning (int, error) and one returning (error, int) have different call-site requirements. Reversing them leads to type mismatches at assignment. Always check the signature.
Forgetting to check the error before using the result
This is the most frequent bug in Go programs. The compiler won’t help if you assign the error but never read it. Linters will, but the code compiles. Build the habit of writing the if err != nil block immediately after the call, before anything else.
// Dangerous
result, _ := divide(10, 0)
fmt.Println(result) // 0, with no clue that something went wrong
// Safe
result, err := divide(10, 0)
if err != nil {
log.Println(err)
return
}
fmt.Println(result)
Using := where = is required
Attempting a[0], b := myFunc() fails because a[0] is not an identifier. The fix is to declare b (or err) in advance and use =.
var b int
a[0], b = myFunc()
Ignoring an error without the blank identifier
Declaring result, err := ... and never using err causes a compilation error. Use _ if you genuinely don’t care, but be prepared to justify that choice.
Unused variable compile error protects you:
A compile-time complaint about an unused error variable is Go doing you a favor. It stops you from shipping code that silently ignores failures. Treat it as a feature, not an annoyance.
Summary
Multiple return values are not just a convenience—they are the backbone of Go’s design for error handling and for communicating optional results. They replace exception mechanisms with explicit, in-band values that the caller cannot ignore by accident. When you see a function that returns (T, error), (value, bool), or (result, bool) from a channel receive, you know exactly what you’re getting and what you’re expected to check.
Understanding the assignment rules—when to use :=, when to use =, and where _ fits—takes practice but quickly becomes second nature. Once you internalize the "call, check, use" rhythm, you’ll read and write Go code that is precise about failure and unambiguous about success.