Skip to content
gohacks.dev
Basics

The try Proposal and Why Go Kept Explicit Error Checks

The history of Go error handling proposals, from check and handle to try and the question mark, why each failed, and what the Go team decided in 2025.

8 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

If you have written Go for a month you have typed if err != nil { return err } more times than you can count, and you have probably wondered why a language designed by experienced people has not fixed it. It has not been for lack of trying. The Go team has made three serious proposals over seven years, argued about hundreds of community ideas, and in 2025 decided to stop. The story explains the shape of the language better than any style guide.

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

This article tells that history from the primary sources, shows what each proposal looked like, and ends with what you can do today. It continues reducing error handling boilerplate, and it belongs to the error handling guide. Everything ran on Go 1.25.6, and the accounts of the proposals come from the Go team's own writing, linked at the end.

The code everyone is talking about

The complaint is about code like this, which the Go blog itself uses as the example:

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func printSum(a, b string) error {
	x, err := strconv.Atoi(a)
	if err != nil {
		return err
	}
	y, err := strconv.Atoi(b)
	if err != nil {
		return err
	}
	fmt.Println("result:", x+y)
	return nil
}

func main() {
	fmt.Println(printSum("20", "22"))
	fmt.Println(printSum("20", "x"))
}
Text
result: 42
<nil>
strconv.Atoi: parsing "x": invalid syntax

Of the ten lines in the body of printSum, four do real work. The other six are checks that pass the error up unchanged. In a function that makes a dozen calls, the ratio is worse, and the logic gets lost between the checks. Complaints about this have headed the annual Go developer survey for years, apart from a period when the lack of generics took first place.

2018: check and handle

The first formal attempt came as part of the Go 2 effort. Russ Cox described the problem, and a draft design by Marcel van Lohuizen proposed two new keywords. A handle block declared what to do with an error, and check in front of a call returned from the function through that handler on failure. Sketched in text, since it never became real Go:

Text
func printSum(a, b string) error {
	handle err { return err }
	x := check strconv.Atoi(a)
	y := check strconv.Atoi(b)
	fmt.Println("result:", x+y)
	return nil
}

The draft was thorough, including a survey of how other languages handle errors, and the team judged it too complicated. Nesting handlers, interactions with defer and the question of what a handler's scope covered made it a large change for a modest gain.

2019: the try built-in

Almost a year later the team tried again with something much simpler. The check keyword became a built-in function try, and the handle part was dropped, leaving defer to add context:

Text
func printSum(a, b string) error {
	x := try(strconv.Atoi(a))
	y := try(strconv.Atoi(b))
	fmt.Println("result:", x+y)
	return nil
}

The proposal drew close to nine hundred comments, and the central objection was consistent. try returns from the enclosing function when its argument fails, and because it is an expression it can sit inside another call, deep in a nested expression. Control flow that exits a function was now invisible in the middle of a line. Go's whole style is that you can read down the left margin and see where a function can return: the return statements and the if err != nil checks. A try buried in f(try(g()), try(h())) broke that property. User sentiment was strongly negative, and the team withdrew it. In hindsight, the Go blog notes, a new keyword and a restriction to statements might have softened some objections, and the process failed too: a nearly finished design with an implementation timeline left no room for feedback.

2024: the question mark

After several years and a steady stream of community ideas, Ian Lance Taylor proposed borrowing Rust's ? operator. In the blog's rendering of the proposed syntax:

Text
func printSum(a, b string) error {
	x := strconv.Atoi(a) ?
	y := strconv.Atoi(b) ?
	fmt.Println("result:", x+y)
	return nil
}

This had real advantages. It is a statement-level construct, so it cannot hide in an expression, and the mark at the end of the line is visible. In small informal studies most programmers shown the code guessed its meaning correctly. The team wrote a converter and prototyped the change in the compiler. And then the discussion went the way of its predecessors: the issue filled with variations and personal preferences, the proposal was closed and moved to a discussion, and a slightly modified version received a warmer reception without broad support.

June 2025: stop

On 3 June 2025 Robert Griesemer published "[ On | No ] syntactic support for error handling" on the Go blog. After three full proposals by the team and hundreds from the community, none of which attracted enough support, the question was whether to continue. The answer was no: the team will stop trying to solve the syntactic problem for the foreseeable future. The reasons it gives are worth reading in full, and in summary they are these.

  • No consensus, and the process says what that means. Proposals that cannot reach general agreement are declined, and even senior members of the Go team did not agree on the best path.
  • The moment has passed. Had sugar been added early, few would argue about it now. Fifteen years in, Go has "a perfectly fine way" to handle errors even if it is verbose, and a new construct would swap one unhappy group of users for another.
  • Everyone would have to adopt it. Generics were optional for most users; a new error construct would not be, because code that ignored it would become unidiomatic. That conflicts with the design rule of not offering several ways to do the same thing.
  • Verbosity fades when errors are actually handled. Good handling adds information, and the post argues that the boilerplate matters less once each check does some work.
  • Debugging favours the plain if. A dedicated line is easy to put a breakpoint or a print on, and logic hidden behind check, try or ? has to be rewritten first.
  • Language changes are expensive, and the team is small with other priorities.

Notice what that is and is not. It is not a claim that the verbosity is fine; the post calls it real. It is a judgment that no proposed cure beat the disease by enough to justify changing a language that promises compatibility.

What you can do today

The boilerplate is manageable with ordinary techniques, none of which need the language to change.

Wrap once, at the right level. A bare return err is the six-line noise the blog complains about, and it is often right. Where context helps, make the line do work: return fmt.Errorf("parse left operand: %w", err). The check then earns its place (wrapping with %w).

Hold the error in a struct. For a sequence of related operations, the pattern from the Go blog's Errors are values stores the first error and turns later calls into no-ops:

main.goGo
package main

import (
	"fmt"
	"strconv"
)

type parser struct {
	err error
}

func (p *parser) atoi(s string) int {
	if p.err != nil {
		return 0
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		p.err = fmt.Errorf("parse %q: %w", s, err)
	}
	return n
}

func sum(a, b string) (int, error) {
	var p parser
	x, y := p.atoi(a), p.atoi(b)
	return x + y, p.err
}

func main() {
	fmt.Println(sum("20", "22"))
	fmt.Println(sum("20", "x"))
}
Text
42 <nil>
20 parse "x": strconv.Atoi: parsing "x": invalid syntax

One check at the end replaces one per call. The cost is a helper type and the fact that errors surface late, which suits parsing and writing, where the steps are a pipeline, and does not suit code whose later calls depend on earlier results. The standard library's bufio.Scanner and bufio.Writer work this way. Reducing boilerplate covers more of these.

Combine independent errors. The blog's own suggestion for the case where several calls are independent is cmp.Or, which returns the first non-zero value, so the first non-nil error:

main.goGo
package main

import (
	"cmp"
	"fmt"
	"strconv"
)

func printSum(a, b string) error {
	x, err1 := strconv.Atoi(a)
	y, err2 := strconv.Atoi(b)
	if err := cmp.Or(err1, err2); err != nil {
		return err
	}
	fmt.Println("result:", x+y)
	return nil
}

func main() {
	fmt.Println(printSum("20", "22"))
	fmt.Println(printSum("20", "x"))
}
Text
result: 42
<nil>
strconv.Atoi: parsing "x": invalid syntax

It suits calls with no dependency between them, and it still evaluates every call, so use it where that is harmless.

Let the tools carry the weight. Editors insert the check with a keystroke or a snippet, and many fold it on screen. A linter such as errcheck ensures none is skipped (errcheck).

Shorten functions. A function with a dozen fallible calls is usually doing more than one thing. Splitting it reduces the boilerplate per function and improves the names.

What the history teaches

Three lessons come out of the seven years. A change to error handling is a change to the language's central readability property, which is why every proposal that obscured control flow lost. Large language changes need a consensus that did not form, and a minority objection in a community this size is thousands of people. And the explicit check is not an accident of immaturity: it is the design, and the team chose to keep it with open eyes.

If you want the primary sources, start with the blog post, then follow its links to the original drafts. The Go blog post of June 2025 is the best single summary, the 2019 proposal is on the Go issue tracker, and Rob Pike's Errors are values is the earlier statement of the philosophy behind it.

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.