go

Errors are values. Go 1.20 made them a list.

Errors are values. Go 1.20 made them a list.

I keep picking Go up and putting it down. Not because of anything it does badly — because every time I come back, the part that feels foreign is the same part, and I had never sat with it long enough to stop translating it into something else.

Go 1.20 landed on the first of February, and one of its smaller additions is what finally made me stop translating.

The part that feels wrong when you arrive from somewhere else

In C# and TypeScript, failure travels on a second, invisible return path. A function's signature tells you what it produces when it works, and says nothing at all about what it does when it does not. You find that out from documentation, from a stack trace at three in the morning, or never.

Go puts it in the signature:

func loadConfig(path string) (Config, error)

The universal first complaint is the verbosity — if err != nil on every third line. Mine was the same, and I think it is the wrong complaint. The verbosity is a symptom of the actual difference, which is that there is exactly one way out of a function and it is visible at the call site. Nothing unwinds past you while you are not looking.

What I did miss was structure. An exception carries a type you can catch on and an inner exception underneath it. Go's answer arrived in 1.13: wrap with %w, and interrogate the chain.

if err != nil {
    return fmt.Errorf("loading config from %s: %w", path, err)
}

errors.Is walks that chain looking for a particular value, errors.As walks it looking for a particular type. It is a linked list, and the caller can ask questions of any link in it.

What 1.20 added

A chain has one link per level. Plenty of real failures are not shaped like that.

Validating ten fields on a request produces up to ten independent problems and the user would like all of them. Closing three resources in a defer can fail three separate ways. Fanning out to five services and gathering the results gives you up to five failures with no ordering between them.

Until 1.20 you either returned the first failure and discarded the rest, or you wrote a multi-error type. Everyone wrote a multi-error type. Every codebase had a slightly different one, and none of them worked with anybody else's helpers.

1.20 adds errors.Join, and lets fmt.Errorf take more than one %w:

func (c *Client) Close() error {
    return errors.Join(
        c.conn.Close(),
        c.cache.Close(),
        c.metrics.Flush(),
    )
}

Two details make this better than the type I would have written:

Nil arguments are discarded, and if every argument is nil the result is nil. So the accumulate-and-return pattern needs no special casing at the end — the happy path falls out of the same expression.

var errs error
for _, field := range fields {
    errs = errors.Join(errs, validate(field))
}
return errs // nil if nothing failed

And the result still answers the standard questions. errors.Is and errors.As now traverse a tree rather than a list, so a caller checking for one specific condition finds it whether it arrived alone or alongside four others:

if errors.Is(err, ErrNotFound) {
    // true even if err also wraps three unrelated failures
}

The chain became a tree, and every tool that understood the chain understands the tree. That is a small amount of API for a fairly large amount of consolidation.

One thing to know before it surprises you in a log aggregator: the joined error's message is the individual messages separated by newlines, not commas. A single "error" field in structured logging suddenly spans several lines.

The other headline, which I would measure first

1.20 also ships a preview of profile-guided optimisation. You collect a CPU profile from a real workload, hand it back to the compiler at build time, and it uses the profile to make application-specific decisions — chiefly about what to inline.

The figure in the announcement is around three to four per cent on typical applications, and the release notes are explicit that this is a preview with rough edges that may rule out production use.

I have written before about the difference between a published percentage and a number you can put in a capacity plan, and this is the same situation. Three to four per cent is a real improvement and also well inside the range where an unrepresentative benchmark will tell you anything you want to hear. If I wanted it, I would build both ways, run the same load against both, and keep tail latency as well as the mean — and I would not build a plan on a preview.

The rest of the release is quietly useful: conversion from a slice to an array without the pointer dance, build speeds back in line with 1.17 after a couple of releases of drift, and coverage collection extended to whole binaries so integration tests can contribute to a coverage number instead of being invisible to it.

Why I keep coming back

Go's release cadence is six months and boring, and that is a feature I have come to appreciate more the longer I work. Nothing in 1.20 asks me to restructure anything. errors.Join is additive, PGO is opt-in, and code written against 1.19 keeps compiling.

I do not write Go professionally. This is still a language I use for weekend things and read more of than I write. But the reason I keep returning is that the thing it is strict about — the fact that a function can fail, stated in its signature, handled at the call site — is the thing that actually breaks in the systems I work on. Making that shape better, even slightly, is worth more to me than most syntax.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev