Go errors: to wrap or not to wrap
A lot of the time, the software I write boils down to three phases: parse some input, run it through a state machine, and persist the result. In this kind of code, you spend a lot of time knitting your error path, hoping that it’d be easier to find the root cause during an incident. This raises the following questions:
- When to
fmt.Errorf("doing X: %w", err) - When to use
%vinstead of%w - When to just
return err
There’s no consensus, and the answer changes depending on the kind of application you’re writing. The Go 1.13 blog already covers the mechanics and offers some guidance, but I wanted to collect more evidence of what people are actually doing in the open and share what’s worked for me.
Mutate your locked state inside a closure
When multiple goroutines need to read and write the same value, you need a mutex to make sure
they don’t step on each other. Without one, concurrent writes can corrupt the state - two
goroutines might read the same value, both modify it, and one silently overwrites the other’s
change. The usual approach is to put a sync.Mutex next to the fields it protects:
var (
mu sync.Mutex
counter int
)
mu.Lock()
counter++
mu.Unlock()
This works, but nothing enforces it. The compiler won’t stop you from accessing counter
without holding the lock. Forget to lock in one spot and you have a data race. One way to
make this safer is to bundle the value and its mutex into a small generic wrapper that only
exposes locked access through methods:
What canceled my Go context?
I’ve spent way more hours than I’d like to admit debugging context canceled and
context deadline exceeded errors. These errors usually tell you that a context was
canceled, but not exactly why. In a typical client-server scenario, the reason could be any
of the following:
- The client disconnected
- A parent deadline expired
- The server started shutting down
- Some code somewhere called
cancel()explicitly
Go 1.20 and 1.21 added cause-tracking functions to the context package that fix this, but
there’s a subtlety with WithTimeoutCause that most examples skip.
Structured concurrency & Go
At my workplace, a lot of folks are coming to Go from Python and Kotlin. Both languages have
structured concurrency built into their async runtimes, and people are often surprised that
Go doesn’t. The go statement just launches a goroutine and walks away. There’s no scope
that waits for it, no automatic cancellation if the parent dies, no built-in way to collect
its errors.
This post looks at where the idea of structured concurrency comes from, what it looks like
in Python and Kotlin, and how you get the same behavior in Go using errgroup, WaitGroup,
and context.
Your Go tests probably don't need a mocking library
There are frameworks that generate those kind of fakes, and one of them is called GoMock… they’re fine, but I find that on balance, the handwritten fakes tend to be easier to reason about and clearer to sort of see what’s going on. But I’m not an enterprise Go programmer. Maybe people do need that, so I don’t know, but that’s my advice.
– Andrew Gerrand, Testing Techniques (46:44)
No shade against mocking libraries like gomock or mockery. I use them all the time, both at work and outside. But one thing I’ve noticed is that generating mocks often leads to poorly designed tests and increases onboarding time for a codebase.
Tap compare testing for service migration
Throughout the years, I’ve been part of a few medium- to large-scale system migrations. As in, rewriting old logic in a new language or stack. The goal is usually better scalability, resilience, and maintainability, or more flexibility to adapt to changing requirements. Now, whether rewriting your system is the right move is its own debate.
A common question that shows up during a migration is, “How do we make sure the new system behaves exactly like the old one, minus the icky parts?” Another one is, “How do we build the new system while the old one keeps changing without disrupting the business?”
Splintered failure modes in Go
A man with a watch knows what time it is. A man with two watches is never sure.
Take this example:
func validate(input string) (bool, error) {
// Validation check 1
if input == "" {
return false, nil
}
// Validation check 2
if isCorrupted(input) {
return false, nil
}
// System check
if err := checkUpstream(); err != nil {
return false, err
}
return true, nil
}
This function returns two signals: a boolean to indicate if the string is valid, and an error to explain any problem the function might run into.
Re-exec testing Go subprocesses
When testing Go code that spawns subprocesses, you usually have three options.
Run the real command. It invokes the actual binary that creates the subprocess and asserts against the output. However, that makes tests slow and tied to the environment. You have to make sure the same binary exists and behaves the same everywhere, which is harder than it sounds.
Fake it. Mock the subprocess to keep tests fast and isolated. The problem is that the fake version doesn’t behave like a real process. It won’t fail, write to stderr, or exit with a non-zero code. That makes it hard to trust the result, and over time the mock can drift away from what the real command actually does.
Revisiting interface segregation in Go
Object-oriented (OO) patterns get a lot of flak in the Go community, and often for good reason.
Still, I’ve found that principles like SOLID, despite their OO origin, can be useful guides when thinking about design in Go.
Recently, while chatting with a few colleagues new to Go, I noticed that some of them had spontaneously rediscovered the Interface Segregation Principle (the “I” in SOLID) without even realizing it. The benefits were obvious, but without a shared vocabulary, it was harder to talk about and generalize the idea.
Avoiding collisions in Go context keys
Along with propagating deadlines and cancellation signals, Go’s context package can also
carry request-scoped values across API boundaries and processes.
There are only two public API constructs associated with context values:
func WithValue(parent Context, key, val any) Context
func (c Context) Value(key any) any
WithValue can take any comparable value as both the key and the value. The key defines how
the stored value is identified, and the value can be any data you want to pass through the
call chain.