1) Introduction
Error handling is a core part of writing reliable software, and Go approaches it very differently from many other popular programming languages. Instead of using exceptions that automatically interrupt the flow of a program, Go treats errors as ordinary values that functions return alongside their results. This design forces developers to handle potential failures explicitly and close to where they occur, making program behavior easier to understand and reason about. While this approach can feel more verbose at first, it encourages clarity, predictability, and better control over how errors are handled, especially in large or concurrent systems. Understanding Go’s philosophy around errors is essential to writing idiomatic, maintainable Go code.
In Go, handling an error means making an explicit decision about what the program should do when something goes wrong. A key principle is that errors should be handled at the level that has enough context to make a correct decision.
When a function returns (value, err), the first responsibility of the caller is to check the error and then choose one of several possible actions:
- Return the error to the caller (possibly wrapping it with more context)
- Handle it locally (for example, retry, substitute a default value, or ignore it intentionally)
- Log it if the failure is non-critical and the program can continue safely
- Convert it into another domain-specific error (e.g., HTTP status errors)
Choosing to log an error is still handling it, as long as the decision is intentional and documented.
Table of Contents
- 1) Introduction
- 2) Returning Errors Vs Using Exceptions
- 3) panic
- 4) panic, defer, recover
- 5) Error Wrapping
-
6)
errors.Isvserrors.As - 7) Sentinel Errors
- 8) Typed Errors
- 9) Error Handling Good Practices
- 10) Context Error Handling: Cancellation Is Not a Failure
- 11)
errors.Join - 12) Error Handling Anti-pattern
- 13) Conclusion
2) Returning Errors Vs Using Exceptions
Go prefers returning errors instead of using exceptions because it makes control flow explicit, predictable, and local. This has several important advantages over exceptions, especially in large systems.
First, explicit control flow.
With exceptions, execution can jump non-locally to a handler far away in the call stack. This makes it harder to reason about which code runs and which does not. In Go, you can look at a function and immediately see all possible error paths because they are written directly in the code.
Second, errors are part of the function’s contract.
The function signature tells you that something can fail. This forces callers to acknowledge failure cases and prevents accidentally ignoring errors, which is easy to do with exceptions.
Third, simpler reasoning and testing.
Because errors are values, they can be compared, wrapped, logged, returned, or replaced like any other value. This makes unit testing easier and avoids hidden control flow.
Fourth, better composability in large codebases.
In big systems, many layers call each other. Go’s error-returning style allows each layer to add context, translate errors into domain-specific meanings, or decide to handle or ignore them. This avoids “exception tunneling,” where low-level failures unexpectedly crash high-level logic.
Finally, panic is reserved for programmer errors, not normal failures.
Go still has panic, but it is intended for unrecoverable bugs (nil pointer dereference, violated invariants), not for routine error handling like I/O failures or invalid input.
3) panic
We already touched upon panic briefly, but this paragraph will elaborate on panic a bit broader.
A panic is a way for a program to immediately stop normal execution when it reaches a state it cannot safely continue from.
More specifically, when a panic occurs, Go unwinds the call stack, running any deferred functions along the way, and then crashes the program if the panic is not recovered. Panics are meant to signal programmer errors or unrecoverable conditions, such as accessing an invalid index, dereferencing a nil pointer, or violating assumptions that should never happen in correct code. In other words, panic is appropriate when the program cannot maintain its invariants or guarantee correctness.
A common valid use case is during the startup phase of an application. If a program fails to initialize a critical dependency such as loading configuration, binding to a required port, or establishing a mandatory database connection - panicking is acceptable. It is acceptable because the program cannot fulfill its purpose without that dependency, or there is no meaningful recovery strategy, or simply because continuing execution would put the program in an invalid state.
Example:
db, err := sql.Open("postgres", dsn)
if err != nil {
panic(err)
}
In this case, panicking causes the program to fail fast and loudly, which is desirable during initialization.
Unlike regular errors, which are expected and returned as values to be handled explicitly, panics are exceptional and should be used sparingly. Idiomatic Go code treats panics as a last resort, not as a general error-handling mechanism.
4) panic, defer, recover
Since this article focuses on interview questions related to Go internals, it’s important to cover panic, defer, and recover, and how they work together. Many of you may already be familiar with defer and recover individually, but the way these mechanisms interact is often less well understood. In this section, we’ll walk through each concept step by step and explore how they relate to one another in practice.
As we already said panic represents an **unrecoverable situation where the program’s invariants are broken or it cannot continue safely, therefore panic immediately stops normal execution. What you may not know is that **panic begins stack unwinding, executing deferred functions along the way. This brings us to defer mechanism.
4.1. defer
defer schedules a function call to run after the surrounding function returns, whether that return happens normally or due to a panic.
Deferred functions are guaranteed to execute during panic unwinding and always run in LIFO (last in, first out) order.
In other words, when multiple functions are deferred within a parent function, the last deferred function runs first after the parent function returns.
defer is essential for releasing resources, logging or cleanup.
Example:
func handler() {
defer runLogging() // runs last after panic
defer runReleaseResources() // runs second after panic
defer runCleanup() // runs first after panic
doWork() // may panic
}
4.2. recover
recover allows a program to regain control after a panic, but only under very specific conditions.
recoveronly works inside a deferred function because panic unwinds the stack, and deferred functions are the only code guaranteed to run during that unwinding process.
It stops the panic and returns the panic value, but If used incorrectly, it does nothing.
Example:
defer func() {
if r :=recover(); r !=nil {
log.Println("recovered:", r)
}
}()
Using recover deep inside business logic to “keep the program running” often hides bugs and makes systems unreliable, therefore recover should be used only at well-defined boundaries (e.g. goroutine entry points, HTTP handlers) to prevent a panic from crashing the entire process.
4.3. How it works together
Knowing what each mechanism is and what it is for, we can clearly paint a picture of how they work together. A typical scenario would be the following:
- A panic occurs
- The stack begins unwinding
- Deferred functions are executed
- A deferred function may call
recover - If recovered, the panic stops and normal execution resumes
4.4. Trick question
A trick question you may encounter in an interview is: “Should libraries ever call panic? If so, in what situations?” You may think that this is a stupid question, but since I’ve actually been asked this question before, it’s worth addressing for those who may not be completely sure what the correct answer is.
The answer is a giant NO.
Libraries should never call
panicfor normal or expected errors, because a library should not control how it is used and should not be able to crash the caller’s program.
Libraries should rather return errors so that YOU who has the most context can decide how to handle failures. The only case library may panic is when the caller violates the library’s documented invariants.
5) Error Wrapping
To fully understand an importance of error wrapping we need to see what problem it solves.
You see, Go wasn’t always super cool and great language as it is today. It had some big flaws. Don’t get me wrong, Go is not perfect language, no language is perfect and there will always be people who prefer one language over the another. What makes Go unique, in my opinion, is its strong community and a dedicated core team that actively listens to developers and works hard to evolve the language in ways that addresses real-world needs.
Anyways, I got a bit distracted by my love for Go. The point is that earlier versions of the Go (prior 1.13.) had some notable shortcomings when it came to error handling, and we’ll take a closer look at them shortly.
Originally, errors in Go were just values:
return errors.New("file not found")
Now imagine this call stack:
readConfig → loadFile → os.Open
If os.Open fails, what error do you want at the top?
Do you want low-level info (what actually failed) or high-level context (what the program was trying to do) ?
Before Go 1.13, you had two options.
Option one is to lose context by simply returning the err value.
if err != nil {
return err
}
In that case, the caller only sees a message like “no such file or directory”, which often raises more questions than it answers: Which file? During what operation did this happen?
Option two is to lose identity by creating your formatted error which is actually just a text.
if err != nil {
return fmt.Errorf("failed to load config: %v", err)
}
This shortcoming made programmatic handling impossible and this is exactly with nowadays modern error wrapping comes in.
Starting from version 1.13, wrapping an error lets you add context to an error value while preserving the original error. This is done using %w, and here is an example:
return fmt.Errorf("load config: %w", err)
Now the error prints with context AND still contains the original error inside it.
Think of this as a linked list of errors.
Here is a concrete example:
err := os.ErrNotExist
err = fmt.Errorf("open config: %w", err)
err = fmt.Errorf("startup failed: %w", err)
Printing err gives “startup failed: open config: file does not exist”, but programmatically, the original error is still there. Now higher layers can inspect error type by doing the following:
if errors.Is(err, os.ErrNotExist) {
// handle missing file
}
Even though the error has been wrapped multiple times.
I hope you now have a clear understanding of the importance of error wrapping and why you should use it if you haven’t already. To summarize this in a strong interview-style answer:
Error wrapping allows Go code to add contextual information to errors while preserving the original error value, enabling both human-readable messages and reliable programmatic handling.
6) errors.Is vs errors.As
Before we explain what each method does and what are differences, I will quickly explain what sentinel error is.
A sentinel value is a predefined, well-known value that has special meaning. Basically, a sentinel value is a special value used to signal that something specific has happened.
Examples outside Go:
-
1meaning “not found” -
nilmeaning “no value” -
EOFmeaning “end of file”
These values are not errors by themselves, they are signals.
A sentinel error is a package-level error value that callers are expected to compare against.
Example:
var ErrNotFound = errors.New("not found")
This error acts as a marker: “This specific kind of failure occurred”. And exactly these kinds of markers we check with errors.Is.
6.1. errors.Is
errors.Isis used to check whether an error is equal to a specific sentinel error, even if that error has been wrapped multiple times.
It answers the question: “Does this error represent this kind of failure?”
Example:
if errors.Is(err, os.ErrNotExist) {
// file does not exist
}
This works even if os.ErrNotExist is deeply wrapped with %w.
Use errors.Is when you care about what happened by checking against a known sentinel error.
6.1.1. Error comparison pitfall
A trick question you may encounter is “Is it OK to compare errors directly with == in Go?”
The question has a simple answer, which is “it depends”, but the answer to the follow-up question “why?” requires a much deeper explanation.
In general, you should not compare errors using == in Go, because errors may be wrapped. However, direct comparison can be acceptable for known sentinel errors that are guaranteed not to be wrapped. The key is understanding why this distinction exists.
Most errors in modern Go code are wrapped using %w, propagated across layers, and sometimes even joined using errors.Join. When an error is wrapped, it is no longer equal to its original value, even though it still represents the same underlying condition.
base := sql.ErrNoRows
err := fmt.Errorf("query failed: %w", base)
err == sql.ErrNoRows// ❌ false
errors.Is(err, sql.ErrNoRows)// ✅ true
A direct comparison with == fails here because err is a different value. It just contains sql.ErrNoRows. If you rely on ==, you will miss errors that are semantically the same but wrapped for context. This is exactly why many developers instinctively avoid direct comparison.
This is where errors.Is comes in. It walks the error chain, checks wrapped and joined errors, and respects the Unwrap method when present. Instead of asking “are these two error values identical?”, it answers the more important semantic question: “does this error represent this condition?”. In almost all real-world cases, that is what you actually want to know.
That said, there is an important expert-level nuance. Using == is acceptable when all of the following are true: the error is a sentinel, you control its source, you know it will not be wrapped, and the comparison happens at the same layer of the codebase.
var errClosed = errors.New("closed")
func doThing() error {
return errClosed
}
func caller() {
if err := doThing(); err == errClosed {
// OK
}
}
In this example, the sentinel error is private, unwrapped, and used entirely within the same package. There is no wrapping, no boundary crossing, and no ambiguity, so the comparison is safe.
However, this situation is rare in real systems. As soon as errors cross package boundaries, are wrapped with context, or are returned upward through layers, direct comparison becomes fragile. For that reason, library code and application boundaries should always rely on errors.Is.
A simple rule you can memorize, and safely use in interviews, is following:
Use
errors.Isby default, and use==only for private sentinel errors in tightly controlled scopes.
6.2. errors.As
errors.Asis used to extract a specific error type from an error chain.
It answers the question: “Is there an error of this type somewhere in the chain, and can I get it?”
Example:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("path:", pathErr.Path)
}
Use errors.As when you care about extra fields or behavior or you need structured information. Note that his works only with custom or typed errors.
6.3. Method comparison wrap up
Now that we have analyzed both errors methods, we can safely conclude that errors.Is is used for data comparison while errors.As or data extraction. In plain simple words:
Use
Isfor what happened, useAsfor what data it carries.
7) Sentinel Errors
Yeah, I know I already talked about this in the previous section, but did you know we can go even deeper when it comes to sentinel errors? We might want to do that because sentinel errors is a topic that can easily come up in interviews. I know this for a fact, since they were a topic in one of mine. Since we already defined what sentinel errors are (check previous section), let’s jump straight into why we would want to use them and why we would not.
7.1 Advantages of sentinel errors
These are some of the key advantages of using sentinel errors. They are explicit and simple, allowing callers to clearly check for a known condition and branch their logic accordingly. Sentinel errors are also lightweight, since they require no custom error types or additional allocations. This makes them especially suitable for stable, well-defined conditions such as io.EOF, sql.ErrNoRows, or context.Canceled, where the meaning of the error is unlikely to change over time.
7.2 Disadvantages of sentinel errors
A major downside of sentinel errors is tight coupling between packages. When a caller checks for a specific sentinel error, it creates a hard dependency on the callee’s internal error definitions:
if err == mypkg.ErrNotFound {
// handle missing resource
}
At this point, the caller knows about the internal error details of mypkg. Changing or removing ErrNotFound now becomes a breaking change, even if the underlying behavior hasn’t changed.
Another issue is poor extensibility. Sentinel errors cannot carry structured data, which becomes limiting as requirements evolve. For example, imagine you later want to know which field failed or which ID was missing:
return ErrInvalidInput
There’s no way to attach additional context like a field name, an identifier, or retry hints. You’re stuck introducing more sentinel errors or redesigning the API entirely, both of which scale poorly over time.
Sentinel errors also tend to encourage a branching explosion as APIs grow. Error handling logic can quickly turn into a long chain of conditionals:
switch {
case errors.Is(err, ErrA):
handleA()
case errors.Is(err, ErrB):
handleB()
case errors.Is(err, ErrC):
handleC()
}
When this starts happening, it’s usually a sign that the error model is becoming brittle and that error values are being stretched beyond what they were meant to represent.
Finally, sentinel errors are easy to misuse as control flow. They are sometimes abused as loop breakers or alternative return paths:
for {
err := doWork()
if err == ErrStop {
break
}
}
While this may work, it leads to unclear logic and hidden behavior. Errors are no longer signaling failure, but they are silently steering execution, which makes the code harder to reason about and maintain.
7.3. When to use and when not to
Sentinel errors are a good fit when the condition they represent is stable and fundamental to the operation. They work best in situations where the caller must explicitly react differently to a specific outcome and where no additional data is needed to understand or handle the error.
On the other hand, sentinel errors should be avoided when more information is required to handle the failure correctly. If errors are part of a growing or evolving domain, or if you expect the error model to expand in the future, sentinel values quickly become limiting. They are also a poor choice when errors cross multiple abstraction layers, as they tend to leak implementation details and create tight coupling. In these cases, typed errors provide a more flexible and scalable approach.
8) Typed Errors
A typed error in Go is an error implemented as a custom type (usually a struct) that satisfies the error interface. Instead of representing an error as a single shared value, typed errors allow each error occurrence to be its own value while still sharing the same type.
type NotFoundErrorstruct {
Entity string
ID string
}
func(e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s not found", e.Entity, e.ID)
}
Each time this error is created, it’s a new value, but callers can still recognize it based on its type. To detect typed errors, callers use errors.As, which allows them to extract the error and access its structured fields:
var nf *NotFoundError
if errors.As(err, &nf) {
// nf.Entity and nf.ID are available here
}
This is a key distinction from sentinel errors and one of the main reasons typed errors exist.
8.1. How typed errors differ from sentinel errors
Sentinel errors are defined as single, shared values:
var ErrNotFound = errors.New("not found")
They act as signals. There is exactly one instance, they carry no structured data, and callers typically detect them using errors.Is. Typed errors, on the other hand, can have many instances, carry rich contextual information, and are detected using errors.As.
Sentinel errors answer “what happened?” while typed errors answer *“what happened, and to what?”*
8.2. When to choose typed errors over sentinel errors
Typed errors are the better choice when callers need structured information to react properly. If consumers of your API need to know details such as which entity failed, which field or ID caused the problem, or whether an operation is retryable, sentinel errors simply can’t provide that information.
They are also a better fit when the error represents a domain concept rather than a low-level condition. Errors like OrderNotCancelableError, InsufficientBalanceError, or QuotaExceededError are part of a business model and naturally belong as types, not as global sentinel values.
Another important factor is scalability. As an error surface grows, sentinel errors tend to multiply:
ErrUserNotFound
ErrOrderNotFound
ErrInvoiceNotFound
Typed errors scale much better here. One error type can cover many related cases without exploding the API.
8.3. Combining typed errors with sentinel categories (best practice)
A very strong and commonly recommended pattern is to combine typed errors with sentinel errors. You define a broad sentinel error that represents a category, and then wrap it inside a typed error that carries details:
var ErrNotFound = errors.New("not found")
type NotFoundErrorstruct {
Entity string
ID string
}
func(e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s not found", e.Entity, e.ID)
}
func(e *NotFoundError) Unwrap() error {
return ErrNotFound
}
This gives callers the best of both worlds. They can broadly handle “not found” cases using errors.Is, or extract structured details using errors.As, depending on how much they care about the specifics.
8.4. When sentinel errors are still the better choice
Sentinel errors are still the right tool when the condition is simple, stable, and unlikely to evolve. If no extra data is needed and callers only need a yes-or-no branch in their logic, sentinels are often the cleanest solution. Common examples include io.EOF, context.Canceled, and sql.ErrNoRows.
9) Error Handling Good Practices
For good error handling, it is important to know when is the right time to handle an error, when to wrap it, and when to propagate it.
In a well-structured Go application, error handling usually follows a simple rule:
Errors should be wrapped and propagated at lower levels, and handled at higher levels where the program has enough context to decide what should actually happen.
At lower levels of the codebase, such as helpers, database access, filesystem operations, or network calls, errors generally have no business meaning yet. These layers don’t know whether a failure should result in a retry, a user-facing message, or a fatal exit. Their responsibility is to return the error and, when useful, wrap it with contextual information that explains what operation failed.
u, err := repo.GetUser(ctx, id)
if err !=nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
This kind of wrapping preserves the original error while adding breadcrumb-like context that becomes invaluable during debugging.
In the middle layers of an application, such as the service or domain layer, errors start to gain meaning. This is often where infrastructure-level errors are translated into domain-level ones. For example, a database-specific “record not found” error might be mapped to a domain error like ErrUserNotFound. This layer may also decide retry or compensation behavior if that logic is part of the business rules.
At the highest levels like HTTP handlers, CLI commands, background jobs, or goroutine entry points, errors are typically handled for real. This is where the application decides how the error should be presented or acted upon. Highest layers of code can decide which HTTP status code to return simply because only at this boundary does the program have enough context to decide whether an error represents a 400, 404, 503, or 500. Moreover, highest layers have enough context to decide whether a job should be retried or failed, and what message should be shown to the user.
This is also the right place to log the error, ideally once, enriched with request IDs, user IDs, or other relevant metadata. A very common Go anti-pattern is logging the same error at every layer, once in the repository, again in the service, and again in the handler. The result is noisy logs filled with duplicated messages and stack traces that make debugging harder, not easier. A good default rule is to return errors upward and log them once at the boundary where the final decision is made. There are exceptions, such as audit logs or security-relevant events, but as a general guideline this rule holds up well.
Another subtle but important detail is how wrapping interacts with error inspection. Wrapping errors is safe and encouraged as long as you preserve error identity. If callers are expected to use errors.Is or errors.As, wrapping must be done with %w or by using typed errors. Using something like fmt.Errorf("operation failed: %v", err) breaks error identity and makes later checks impossible. When you expect callers to inspect errors, how you wrap them matters just as much as whether you wrap them at all.
10) Context Error Handling: Cancellation Is Not a Failure
Here is a question for you:
“Y*ou have a function that does a real operation (e.g., DB query) using a context.Context. If the operation returns some error, and ctx.Err() is also non-nil (deadline exceeded / canceled), which error should you return ? The operation error or ctx.Err()?*”
I suppose this one got you thinking just like it got me first time I got asked this question. Let’s dive deep in realm of context errors (it’s not that deep) and come up with senior-grade answer for such question.
So, if you are not familiar with context, even though you should be, it’s basically a mechanism for propagating signals and request-scoped data downstream into lower levels of the application. In APIs, we usually bind a context to the lifetime of an HTTP request. That means if a client decides they won’t wait for the response and closes the tab, from the backend perspective that request context gets canceled. Once the context is canceled, any operation depending on it should also stop, like a database query:
err := db.WithContext(ctx).First(&user).Error
if err != nil{
return err
}
That cancellation typically results in an error like context.Canceled. The same idea applies to deadlines. If the request has a timeout and your super slow database doesn’t yield a result before the deadline, you’ll usually end up with context.DeadlineExceeded.
We solved uncertainty regarding context and its errors (if there was any), but you may also be wondering: how the hell can an error be of two types at the same time? It’s simple, it can’t. The key thing here is the order in which things fail.
Let’s say you perform a query that is going to fail, but the context gets canceled before the database has a chance to fail. In that case, the error returned is context.Canceled.
Now let’s flip the situation. Imagine the database operation completes successfully with no error, and only after that the context gets canceled. In this case, the returned error is nil, and the context cancellation error is stored in context and reported by calling ctx.Err().
The same idea applies if the database fails right before the context gets canceled. The function returns something like ErrRecordNotFound, while the context cancellation, if it happens afterward, is still reported via ctx.Err().
I hope you get the picture. An error cannot be of two types at the same time. What actually happens is that two different errors may exist around the same operation, but they are observed through two different mechanisms. The real question we’re trying to answer is which one of those signals should be treated as the primary one.
Okay, I hope that answer to the question is now obvious. In case it isn’t, the answer is:
IT DEPENDS.
It really depends on your logic, UX, and generally how you prefer doing things. But in most cases, you’ll want to return ctx.Err(), because it communicates what actually happened from the user’s perspective. In other words, if the user gave up on waiting for something to load, there’s not much point in returning an error like “record not found”, because the user already stopped caring about that result.
What you should definitely do in that case is still log the operation error (if you have it) for debugging and metrics purposes, since it can be useful to know what would have happened if the request wasn’t canceled.
General rule of thumb is:
Return
ctx.Err()if it’s non-nil because cancellation/deadline is the caller-visible outcome, but keep the underlying error for observability and only prefer it when it signals a persistent root cause.
I guess now you may ask: “Does that mean I should check ctx.Err() everywhere and log it every time?”
Well, no, you dummy.
You should check for context errors only in places where you’re about to treat the error as a real failure. In other words, check it at boundaries, where you’re deciding what to return to the client, whether to retry, and whether something should be logged as an actual incident. Here is an example of an HTTP handler with context error checks in the right places:
func (r *CategoryController) CreateCategory(c echo.Context) error {
ctx := c.Request().Context()
var req CreateCategoryRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
if err := r.validator.ValidateRequest(req); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
categoryUuid := uuid.New().String()
category := &models.Category{
Uuid: categoryUuid,
Name: req.Name,
Path: categoryUuid,
}
// DB read (can fail in cancellation-shaped ways)
if req.ParentUuid != nil {
parent, err := r.CategoryService.GetCategoryByUuid(ctx, *req.ParentUuid)
if err != nil {
// If the request is canceled/deadline exceeded, don't try to respond.
if ctx.Err() != nil {
return nil
}
// Keep your current behavior (400). You may later want to map "not found" to 404.
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
category.ParentUuid = req.ParentUuid
category.Path = path.Join(parent.Path, category.Uuid)
}
// DB write (most important place for ctx.Err() classification)
created, err := r.CategoryService.CreateCategory(ctx, category)
if err != nil {
if ctx.Err() != nil {
return nil
}
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
category = created
// Optional: if the client canceled right before we write the response, don't bother.
if ctx.Err() != nil {
return nil
}
return c.JSON(http.StatusOK, echo.Map{"message": "Category created successfully!"})
}
The reason those checks exist only in two places is because we don’t want to sprinkle ctx.Err() checks everywhere. First of all, the code would become painful to read. Second, it’s usually not even necessary.
In practice, we care about context cancellation mainly for expensive or blocking work. Things like request binding and validation are fast, and even if the context gets canceled during them, the amount of unnecessary CPU work is negligible. More importantly, the next context-bound operation the program hits (a DB query, a network call, etc.) will fail with context.Canceled (or context.DeadlineExceeded) anyway, so the cancellation will naturally surface where it actually matters.
The key thing to remember is that we should be careful not to classify context cancellation errors as internal errors. And that brings me to logging of context errors.
Personally, I always implement logging middleware where I distinguish between context errors and internal errors. When it comes to context errors, I usually log only context.DeadlineExceeded, because it can be a signal that I have bottlenecks somewhere in the system, so it’s useful for metrics and debugging. Internal errors, on the other hand, I always log, because that’s the stuff I’ll need later when I’m trying to figure out what actually broke.
Here is an example of my logging middleware:
func LoggingMiddlewareLogger(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
fields := map[string]interface{}{
"method": c.Request().Method,
"uri": c.Request().URL.Path,
"query": c.Request().URL.RawQuery,
"user_agent": c.Request().UserAgent(),
"remote_ip": c.RealIP(),
}
// Intercept response body to optionally extract small JSON fields
writer := newResponseInterceptor(c.Response().Writer)
c.Response().Writer = writer
err := next(c)
// NOTE: ctx.Err() is authoritative for "client went away" / deadline.
ctxErr := c.Request().Context().Err()
// Echo sets Status on write; if nothing was written, Status may be 0.
status := c.Response().Status
if status == 0 {
// If no status was written, fall back:
// - if handler returned an error, assume 500 (Echo error handler likely will write 500)
// - otherwise default 200
if err != nil {
status = http.StatusInternalServerError
} else {
status = http.StatusOK
}
}
fields["status"] = status
// This helps debugging without misclassifying cancellations as server failures.
if err != nil {
fields["handler_error"] = err.Error()
}
// If ctx is canceled/deadline exceeded, don't log this request as a server error.
if ctxErr != nil {
fields["outcome"] = "context_ended"
fields["ctx_error"] = ctxErr.Error()
switch {
case errors.Is(ctxErr, context.Canceled):
logger.Logger.LogDebug().Fields(fields).Msg("Request canceled")
return err
case errors.Is(ctxErr, context.DeadlineExceeded):
// but still not a server bug by itself.
logger.Logger.LogWarn().Fields(fields).Msg("Request deadline exceeded")
return err
default:
// Rare: unknown context error
logger.Logger.LogInfo().Fields(fields).Msg("Request context error")
return err
}
}
// From here on, ctx is fine. Classify request by status code and (optionally) small JSON body.
fields["outcome"] = "completed"
var body map[string]interface{}
parseErr := json.Unmarshal(writer.body.Bytes(), &body)
if parseErr == nil {
if msg, ok := body["message"]; ok {
fields["message"] = msg
}
if e, ok := body["error"].(string); ok && e != "" {
fields["error"] = e
}
} else {
fields["body_parse"] = "failed"
}
// CHANGE: Use status code as the primary log level signal.
// - 5xx: error
// - 4xx: warn/info (many teams use info for 4xx; I’m using warn to make it visible)
// - <4xx: debug
switch {
case status >= 500:
// If you want a stable message rather than body error text, keep it generic:
logger.Logger.LogError().Fields(fields).Msg("Server error response")
case status >= 400:
// 4xx are often client-caused; warn (or info) is typical.
// If you extracted a body["error"], it’s already in fields.
logger.Logger.LogWarn().Fields(fields).Msg("Client error response")
default:
logger.Logger.LogDebug().Fields(fields).Msg("Response")
}
return err
}
}
Note that this example is as simple as possible for educational purposes. Logging is a topic on it’s own and I may write a separate article about proper logging practices in Go in the future.
11) errors.Join
When you run multiple operations concurrently, partial failures are almost guaranteed. Some operations succeed, others fail. If you simply return the first error you encounter, you lose information that can be important for debugging and sometimes even for correct handling. There is a big difference between “something failed” and “three operations timed out while one returned forbidden”.
This is exactly the problem errors.Join was introduced to solve in Go 1.20. It allows you to return a single error value that represents multiple underlying failures, instead of pretending that only one of them mattered.
The usual pattern is straightforward. You run several tasks in parallel, each task may return an error, you collect those errors in a thread-safe way, and once all work is done you return them as one:
func fetchAll(ctx context.Context) error {
var (
mu sync.Mutex
errs []error
wg sync.WaitGroup
)
calls := []func(context.Context) error{
callServiceA,
callServiceB,
callServiceC,
}
for _, call := range calls {
wg.Add(1)
go func(fn func(context.Context) error) {
defer wg.Done()
if err := fn(ctx); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(call)
}
wg.Wait()
return errors.Join(errs...)
}
If all collected errors are nil, errors.Join returns nil. That makes it easy to use without special checks or extra flags.
The important part to understand, and the reason this topic often shows up in interviews, is what happens after you return that joined error. Even though you return a single error value, callers do not lose the ability to reason about what went wrong. Joined errors fully work with errors.Is and errors.As.
For example, even if a timeout is just one of several failures, callers can still detect it:
if errors.Is(err, context.DeadlineExceeded) {
// handle timeout
}
The same applies to typed errors. If any of the underlying errors matches a specific type, errors.As can still extract it:
var nf *NotFoundError
if errors.As(err, &nf) {
// extract structured information
}
So you get one returned error, but you still keep full visibility into the actual causes.
There is another important aspect here that’s worth understanding well. When you run concurrent operations and one of them fails, you have to decide how the rest of the work should behave. In some cases, once one operation fails, the whole result becomes meaningless. In that situation, canceling the remaining work early makes sense and saves resources.
In other cases, each operation is independent and you actually want to know everything that failed. Stopping at the first error would hide useful information. In those scenarios, you usually let all operations finish and then aggregate all failures into a single error using errors.Join.
This distinction matters because errors.Join only makes sense when your goal is to report multiple failures. If your goal is to fail fast, returning the first error is often enough. Being able to explain this tradeoff clearly is exactly what interviewers look for when they ask about concurrent error handling.
Another important thing to know is that sometimes errors.Join is still not enough. In more complex systems you may want a custom aggregation type so you can count failures, categorize them, or attach structured metadata:
type MultiError struct {
Errs []error
}
That’s a valid choice when you truly need richer structure. But in modern Go, errors.Join should usually be your default. It keeps APIs simple, works seamlessly with errors.Is and errors.As, and lets you represent the real outcome of concurrent work without losing important information.
12) Error Handling Anti-pattern
An interview may ask you this simple question:
“What is wrong with this code?”
if err != nil {
log.Printf("error: %v", err)
return fmt.Errorf("failed: %v", err)
}
Even though this code works, now that we have complete knowledge about error handling in Go, we can see potential improvements and answer to this question like a true senior would.
A very common error handling mistake in Go is losing error identity by using %v instead of %w when wrapping errors. Formatting an error with %v turns it into plain text, which means the original error is no longer part of the error chain. Upstream callers can no longer use errors.Is or errors.As, and reliably classifying the root cause later becomes impossible. The fix is simple and idiomatic: when propagating an error, always use %w so the original error is preserved.
return fmt.Errorf("failed: %w", err)
Another frequent issue is double logging, which often leads to noisy logs and duplicated alerts. This usually happens when a lower-level function logs an error and then returns it, while the caller, often a boundary like an HTTP handler, logs it again. The result is the same error appearing multiple times with little added value. As a default rule, lower layers should avoid logging altogether. They should wrap the error with context and return it, leaving logging to the boundary where the final decision is made.
Context itself is another subtle but important detail. A message like "failed" doesn’t tell you what actually went wrong. Good error wrapping adds meaningful, operation-specific context so that the error trail reads like breadcrumbs when debugging. Instead of vague messages, the context should describe what the program was trying to do.
return fmt.Errorf("load config file: %w", err)
When you put these ideas together, the clean version at a lower layer is simple and predictable. You check the error, wrap it with useful context, and return it.
if err != nil {
return fmt.Errorf("doThing: %w", err)
}
At a boundary, such as an HTTP handler, the responsibilities change. This is where errors are classified, translated into responses, and logged once. Context-related errors like cancellations or timeouts may be handled quietly, while unexpected failures are logged and turned into user-facing responses.
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
// optional: debug log
return
}
log.Printf("request failed: %v", err)
// c is echo.Context
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
The exact response policy will depend on the application, but the structure remains the same: classify, decide, and log once.
13) Conclusion
Thats it. Now you have full understanding of error handling in Go and there is no question on this topic that might come up in an interview and catch you off guard. Please note that I write all of my texts myself, but I do use AI here and there to help with formatting and polishing the text. After all, I am a software developer, not a content writer and I intended my articles for article readers, not mind readers.
Good luck with interviews.
Top comments (0)