Go EP8: Handle errors while using defer to prevent silent failures
There's a subtle trap that many fall into: forgetting to check for errors in deferred calls.
If you haven’t read the last week, then here is the recap:
Take 1: Goroutines Are Stackful
Take 2: Lead with context, end with options, and always close with an error
Take 3: Prefer strconv over fmt for converting to/from strings.
Take 4: Naming unexported global variables with an underscore (_) prefix.
This week’s topics:
Take 1: Using unexported empty struct as context key.
Take 2: Make your errors clear with
fmt.Errorf
, don’t just leave them bare.Take 3: Avoid
defer
in loops, or your memory might blow up.Take 4: Handle errors while using defer to prevent silent failures.
Take 1: Using unexported empty struct as context key
Besides cancellation signals and deadlines, the context
package is commonly used for passing request-scoped values.
We can add a value to the context, pass it down, and then retrieve it:
The challenge is, how do we ensure our key (“data” in this case) is unique?
It’s entirely possible that someone else has already used “data” as a key, so there may be a potential conflict. This is where the empty struct comes in handy, each struct is unique compared to other structs:
In general, using an unexported (private) empty struct, we can avoid any potential conflicts arising from other packages.
> “Can I use other types, but the underlying type is still a string or int?”
Yes, we can definitely use another type, and it should avoid conflict.
For example, a number(0) which has an underlying type of int and int(0) are different:
How this works boils down to how Go compares interface{}, 2 interface{} are only equal if both their type and value match.
The first value is: { type: number, value: 0 }
The second value is: { type: int, value: 0 }
They are different types, so they are not equal.
“But why use an empty struct{}?”
An empty struct doesn’t allocate memory; it has no fields and therefore no data, but its type can still uniquely identify context values.
Of course, there are still cases where we use type definitions that have an underlying primitive type.
Using context values is something I always avoid, especially when writing business logic. It’s not compile-time-safe and is difficult for tracking and debugging.
Take 2: Make your errors clear with fmt.Errorf, don’t just leave them bare.
In Go, errors are treated as values and we return errors instead of throwing them:
Simply returning errors without any extra details can make it hard to figure out where they came from and why they happened.
This can make fixing bugs and handling errors tougher.
Using fmt.Errorf with %w
Go 1.13 brought us a way to add more information to errors while keeping the original error. This is done by using fmt.Errorf
with the %w
verb. It wraps an error so you can look into it more if you need to later:
“I still don’t see the benefit, it’s just an error either way.”
Let’s look at an example to see why adding details is important:
Which one below tells you more?
“Failed to retrieve resource: authorization check failed: user 123 does not exist: mongo: no documents in result”
“Failed to retrieve resource: mongo: no documents in result”
The first one clearly shows that the problem starts with a user that doesn’t exist, leading to failure.
The second message doesn’t help us understand if the problem is with the user or the resource.
Without these details, we might miss important clues about what went wrong. Plus, we can use errors.Is()
to pinpoint the exact type of error:
Take 3: Avoid defer in loops, or your memory might blow up.
When we use defer in Go, we’re scheduling a function to run later, just before the current function returns.
However, placing defer within a loop like this isn’t advisable:
(For simplicity, let’s not consider the error handling of f.Close).
Here are two key points to consider:
1. Execution Timing
All these deferred calls execute only when the function is about to return, not after each iteration of the loop.
If your loop is part of a long-running function, this means none of your deferred tasks will be executed until much later.
2. Potential for Memory Blow-up
This is especially problematic when those deferred tasks are intended for releasing resources or cleanup.
Instead of freeing up resources as soon as we’re done with them, we end up waiting until the very end.
Each defer adds an entry to the memory and in a loop that iterates hundreds or thousands of times, we accumulate a stack full of deferred calls, each consuming memory.
The details of each deferred call (such as the function to call and its arguments) need to be stored somewhere. This storage is allocated in the function’s stack frame (or on the heap, based on the compiler’s strategy)
There are several strategies to mitigate the impact. If looking for a “lazy” fix, consider using an anonymous function:
We could separate this functionality into a named function or perhaps cautiously choose not to use defer (bearing in mind that defer is still called if panic occurs, so not using defer might not be your solution).
Take 4: Handle errors while using defer to prevent silent failures
There’s a subtle trap that many fall into: forgetting to check for errors in deferred calls.
Let’s use the snippet above as an example.
If the file closing operation fails (perhaps due to an un-flushed write operation or an issue with the file system) and this error is not checked, we lose the opportunity to handle the failure gracefully.
Now, still using defer, we have 3 options:
Handle this as a function error
Panic
Logging
Panic or logging are straightforward, but how to handle this as a function error?
In this case, the solution might be easy with named returns:
Or, maybe shorter:
However, this approach remains somewhat verbose due to the need to create an anonymous function, which increases nesting.
Considering that most deferred calls involve closing resources, such as connections or I/O operations, we can simplify this with a more concise, one-liner solution with io.Closer:
But this will cause a panic because of dereferencing err, while err can be nil, right?
Actually, not. This code works well.
(long story alert)
Fortunately, since error is an interface, a nil error doesn’t mean it’s a nil pointer in the same sense as a nil pointer for other pointer types (like *int).
A nil (interface) error has {type = nil; value = nil}, but it’s still… a value, the zero value of the interface.
When we take the address of err using &err in the defer closeWithError(&err, file)
call, we’re not getting a nil pointer.
We’re getting a pointer to an interface variable that is {type = nil, value = nil}. So in the closeWithError function, when we dereference the error pointer with *err to assign a new value, we’re not dereferencing a nil pointer (which would cause a panic).
Instead, we’re modifying the value of an interface variable through its pointer.