Can You Reduce Error Handling Boilerplate in Go Code
Reduce Go error handling boilerplate with techniques that work today: sticky errors, step runners, deferred wrapping, cmp.Or and shorter functions.

The previous article told the story of why Go's error checks did not get shorter through syntax (the proposals). This one is the practical sequel: what you can do today, in ordinary Go, to make a function with a dozen fallible steps readable. None of the techniques is new, and each costs something. The skill is in knowing which cost you can afford in which place.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It is part of the error handling guide. Everything ran on Go 1.25.6.
Start by asking whether it is boilerplate
Before reaching for a trick, look at the checks. A return err that passes an error up unchanged is boilerplate. A check that wraps with context, logs, branches on a sentinel or cleans up is code, and removing it removes behavior. The Go team's own post on the subject makes the point that verbosity fades into the background when errors are actually handled. So the first move is to make the checks do something: add a phrase with %w (wrapping), and the same line becomes the place where the message is built. The techniques below are for what remains: runs of calls where nothing useful happens between checks.
Technique 1: the sticky error
When several operations belong to one task and you only care whether all of them worked, store the first failure and turn later calls into no-ops. The standard library does this in bufio.Writer, which remembers an error and returns it from Flush. You can write the same for your own domain:
package main
import (
"fmt"
"io"
"os"
"strings"
)
type reportWriter struct {
w io.Writer
err error
}
func (rw *reportWriter) printf(format string, args ...any) {
if rw.err != nil {
return
}
_, rw.err = fmt.Fprintf(rw.w, format, args...)
}
func writeReport(w io.Writer, name string, rows []string) error {
rw := &reportWriter{w: w}
rw.printf("Report: %s\n", name)
for i, r := range rows {
rw.printf("%2d. %s\n", i+1, r)
}
rw.printf("%d rows\n", len(rows))
return rw.err
}
type failAfter struct{ n int }
func (f *failAfter) Write(p []byte) (int, error) {
if f.n <= 0 {
return 0, io.ErrShortWrite
}
f.n--
return len(p), nil
}
func main() {
fmt.Println(writeReport(os.Stdout, "weekly", []string{"alpha", "beta"}))
fmt.Println(writeReport(&failAfter{n: 2}, "weekly", strings.Fields("a b c d")))
}Report: weekly
1. alpha
2. beta
2 rows
<nil>
short writeThe report makes a handful of calls with a single check, and the second run shows the error from the third write arriving at the end, with every later call skipped. This is the pattern in Rob Pike's Errors are values. It fits output and parsing, where the steps are a pipeline and a failure anywhere means the whole is bad. It does not fit code where step three needs the result of step two, because the no-op returns a zero value and the later steps carry on with garbage.
Technique 2: a list of steps
When the operations are heterogeneous but each is "do this, stop if it fails", run them from a slice:
package main
import (
"errors"
"fmt"
)
type step struct {
name string
run func() error
}
func runSteps(steps []step) error {
for _, s := range steps {
if err := s.run(); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
}
return nil
}
func main() {
var log []string
ok := func(name string) func() error {
return func() error { log = append(log, name); return nil }
}
fail := func() error { return errors.New("connection refused") }
err := runSteps([]step{
{"build", ok("build")},
{"push image", fail},
{"roll out", ok("roll out")},
})
fmt.Println(log, err)
}[build] push image: connection refusedOne check serves all the steps, and the wrap with the step's name lives in the runner, so every failure reads push image: connection refused. This is the structure of a deploy script, a migration runner or a start-up sequence, and the list can be built dynamically. The price is that the steps communicate through captured variables or a shared struct, which is less direct than passing values. Reach for it when the sequence is the point.
Technique 3: one deferred wrap
A function with many exits can add the same context to every error with a deferred closure over a named result:
package main
import (
"fmt"
"os"
"strconv"
)
func wrap(err *error, format string, args ...any) {
if *err != nil {
*err = fmt.Errorf(format+": %w", append(args, *err)...)
}
}
func loadPort(path string) (port int, err error) {
defer wrap(&err, "load port from %s", path)
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
port, err = strconv.Atoi(string(data))
if err != nil {
return 0, err
}
return port, nil
}
func main() {
_, err := loadPort("/nonexistent/port")
fmt.Println(err)
}load port from /nonexistent/port: open /nonexistent/port: no such file or directoryThe defer wrap(&err, ...) line wraps whichever error the function returns, so the individual return 0, err lines stay bare while the message still says what was being attempted. This leans on the named result mechanics from named returns and defer, and its drawback is the one noted there: the wrap happens away from the return. Use it in functions where all exits deserve the same phrase.
Technique 4: combine the independent
When calls do not depend on each other, evaluate them and check once. cmp.Or returns the first non-zero argument, which for errors is the first non-nil one, and errors.Join keeps all of them:
package main
import (
"cmp"
"errors"
"fmt"
"strconv"
)
func main() {
_, e1 := strconv.Atoi("1")
_, e2 := strconv.Atoi("two")
_, e3 := strconv.Atoi("3x")
fmt.Println(cmp.Or(e1, e2, e3))
fmt.Println(errors.Join(e1, e2, e3))
}strconv.Atoi: parsing "two": invalid syntax
strconv.Atoi: parsing "two": invalid syntax
strconv.Atoi: parsing "3x": invalid syntaxcmp.Or reports the first failure, and Join reports all of them, one per line. Both run every call first, which is harmless for parsing and wrong for operations with side effects, where a failed step should prevent the next one. For validation they are ideal (collecting multiple errors).
Technique 5: make functions smaller
The least clever technique is often the best. A function with fifteen fallible calls is doing several jobs, and the boilerplate is the visible symptom. Split it into three functions of five, give each a name that says its job, and the checks between them become a short, meaningful sequence. Each function then returns one error, wrapped once, and the top-level function reads like an outline. This also makes each piece testable on its own, and it is the refactor that works regardless of which other technique you use.
How these look in a real function
Suppose a function reads a config file, opens a database, runs a migration and starts a server. The first three are a sequence of named actions with no data flowing between them except through shared state, which is the list-of-steps shape, and each failure deserves the step's name. Starting the server is the last step and blocks, so it stays a plain call after the runner returns. Written that way, main is a dozen lines that read as an outline, the error messages say which step failed, and there is one check for the sequence and one for the server. Written with plain checks it is the same behavior in thirty lines. Neither is wrong; the shorter one is easier to extend when a fifth step arrives, and the longer one is easier to step through in a debugger. Pick the one your team will maintain, and be consistent within a package.
A good test of whether a technique is paying for itself: delete it and count the lines you would add, then ask whether those lines would have told a reader anything. If they would have said only return err, the technique earned its place. If each of them added context, you were removing information and should keep the checks.
What not to do
Don't use panic as a shortcut. A helper like must(err) that panics on failure and a recover at the top gives you try by another name, and it hides exits exactly as the rejected proposal did. It is acceptable for start-up code (Must functions) and wrong in a library.
Don't generate the checks away. Code generators and macros that expand into error handling make the stack traces and the debugger point at generated lines. A little repetition in source is cheaper than that.
Don't hide it behind generics. A Result[T] type with Map and FlatMap imports a style from other languages that Go's tooling, standard library and every reader's habits do not support (result types covers why it rarely pays).
Choosing
If the steps are a pipeline over one resource, use a sticky error. If they are a sequence of named actions, use a list of steps. If every exit needs the same context, defer a wrap. If the calls are independent, combine them. And if none fits, the plain check is the right answer, and the repetition is the price of code anyone can follow. The reference for the idea behind most of this is the Go blog's Errors are values, and cmp.Or is documented in the cmp package.


