The two-handed rule
You write a function that reads a configuration file. It returns the parsed settings and an error if the disk is full or the JSON is malformed. You call it from main, assign the result to a single variable, and hit build. The compiler stops you immediately with multiple-value LoadConfig() in single-value context.
This is not a syntax typo. It is a deliberate design boundary. Go refuses to let you accidentally discard a return value. When a function hands you two pieces of data, the language expects you to acknowledge both. If you only extend one hand, the compiler treats it as a logical gap and rejects the program.
Think of it like receiving a package with two boxes. The delivery driver will not leave both on your porch if you only hold out one hand. You must either take both, or explicitly tell the driver to leave one behind. Go enforces that explicit choice at compile time.
How the compiler counts slots
Go treats multiple return values as a first-class feature. Other languages pack extra results into tuples, objects, or wrapper structs. Go bakes them directly into the function signature. The compiler tracks the exact number of values a function produces and matches them against the assignment targets.
// ParseJSON reads a byte slice and returns a decoded map plus an error.
func ParseJSON(data []byte) (map[string]any, error) {
// The function signature declares two return slots.
// The compiler will verify every call site accounts for both.
var result map[string]any
err := json.Unmarshal(data, &result)
return result, err
}
func main() {
// This assignment fails at compile time.
// The compiler sees two return slots but only one target variable.
// config := ParseJSON([]byte(`{"key": "value"}`))
// Capture both values explicitly.
// The left side must provide exactly two identifiers.
config, err := ParseJSON([]byte(`{"key": "value"}`))
// Handle the error immediately.
// Go convention places error checks right after the call.
if err != nil {
log.Fatal(err)
}
// Use the config safely.
fmt.Println(config)
}
The compiler performs a static slot count before generating any machine code. It reads the function signature, notes the (map[string]any, error) tuple, and scans the call site. If the left side of the assignment has fewer identifiers than the right side produces, it emits the multiple-value error. There is zero runtime overhead for this check. The mismatch is caught during the type-checking phase, which means your program never runs with an unhandled return value.
Go's error handling philosophy relies on this explicitness. The language does not use exceptions. Errors are ordinary values that travel alongside successful results. By forcing you to name every return value, the compiler guarantees that the error path cannot be accidentally ignored. You must either process it, wrap it, or deliberately discard it.
The compiler counts slots. Match them or rewrite the call.
Real code: configuration and HTTP handlers
In production code, multi-return functions appear everywhere. Database queries, network calls, file operations, and type assertions all follow the (value, error) pattern. The assignment pattern scales to realistic handlers without adding ceremony.
// FetchUser queries the database and returns a User struct or an error.
func FetchUser(id int) (User, error) {
// Simulate a database lookup.
// The function always returns two values, even on success.
if id == 0 {
return User{}, fmt.Errorf("invalid user id")
}
return User{ID: id, Name: "Alice"}, nil
}
// HandleUserRequest processes an HTTP request and writes a response.
func HandleUserRequest(w http.ResponseWriter, r *http.Request) {
// Parse the ID from the URL path.
// strconv.Atoi returns an int and an error.
id, err := strconv.Atoi(r.URL.Query().Get("id"))
// Fail fast if parsing fails.
// Returning early keeps the happy path unindented.
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
// Fetch the user from the database.
// Both return values must be captured.
user, err := FetchUser(id)
// Handle database errors separately from parsing errors.
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Write the response.
// The compiler verified both errors were acknowledged.
fmt.Fprintf(w, "Hello %s", user.Name)
}
Notice how the error handling flows naturally. Each call site captures both values. The if err != nil block sits directly below the call. This pattern is verbose by design. The Go community accepts the repetition because it makes failure paths visible at a glance. You do not need to trace call stacks or search for catch blocks. The error handling lives exactly where the error originates.
When you genuinely do not care about a secondary return value, you use the blank identifier _. The underscore tells the compiler, "I saw this return slot, and I am intentionally dropping it." Use it sparingly. Never use it to swallow an error unless you have a documented reason. Errors that disappear into an underscore become invisible bugs that surface months later.
Explicit assignment wins. The blank identifier is a deliberate choice, not a shortcut.
Where the error hides
The multiple-value X in single-value context error appears in three common shapes. Recognizing them saves debugging time.
The first shape is direct assignment, which you already saw. You call a function and assign it to one variable. The compiler rejects it immediately.
The second shape is function arguments. You try to pass a multi-return function directly into another function that expects a single value.
// LogResult prints a single string.
func LogResult(msg string) {
fmt.Println(msg)
}
func main() {
// This fails. LogResult expects one string, but ParseJSON returns two values.
// The compiler cannot automatically unpack the tuple for you.
// LogResult(ParseJSON([]byte(`{}`)))
// Unpack first, then pass the value you need.
data, _ := ParseJSON([]byte(`{}`))
LogResult(fmt.Sprintf("%v", data))
}
The compiler complains with multiple-value ParseJSON(...) in single-value context because argument evaluation happens before the function call. Go does not perform implicit tuple unpacking in argument lists. You must assign the values to variables first, then pass the specific one you need.
The third shape is type assertions and map lookups. Both operations return two values by default. A type assertion returns the asserted value and a boolean indicating success. A map lookup returns the value and a boolean indicating presence.
func main() {
var data any = "hello"
// Type assertion returns (value, bool).
// Assigning to one variable triggers the multiple-value error.
// str := data.(string)
// Use the comma-ok idiom to capture both.
str, ok := data.(string)
if !ok {
log.Fatal("type mismatch")
}
fmt.Println(str)
}
If you are absolutely certain the type matches, you can use a single assignment, but only if you omit the second return value entirely by using a type switch or by accepting the panic risk. The safer path is always the two-target assignment. The compiler forces you to confront the possibility of failure.
Never swallow an error with an underscore unless you have a written excuse.
Picking the right assignment pattern
Go gives you a few ways to handle multiple return values. The right choice depends on what you need from the function and how you plan to handle the secondary value.
Use explicit multi-assignment when you need both the primary result and the error or status flag. This is the default pattern for database calls, network requests, and file operations.
Use the blank identifier when you intentionally discard a secondary return value like a boolean presence flag or a secondary metric. Reserve this for cases where the discarded value is genuinely irrelevant to the current scope.
Use a temporary variable when you need to unpack a multi-return function before passing it to another function. Go does not unpack tuples automatically in argument lists, so assign first, then forward.
Use a single return value when your function genuinely only produces one outcome and error handling belongs in a wrapper or a higher-level caller. Redesign the function signature if it is forcing you to ignore values constantly.
The language rewards explicitness. Name your variables, acknowledge your errors, and let the compiler verify the rest.