Go Errors: Explicit by Design, Better or Worse
One of the first things people notice about Go is how little it tries to hide errors.
There is no built-in exception-driven flow for ordinary application mistakes. No silent stack unwinding as the default story. No sense that failure should stay out of sight until it absolutely has to surface.
Instead, Go does something much more direct.
Functions return errors as values, and callers deal with them explicitly.
At first, that can feel repetitive. Sometimes it is repetitive. But once code stops being toy-sized, Go’s error model starts revealing what it is actually optimizing for: visible control flow, predictable failure handling, and fewer hidden branches in the middle of ordinary code.
This is not a beginner tutorial on if err != nil.
This is a practical look at how Go errors work in real systems, why the model feels plain on the surface, and which details become much more important once the code starts talking to filesystems, databases, payment providers, queues, webhooks, and external APIs.
Errors in Go are values first
This is the core idea.
In Go, an error is not some special language event floating outside the type system. It is a value. More specifically, it is any value that satisfies the built-in error interface.
That matters because it shapes how code reads.
A function can return useful data and an error side by side:
func loadStoreCurrency(storeID string) (string, error) {
// ...
}
That function signature is already telling you something important: this operation may fail, and failure is part of the contract.
This is one of the reasons Go errors age better than people first assume. The possibility of failure is visible at the call site, visible in the signature, and visible in the control flow. You do not have to guess whether a function might panic, throw, or hide a failure in some side channel.
That visibility is not free. It creates repetition. But it also creates honesty.
The repetition is real, but so is the clarity
This is where people usually split.
Some see repeated if err != nil checks and conclude the model is clumsy. Others see the same pattern and conclude the model is disciplined. Both reactions are understandable.
A realistic example:
func processWebhook(ctx context.Context, body []byte) error {
event, err := parseWebhook(body)
if err != nil {
return err
}
if err := validateWebhook(event); err != nil {
return err
}
if err := storeWebhook(ctx, event); err != nil {
return err
}
return nil
}
There is nothing elegant about this in a decorative sense. But the control flow is very hard to misunderstand.
Each dangerous step is visible. Each failure point is visible. Each early exit is visible.
That is the trade-off. Go gives you less syntactic drama, but it also gives you fewer places for failure to disappear.
Early returns are not just style, they are the shape of Go error handling
A lot of Go error handling makes more sense once you stop treating early returns as a stylistic preference and start treating them as part of the language’s rhythm.
For example:
func validateRefundRequest(amount int, paymentID string) error {
if paymentID == "" {
return errors.New("missing payment id")
}
if amount <= 0 {
return errors.New("amount must be greater than zero")
}
if amount > 200000 {
return errors.New("refund amount exceeds limit")
}
return nil
}
This style works well because it keeps the unhappy paths close to the conditions that cause them. The main path stays flatter. The function is easier to scan. And the reader does not have to mentally unwrap nested blocks just to understand when the code stops.
That does not mean every function should become a wall of guard clauses. But in Go, early returns are usually part of writing error handling that stays readable under pressure.
Not every error deserves a custom type
This is one of the first places people overdo it.
Sometimes a plain error string is enough.
func validateTopUpAmount(amount int) error {
if amount <= 0 {
return errors.New("top-up amount must be greater than zero")
}
return nil
}
That is completely fine.
If the caller only needs to know that something failed, and the message is enough to explain why, errors.New(...) is often the right choice.
The mistake is assuming every error should immediately become a custom type or a full hierarchy. That usually adds more structure than the problem needs.
A good default rule is simple:
- use
errors.New(...)when a simple fixed error is enough - use
fmt.Errorf(...)when you need context - use a custom error type when the caller needs structured information or programmatic branching
That last case matters, but it should be earned.
Context matters more than clever messages
One of the most common mistakes with Go errors is returning an error that is technically true but practically useless.
For example:
return err
Sometimes that is fine. Sometimes it throws away the only context that would have made the failure understandable in logs or production debugging.
A better version is often:
return fmt.Errorf("store webhook event %s: %w", event.ID, err)
Or:
return fmt.Errorf("load inventory for sku %s: %w", sku, err)
That extra context matters because the original error usually knows what failed, but not necessarily where or while doing what.
Good error messages tend to answer one more question than the raw dependency error does.
Not by being verbose. By being located.
Wrapping errors is where Go’s model becomes much more useful
Once you start using fmt.Errorf(... %w ...), Go’s error model gets a lot more interesting.
For example:
func reserveStock(ctx context.Context, sku string, qty int) error {
err := updateInventory(ctx, sku, qty)
if err != nil {
return fmt.Errorf("reserve stock for %s: %w", sku, err)
}
return nil
}
Now the error carries both:
- the higher-level operation context
- the original underlying error
That means callers can still inspect the underlying cause with errors.Is(...) or errors.As(...) if they need to.
This is one of the stronger parts of Go’s error story. You do not have to choose between human-readable context and machine-checkable cause. Wrapping lets you keep both if you do it correctly.
Sentinel errors are useful, but easy to abuse
A sentinel error is usually a package-level error value used as a stable signal:
var ErrInsufficientCredits = errors.New("insufficient credits")
Then:
func deductCredits(current int, cost int) error {
if cost > current {
return ErrInsufficientCredits
}
return nil
}
This is useful when callers need to branch on a meaningful condition:
err := deductCredits(balance, 50)
if errors.Is(err, ErrInsufficientCredits) {
// show top-up UI
}
That is a good use of a sentinel error.
The bad version is turning every possible failure into a package-level sentinel just because comparisons feel convenient. That often leads to brittle APIs and unclear boundaries.
Sentinel errors work best when they represent stable, meaningful categories the caller genuinely needs to recognize.
Not every failure deserves that status.
errors.Is is usually better than ==
This is one of the practical details that matters once wrapping enters the picture.
If you wrap an error:
return fmt.Errorf("charge customer %s: %w", customerID, ErrInsufficientCredits)
then direct comparison with == becomes fragile. errors.Is(...) is the right tool:
if errors.Is(err, ErrInsufficientCredits) {
// handle it
}
That is because errors.Is(...) walks through wrapped errors and checks whether the chain contains the target.
This is one of those small API habits that separates Go code that works right now from Go code that keeps working once context gets added.
errors.As matters when type information matters
Sometimes the caller does not just need to know which category of error happened. It needs structured information.
That is where custom error types become worth it.
A realistic example:
type RateLimitError struct {
Provider string
RetryAfter time.Duration
}
func (e RateLimitError) Error() string {
return fmt.Sprintf("%s rate limit exceeded, retry after %s", e.Provider, e.RetryAfter)
}
Then:
func sendCampaignBatch(ctx context.Context, provider string) error {
return RateLimitError{
Provider: provider,
RetryAfter: 30 * time.Second,
}
}
Callers can inspect it with errors.As(...):
var rateErr RateLimitError
if errors.As(err, &rateErr) {
fmt.Println("retry after:", rateErr.RetryAfter)
}
And in Go 1.26, the standard library added errors.AsType(...), which makes the same pattern smaller and type-safe:
if rateErr, ok := errors.AsType[*RateLimitError](err); ok {
fmt.Println("retry after:", rateErr.RetryAfter)
}
This is not a new error model. It is a cleaner API over the same idea: inspect the wrapped error chain and recover a specific error type when the caller genuinely needs structured information.
That is valuable because now the error is not just a message. It carries structured data the program can actually use.
This is where custom error types make sense.
Not because custom types are fancy. Because the caller needs more than a sentence.
A custom error type should carry information the caller can act on
This is the useful test.
If your custom error type only exists to format a sentence differently, it may not be buying much.
If it carries data that changes behavior, it is usually earning its keep.
For example:
type InvalidTransitionError struct {
OrderID string
From string
To string
}
func (e InvalidTransitionError) Error() string {
return fmt.Sprintf("order %s cannot move from %s to %s", e.OrderID, e.From, e.To)
}
That gives the caller real context.
Another practical example:
type RetryableWebhookError struct {
EventID string
Code int
}
func (e RetryableWebhookError) Error() string {
return fmt.Sprintf("webhook %s failed with status %d", e.EventID, e.Code)
}
Now the retry system can distinguish between this failed and this failed in a way that should be retried.
That is real value.
String matching on errors is almost always the wrong move
This is one of the easiest anti-patterns to slip into.
For example:
if strings.Contains(err.Error(), "not found") {
// ...
}
This usually starts as a shortcut and ends as a brittle dependency on wording.
Error messages are for humans first. If your program needs to make decisions, use:
errors.Is(...)errors.As(...)- stable sentinel errors
- custom error types with useful fields
String matching is usually what happens when the error contract was never made explicit enough.
Logging and returning the same error is often a smell
This is another real-world point that causes a lot of noisy systems.
A function deep in the stack logs an error and returns it. Then the caller logs it and returns it. Then the HTTP layer logs it again. Now one failure produced three identical log entries.
That is rarely helpful.
In many systems, a good default is:
- lower layers add context and return the error
- one boundary layer logs it
- the rest avoid duplicate logging unless they add genuinely different signal
For example, a repository usually should not log every database error if the service or handler above it is already responsible for request-level logging.
Errors are values. Let them travel. Do not turn every stack frame into a megaphone.
defer and named returns can change how errors behave
This is one of the more advanced corners people miss.
Consider:
func processFile(path string) (err error) {
file, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if closeErr := file.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
// work with file
return nil
}
Here the deferred function can see and modify the named return variable err.
This is powerful, but also easy to abuse.
The useful lesson is not always use named returns with defer. It is that defer, named returns, and error flow can interact in ways that are more subtle than they first appear. If you use this style, it should be because it makes the cleanup semantics clearer, not because it looks clever.
errors.Join is useful when multiple failures genuinely matter
In newer Go versions, errors.Join(...) gives you a standard way to combine multiple errors.
That matters in scenarios like cleanup or fan-out work where more than one thing can fail and you do not want to silently discard all but the first.
A realistic example:
func flushAndClose(w io.Writer, c io.Closer) error {
var errs []error
if f, ok := w.(interface{ Flush() error }); ok {
if err := f.Flush(); err != nil {
errs = append(errs, err)
}
}
if err := c.Close(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
This is not something every codebase needs every day. But when multiple failures are genuinely relevant, joining them is much better than overwriting one with another or pretending only the last one mattered.
Context cancellation is an error too, and it should usually stay recognizable
In real systems, you will eventually see errors like:
context.Canceledcontext.DeadlineExceeded
These are important because they often mean something very different from business operation failed.
For example:
if errors.Is(err, context.DeadlineExceeded) {
// timeout handling
}
If your code wraps these too aggressively without preserving the chain, you make it harder for callers to distinguish timeouts and cancellations from ordinary operational failures.
That distinction matters in retries, metrics, dashboards, and user-facing behavior.
Not every error should be handled at the same layer
This is a broader design point, but it matters.
Some errors are best handled close to where they happen:
- validation failures
- missing required input
- local parsing issues
Some errors are better handled at a higher boundary:
- request-level logging
- HTTP status mapping
- retry policy decisions
- user-facing messaging
- metric tagging
Go’s explicit error model tends to make this easier to see because errors are not teleporting through the system invisibly. They are being passed. That gives you more chances to handle them in the right place and more chances to mishandle them too.
Real-world error examples tend to be narrower than people first design them
A few examples that usually make sense:
var ErrDuplicateWebhook = errors.New("duplicate webhook")
var ErrInvalidSignature = errors.New("invalid signature")
type PaymentDeclinedError struct {
Code string
}
func (e PaymentDeclinedError) Error() string {
return fmt.Sprintf("payment declined: %s", e.Code)
}
type RetryablePublishError struct {
Topic string
}
func (e RetryablePublishError) Error() string {
return fmt.Sprintf("publish to topic %s should be retried", e.Topic)
}
These are useful because they either create stable categories or carry structured information that callers can act on.
What they are not doing is building an elaborate taxonomy for its own sake.
So what tends to make Go error handling feel Go-like?
If you zoom out, a few patterns show up repeatedly:
- errors are values
- early returns shape the flow
- plain errors are often enough
- wrapped errors preserve both context and cause
- sentinel errors help when callers need stable categories
- custom error types make sense when structured data matters
errors.Is(...)anderrors.As(...)are more durable than string matching- logging and returning the same error repeatedly usually adds noise
defer, named returns, and cleanup can interact in subtle ways- multiple errors sometimes deserve to stay multiple
That is why Go error handling can feel repetitive at first and much more deliberate later.
The syntax does not try to impress you. The design is trying to keep failure visible.
Final thoughts
Go’s error model is not especially glamorous.
It is often repetitive. It can look blunt. And in weak hands, it can absolutely produce boring code and noisy call paths.
But the model has a real strength: failure is part of the function contract, part of the control flow, and part of the design.
That makes it harder to ignore.
And in real systems, that is often more valuable than elegance that hides too much.