Apr 03, 2026·8 min read

Go error handling after 50 packages: what to use where

Go error handling gets harder as packages grow. Learn when to use sentinel errors, wrapping, or typed errors for clear logs and sane API responses.

Go error handling after 50 packages: what to use where

Why errors get messy after fifty packages

Go error handling feels simple in a small codebase. Then the service grows, one request starts crossing half the repo, and small choices turn into noise.

A signup request might pass through several layers before it fails:

  • HTTP handler
  • validation code
  • user service
  • database or email code

Each layer knows something different. The database knows a unique constraint failed. The user service knows the email is already in use. The handler only needs to return a clean API response. If every layer rewrites the error in its own words, nobody sees the full story anymore.

The mess gets worse when different packages describe the same problem in different ways. One package returns "not found". Another returns "user missing". A third uses a custom struct. Humans can guess they mean the same thing, but code cannot. Teams then add special cases everywhere, and the rules drift over time.

Early rewriting causes a lot of trouble. If a repository turns a database error into "save failed" too soon, logs lose the reason that engineers actually need. Later, someone sees a spike in 500 responses but cannot tell whether the cause was a timeout, bad input, a duplicate record, or a broken downstream call.

API clients have the opposite need. They do not want a chain of internal details. They want a short, stable message and the right status code. "Email already exists" is useful. A raw SQL error is not. That creates a tension every growing team has to manage: keep enough detail for logs and debugging, but return something simple and safe to clients.

Once a Go service reaches fifty packages, errors stop being just return values. They become part of how the team debugs incidents, tracks recurring failures, and keeps API behavior predictable. If the project has no shared rules, the error text starts doing too much work, and it does that job badly.

What each error style actually gives you

Once a Go project spreads across many packages, one error pattern stops being enough. Each style gives you a different kind of information, and each helps at a different point in the call chain.

  • A sentinel error gives callers one shared value to recognize. If a package exposes ErrNotFound, other packages can check for that exact condition with errors.Is. This is good for simple branch decisions. It is weak for debugging, because ErrNotFound alone does not say what lookup failed or where it failed.
  • A wrapped error keeps the original cause and adds local context. A repository might return fmt.Errorf("load account %s: %w", id, err). That extra text makes logs readable, while errors.Is and errors.As can still inspect the underlying error. For day to day debugging, this is the pattern teams lean on most.
  • A typed error carries fields. You can store things like an internal code, operation name, resource, or a safe client message. That helps when an HTTP handler needs to turn a failure into a clean API response without guessing from a log string.

The tradeoff is pretty direct. Sentinel errors are easy to share and easy to test, but they carry almost no detail. Wrapped errors read well in logs, but the added context is still just text unless you wrap a known error underneath. Typed errors are clear and structured, but they add more code and more rules for the team to follow.

A small example makes the split clearer. If a user lookup misses, the data layer can return ErrUserNotFound. The service layer can wrap it with the user ID and operation name. The API layer can then map that condition to a 404, while logging the full chain for the team.

Most teams do better with a mix than with one pure style. Use sentinels for shared conditions, wrapping for context, and typed errors where response mapping or extra fields actually matter. If every package invents its own error struct, the code gets noisy fast.

Where sentinel errors fit

Sentinel errors work best for a small set of business outcomes that stay the same over time. Think of results your API must treat in a consistent way, such as ErrEmailTaken, ErrForbidden, or ErrRateLimited. In Go error handling, that is where sentinels feel natural: they answer "what happened for the business," not "what broke inside the stack."

They also work well across package boundaries because callers can use errors.Is instead of checking raw strings. A user package can wrap ErrEmailTaken with more context, and the API handler can still match it and return a sane 409 response. Logs keep the extra detail, while clients get a short message that makes sense.

Keep the list short enough that the team can remember it without opening a document. If every package starts adding its own sentinels, the code turns into a grab bag of names nobody trusts.

A short list often covers most cases:

  • account or email already exists
  • permission denied
  • resource not found
  • rate limit hit
  • invalid state for this action

That kind of list stays useful because each error drives a real choice in calling code. The handler sends a different status code. The UI shows a different message. Retry logic may change.

Sentinels are a poor fit for low-level details. Do not create package-wide errors like ErrPostgresTimeout, ErrTLSHandshakeFailed, or ErrRedisPoolEmpty just so other layers can compare them. Those details change too often, and they tempt handlers to leak internal failures into API responses.

For SQL, network, or vendor-specific problems, wrap the original error with context and log it. Then map it to a stable client outcome later, such as 500 or 503. If a new sentinel does not change what the caller does next, skip it.

Where wrapping keeps logs readable

Wrapping works best when an error moves through layers and each layer adds one small fact. In a large codebase, that usually means the repository names the database action, the service names the business action, and the handler decides what the client should see. That pattern keeps Go error handling readable without turning every failure into a wall of text.

Keep each wrap short. Use the operation name, not a paragraph. "load account", "save invoice", or "parse signup form" is enough. When the error reaches a log line, those small labels stack into a trail that tells you where things went wrong.

func (r *Repo) FindUser(ctx context.Context, id string) error {
    if err := r.db.QueryRowContext(ctx, "...").Scan(...); err != nil {
        return fmt.Errorf("find user: %w", err)
    }
    return nil
}

func (s *Service) Signup(ctx context.Context, id string) error {
    if err := s.repo.FindUser(ctx, id); err != nil {
        return fmt.Errorf("signup: %w", err)
    }
    return nil
}

The %w matters. It preserves the original cause, so callers can still use errors.Is or errors.As. If the database layer wraps sql.ErrNoRows, the API layer can still detect it and return 404 or 400 instead of 500.

Logs get messy when every package writes its own entry. A repository logs a failure, the service logs it again, and the handler logs it a third time. Now one bad query looks like three incidents. Log near the edge of the system instead - in the HTTP handler, job runner, or message consumer - where you have request IDs, user IDs, and enough context to make the log useful.

A simple signup flow shows why this helps. The repo returns find user: sql: no rows in result set. The service wraps it with signup: .... The handler checks the cause, returns a calm API message like "user not found", and writes one structured log with the full wrapped error. Clients get a sane response. The team gets the full trail.

When typed errors earn the extra code

Strengthen Your Go Backend
Get CTO-level help on service boundaries, error chains, and production behavior.

Typed errors make sense when the caller needs facts, not just a yes or no. If your HTTP layer must choose between 400, 404, 409, and 503, a sentinel error often does too little. Wrapping helps you trace the failure, but it does not give the caller a clean way to decide what response to send.

A small typed error usually needs only a few fields:

  • a status code or app code
  • a public message that is safe to return
  • the underlying cause for logs and debugging

That shape covers a lot of real API work. Say a signup request tries to create an account with an email that already exists. The service can return an error with status 409, public message "email already registered", and the database error as the cause. The API sends the safe message to the client, while logs keep the low-level detail. Nobody has to inspect error text and hope it matches.

Keep the type small. If you keep adding request IDs, retry flags, raw SQL, debug notes, and half the request body, the error stops being useful. It turns into a junk drawer. Most teams do well with one general application error type that implements Error() and Unwrap(), plus a few helper constructors.

Too many error structs create a different mess. NotFoundError, ConflictError, DuplicateEmailError, and UserConflictError may look tidy at first, but they often carry the same three fields with different names. That spreads logic across the codebase and makes common handling harder.

Create a new error type only when the shape really changes. Validation is a good example. A validation error may need field-level details such as which input failed and why. That is different from a simple conflict or timeout, so a separate type earns its place. If the data shape stays the same, reuse one type and keep moving.

A rule set your team can follow

Teams get into trouble when every package invents its own error style. Pick one path and make it boring. In Go error handling, boring wins because people can read it fast, review it fast, and keep it consistent across dozens of packages.

Inside leaf packages, start with plain errors. If a function reads from PostgreSQL, parses JSON, or calls Redis, return a normal error that says what failed. Do not create a public sentinel or custom type unless another layer must make a decision based on that exact failure. Most low-level code does not need more than a clear message.

Then wrap at every hop. The wrapper should name the package or operation, not retell the whole story. fmt.Errorf("userrepo.Create: %w", err) is often enough. After a few layers, logs still read well because they show the path of the failure instead of one vague line like "db error".

The service layer should be the sorting point. This is where you decide which failures are normal business cases and which ones mean the system is broken. A duplicate email, an expired token, or a missing user may map to a sentinel error or a typed error because handlers need a stable way to detect them. A timeout from the database should usually stay a wrapped internal error.

The handler has a different job. It should translate known failures into API status codes and short, sane responses. Clients need a clean message like "email already in use" with a 409. They do not need driver messages, SQL text, or stack details.

Write one team rule for logging and enforce it in code review: log once, at the boundary. For most teams, that means the HTTP handler, job worker, or queue consumer. Lower layers should return errors and stay quiet. If every layer logs, one bug turns into a pile of duplicate noise.

That rule set keeps logs readable, API error responses predictable, and package boundaries clean even after the codebase gets large.

A signup request from form to API response

Stop String Check Drift
Replace brittle checks with error rules your handlers can trust.

A clean signup flow shows why Go error handling needs two outputs at once: a full error chain for your team and a short, plain response for the client.

Say a user submits a form with an email that already exists. The repository calls the database, and the database returns its own duplicate-email error. That raw error often includes driver text, table names, or index names. Good for debugging. Bad for an API response.

The user service should wrap that failure with context as it moves up the stack. A message like create user: insert account: duplicate key value violates unique constraint tells your team where the request failed. When you read the log later, you do not need to guess which step broke.

The service should also translate the cause into an app-level error. For a common case like this, ErrEmailTaken is often enough. If your API needs more structure, use a small conflict type that still unwraps to the original database error. Either choice gives the rest of the app one stable meaning instead of vendor-specific database text.

At the handler layer, the check stays simple. Use errors.Is(err, ErrEmailTaken) or errors.As for the conflict type, then return HTTP 409 with a short message like "email already in use". Do not send the SQL error back to the client. It adds noise, it changes between drivers, and it exposes details the client does not need.

The log should keep the whole chain. Log the wrapped error, request ID, and route. If support needs the email, log a redacted or hashed value, not the full signup payload. That gives developers enough detail to fix the problem without leaking private data.

This pattern is boring in a good way. The database stays specific, the service speaks in business terms, and the API stays calm and predictable. In a larger codebase, that kind of Go error handling keeps logs clear and API error responses sane.

Mistakes that break logs or confuse clients

Bad Go error handling usually hurts two groups at once. Developers lose the real cause in logs, and users get messages that are either vague or far too detailed.

A common bug is comparing wrapped errors with ==. That works only for the exact same value. Once a lower package adds context with fmt.Errorf("create user: %w", err), == stops helping. Use errors.Is for sentinel errors and errors.As for typed ones, or your handler may miss a known case and return the wrong status code.

Another mistake is sending raw database text to the client. A message like pq: duplicate key value violates unique constraint users_email_key helps the backend team, not the person filling out a signup form. Log the full cause, then map it to a plain response like email already in use with a 409.

Teams also muddy their logs by recording the same failure in every layer. If the repository logs it, the service logs it, and the HTTP handler logs it again, one broken request turns into three noisy entries. Pick one boundary for the final log entry, usually the HTTP or RPC layer where the request ID and response code already exist. Lower layers should return errors with context, not print them.

Typed errors can help, but some teams go too far and create a fresh custom type in every package. Then nobody remembers which type means what, and errors.As chains get messy fast. Keep custom types for cases that carry real data, like a validation error with a field name or a rate limit error with a retry time.

The last bad habit is rewriting an error and dropping the cause. fmt.Errorf("save failed") throws away the detail you needed most. fmt.Errorf("save user: %w", err) keeps the trail intact.

A quick review catches most of this:

  • Check wrapped errors with errors.Is or errors.As
  • Check client responses for internal system text
  • Check that only one layer writes the final log entry
  • Check that custom error types carry data you actually use

If a signup request fails because the email already exists, the API can return a clean 409 and short message, while the log still shows the repository call, query context, and original driver error. That split is what keeps clients calm and debugging short.

Quick checks before you merge

Fix Noisy Backend Logs
Find duplicate logging, missing context, and weak boundary rules before incidents pile up.

A small error change can wreck logs even when tests stay green. Before you merge, read one failure path from the place it starts to the place where the API answers the client.

Use this short review:

  • A caller can detect the error with one rule. If the handler needs three string checks, or a type switch plus a status map, the error design is doing too much.
  • The log shows where the failure happened. A line like "save user: insert row: context deadline exceeded" is useful. "request failed" is not.
  • The API returns one stable status and one stable public message for the same class of failure. Clients should not guess whether "email already used" is 400 on Monday and 409 on Friday.
  • Private details stay in logs, not responses. SQL text, hostnames, raw provider errors, and stack traces help developers. They confuse users and leak too much.
  • A new teammate can follow the path in one read. They should see where you add context, where you classify the error, and where you map it to JSON.

If one of those checks fails, stop and simplify. In Go error handling, boring code usually wins. A sentinel for a common business rule, wrapping for location, and a typed error only when the caller truly needs fields keeps the path clear.

One last habit helps more than people expect. Read the final log line and the client response side by side. If both make sense after ten seconds, you probably chose the right pattern.

Next steps for a growing Go team

If your codebase already spans dozens of packages, do not try to fix error handling everywhere at once. Write one short team guide first. Keep it plain: when to use sentinel errors, when to wrap, when to define a custom type, and how handlers turn internal failures into stable HTTP responses.

Start with one endpoint your team touches every week. A signup flow, billing request, or import job is enough. One busy path gives you real logs, real edge cases, and a small place to settle disagreements before you copy the pattern across the service.

Then review the older handlers on that path. Many teams still leak raw database text, vendor SDK messages, or storage errors straight into responses. Clients cannot do much with that, and your logs get harder to scan. Keep the internal cause in wrapped errors, but return a public message and status code that stay the same even if the storage layer changes.

A short review pass usually pays off:

  • replace string checks with errors.Is or errors.As
  • add tests that prove each handler returns the expected status code
  • add tests that confirm wrapped errors still match the expected sentinel or typed error
  • check logs for duplicate messages and missing request context

After one endpoint feels clean, copy the same rules to two or three more paths. If the pattern still feels awkward, the guide is probably too clever. Good Go error handling should be boring enough that new teammates can follow it without guessing.

If your team wants one shared approach across multiple services, Oleg's Fractional CTO advisory can review error handling, logs, and API responses, then help turn that review into a simple rule set your team can keep using. That kind of outside pass is most useful when different services already return the same failure in three different ways.