June 3, 2025·6 min read·engineeringgolanguage-designerrors

Go stops pursuing new error-handling syntax

Years of proposals failed to establish agreement about Go's error-handling syntax. The June decision leaves room for library improvements while ending another round of language proposals.

The hard part of changing Go's error handling has never been finding a shorter spelling. A shorter spelling must also define where a function returns, how the caller adds context, and what another programmer can infer while reading unfamiliar code. Those requirements produced years of designs without producing agreement about which tradeoffs the language should adopt.

On June 3, Robert Griesemer announced that the Go team will stop pursuing syntactic changes for error handling for the foreseeable future. Open and incoming proposals whose primary concern is that syntax will close without further investigation. The announcement recognizes verbosity as a continuing complaint. It also says the team lacks sufficient agreement about the problem and the solution to justify another language change.

I agree with preserving explicit error checks when there is no broadly supported replacement. Go's error values make operational decisions visible, which is one reason I like the language. That preference does not make repeated checks free. It means any replacement has to preserve the reader's ability to find the decision and understand its consequences.

The original design separated detection and handling

Marcel van Lohuizen's 2018 draft proposed check and handle. A checked expression could return successful values while transferring a non-nil error into a handler chain. Handlers used lexical scope, so a reader could determine the relevant handlers from the enclosing function's source.

That approach tried to address more than line count. A handler could attach context once for several operations. Nested scopes could add more context, with handlers running in reverse declaration order until one returned. A default handler in a function ending with an error result could supply ordinary propagation behavior.

The design introduced its own rules. Handlers differed from deferred functions, and their execution did not permit resuming the original function after a failed check. A programmer needed to understand both the check site and the applicable handler chain. Consolidating repetitive code therefore exchanged repeated local statements for another control-flow mechanism.

The draft also discussed errors from deferred calls. A check inside a deferred function literal belongs to that literal, not automatically to the outer function. Cleanup failure cannot simply jump back into an outer error-handling path that has already returned. This is a concrete example of why apparently small syntax proposals reach questions about scope and function completion.

A built-in still changed control flow

The 2019 try proposal removed dedicated handler syntax. It proposed a built-in that accepted a final error result, produced the preceding successful results, and returned from the enclosing function on failure. Existing defer behavior could augment a named error result.

This reduced the number of new constructs, but it created an unusual function-like operation. A call normally returns to its caller. The proposed try could instead return from the function containing it, including when used inside a larger expression. A reader would have to recognize that special behavior wherever the built-in appeared.

The proposal explicitly excluded using try as the called operation in go and defer statements. A new goroutine does not have the enclosing function's result slot. A deferred try would check results at a different time and could overwrite a result selected earlier. These restrictions followed from its control-flow behavior, despite the familiar appearance of a built-in call.

Griesemer's retrospective identifies hidden returns in nested expressions as a major objection. The team abandoned that proposal. Its failure does not establish that every propagation operator is inherently wrong. It demonstrates that fitting an operator into an established language includes expectations about what its existing expression forms mean.

Consensus also has a maintenance cost

The June decision emphasizes that a new idiomatic error form would reach almost every reader of Go. A programmer can often consume a generic library without writing generic declarations. Error handling appears throughout ordinary function bodies, so a successful syntax change would become part of the default reading vocabulary.

Compatibility alone would not avoid that effect. Existing checks could continue compiling while style guidance and new examples favored the new form. Reviewers would encounter both. Authors of documentation and tools would need to explain and support the addition. A source-compatible language change still changes the work required to maintain a shared idiom.

This is a reason to demand agreement about the problem before choosing punctuation. Some complaints concern typing repeated checks. Others concern reading through them or obtaining useful diagnostic context. A construct optimized for character count may have little to say about an error message that fails to identify the operation that failed.

Libraries can change how errors are organized

Rob Pike's earlier Errors are values demonstrates a library-level alternative. A scanner records an error while iteration proceeds through a boolean method, then exposes the stored error after iteration stops. A writer can similarly retain its first failure and make later writes no-ops until the caller inspects the result.

This approach changes the abstraction around a sequence of operations. It does not ask the language to assign a special meaning to every error-returning call. It is appropriate when the abstraction can define what happens after failure and where the accumulated result becomes observable.

It also has limits. Retaining one failure can discard information about partial progress that a caller needs. Deferring inspection is useful only when the caller still performs it and when the abstraction's stopping behavior is correct. Removing repeated statements is not sufficient evidence that two programs have the same failure semantics.

I prefer these changes when they match a real operation, such as scanning a stream. A helper should explain the operation's failure policy through its contract. Introducing a generic wrapper solely to hide every if can make the policy harder to see than the original statements did.

Context remains a deliberate API choice

The Go 1.13 error guidance explains how wrapping can preserve a cause for errors.Is and errors.As. Using %w in fmt.Errorf makes the wrapped error available to inspection. That is an API decision because callers can come to depend on the exposed cause.

func parseLimit(text string) (int, error) {
    n, err := strconv.Atoi(text)
    if err != nil {
        return 0, fmt.Errorf("parse limit %q: %w", text, err)
    }
    return n, nil
}

This fragment requires fmt and strconv. The local check records what the conversion meant to its caller, while wrapping preserves the conversion error. Propagation syntax alone would not decide whether that cause belongs in the function's public contract or which domain context the message should contain.

Ordinary error branches also permit recovery or an alternate operation. Returning immediately is one policy among several. A syntax designed around immediate propagation leaves those other policies to explicit code, which means the language still needs a readable relationship between the abbreviated form and its expanded counterpart.

What remains open

The announcement includes an example that performs two conversions and selects the first error with cmp.Or. The Go 1.24 implementation of that helper returns the first argument unequal to its type's zero value. For error interfaces, that can select a non-nil result after both operations have completed. It is a compact expression of a particular policy, not automatic early propagation.

Evaluation order therefore remains part of the design. Performing both operations before inspecting their errors can be reasonable for independent conversions. It changes behavior when the second operation has side effects or requires the first to succeed. An error-combining helper cannot recover the decision to skip an operation after that operation has already run. This difference gives reviewers a concrete question when considering a shorter formulation. They need to establish whether the original branches controlled execution as well as returned a result.

Stopping syntax work does not prevent changes to APIs that construct or inspect errors. It also does not turn a verbose function into good code by definition. A long sequence of nearly identical branches may reveal an operation that deserves its own abstraction. The abstraction needs to preserve context and partial-result behavior, rather than merely reduce the visible number of checks.

The June announcement leaves the existing mechanism in place and ends another cycle of competing spellings. For maintainers, the substantive work remains deciding what a failure means at each boundary. An explicit branch can attach the relevant context, preserve a cause when that is part of the contract, or choose recovery when returning would be incorrect. Those decisions continue to use ordinary Go statements and error values.