A function that can fail returns an error as its last result, and the caller checks if err != nil right there; error is a built-in interface with a single method, Error() string.
Errors are values: you store, compare, wrap and return them like any other value; nothing jumps over your stack frames.
Explicit control flow: every call that can fail is visible at the call site. That is the reason Go chose this over exceptions: a function has no hidden exit paths.
Handle it or return it with context: return fmt.Errorf("load user %d: %w", id, err). Doing both (log and return) reports one failure twice.
**panic is not the exception mechanism**: it is for bugs and impossible states, not for expected failures such as a missing file or bad input.
GoJunior
Errors & Panics
error values, wrapping with %w, errors.Is/As, panic and recover
Questions closed
0 / 21
- 0Review
- 3Asked often
Questions
A sentinel is a package-level error variable such as io.EOF or sql.ErrNoRows that callers compare against. Two errors.New calls are never equal because each returns a new pointer, and errors compare by identity, not by text.
Declare it once (var ErrUserNotFound = errors.New("user not found")) and return that variable, or wrap it.
Compare with errors.Is(err, ErrUserNotFound), not ==, so the check still works after a layer adds context with %w.
Never match on err.Error(): any layer may reword that string.
io.EOF is the one you will see compared with ==: readers must return it unwrapped by contract. errors.Is works for it too.
An error string is a fragment, not a sentence: lowercase, no full stop, no failed to. Context is added with fmt.Errorf("operation: %w", err), so the top of the stack reads as one line.
Lowercase and no trailing punctuation, because it is printed after other text: run: load config: open /etc/app.json: no such file or directory. Exception: a string that starts with an exported name or an acronym.
Drop failed to and error:; the error type already means failure, and repeating it per layer gives failed to sync: failed to load: ....
Each layer adds its operation and the operands only it knows. Do not repeat what the cause already says: an os.Open error already contains the path.
Wrap with %w. Concatenating err.Error() keeps the text but throws away the cause, so errors.Is upstream returns false.
Any type with an Error() string method is an error. Define a struct when the caller needs data from the failure (a field name, a status code, a retry delay) and not just a yes/no match.
**Sentinel from errors.New**: the caller only needs to know which failure happened.
**fmt.Errorf: you only add context for a human reading the log.
Struct type**: the caller must read details; it gets the value out of a wrapped chain with errors.As.
Put Error on a pointer receiver and return &ValidationError{...}, as the standard library does with *fs.PathError and *json.SyntaxError.
Return it through a result of type error, never *ValidationError, or a nil pointer turns into a non-nil error.
panic stops normal execution and unwinds the current goroutine, running its deferred calls on the way. recover stops the unwinding, but only when called directly by a deferred function of the panicking goroutine; anywhere else it returns nil.
"Directly" is literal: defer logPanic() works if logPanic calls recover, but defer func() { logPanic() }() does not; there recover is one call too deep.
After recover, the function that deferred it returns normally to its caller. To report the panic as an error, use a named result and assign it inside the deferred closure.
recover returns any (whatever was passed to panic). Runtime faults (nil map write, index out of range, nil dereference) carry a runtime.Error: that is a bug to fix, not a condition to handle.
Since Go 1.21 panic(nil) panics with a *runtime.PanicNilError, so nil from recover reliably means "not panicking".
Capture debug.Stack() inside the deferred function; once it returns, the frames are gone.
panic unwinds the goroutine's stack and runs its deferred calls, so recover can stop it; os.Exit ends the process on the spot and runs no defers; log.Fatal is a log line followed by os.Exit(1).
An unrecovered panic runs the panicking goroutine's defers, prints the panic value and the stack, and exits with code 2.
With os.Exit every pending defer is skipped: Flush, Close, tx.Rollback, removing a temp file. Buffered output is simply lost.
log.Panic is the variant that panics instead, so defers do run.
Keep the exit in main: move the work into run() error and let main print the error and call os.Exit(1); by then every defer inside run has finished.
Library code returns an error; ending the process is main's decision.
Panic for programmer errors (states that mean the program itself is wrong) and return an error for everything that can fail at run time. A MustX helper is the sanctioned exception: a panicking twin of X for inputs fixed in the source.
The runtime and the standard library panic on bugs: a write to a nil map, an index out of range, closing a closed channel.
regexp.MustCompile and template.Must are meant for package-level vars: a bad literal fails at startup, where no caller could react. Never call MustX on user input.
A panic must not leave a package through its exported API. If you panic internally as control flow (unwinding a deep recursive parser), recover in the exported function and convert it to an error; recover only your own panic type and re-panic the rest.
Never panic to save writing if err != nil.
%w wraps: the new error keeps the original value and gets an Unwrap method, so errors.Is and errors.As still find the cause. %v only copies the text, and the chain ends there.
Both verbs print exactly the same message, so logs and string-comparing tests cannot tell them apart. Changing one to the other is a silent break that only an errors.Is test catches.
Wrap with %w when callers may match the cause: context.DeadlineExceeded, fs.ErrNotExist, your own sentinels.
Use %v on purpose at a boundary where the cause is an implementation detail. Wrapping a database driver's error makes that driver part of your API; translate it into your own sentinel instead.
Since Go 1.20 one fmt.Errorf may contain several %w; the result has Unwrap() []error and every operand stays matchable.
It prints false, true, false, then false true. == sees only the outer wrapper, errors.Is follows Unwrap link by link, and the walk ends at the first error that has no Unwrap (here, the one built with %v).
Output line 1: err is a *fmt.wrapError, a different value from ErrNotFound.
Output line 2: at each level errors.Is checks err == target, then the error's own Is(error) bool method if it has one, then calls Unwrap and goes one link deeper. For Unwrap() []error (errors.Join, several %w) it searches every branch depth-first.
Output line 3: %v copied the text only, so cut has no Unwrap and the walk stops at once. Rebuilding an error from a string (concatenation, an RPC boundary) cuts the chain the same way.
Output line 4: os.IsNotExist predates wrapping and does not look inside fmt wrappers. Use errors.Is(err, fs.ErrNotExist).
Use errors.Is for sentinels even when today's error is not wrapped.
Use errors.As(err, &target): it walks the same chain as errors.Is and assigns the first error of the target's type. A type assertion err.(*ValidationError) looks only at the outermost value, which after %w is the wrapper.
The target is a non-nil pointer to an error type or to an interface. For a pointer-receiver type that is var ve *ValidationError; errors.As(err, &ve), so you pass a **ValidationError.
A wrong target panics instead of returning false: errors.As(err, ve) gives errors: target must be a non-nil pointer, and a value ValidationError whose Error has a pointer receiver gives *target must be interface or implement error.
An interface target asks about behaviour: var ne net.Error; errors.As(err, &ne) && ne.Timeout().
Go 1.26 adds the generic errors.AsType[*ValidationError](err), which returns the value and an ok without a target variable.
errors.Is asks "is this that error"; errors.As asks "is there one of this type, and give it to me".
Give the type an Unwrap() error method that returns its cause, and errors.Is and errors.As will walk through it. Add an Is(target error) bool method when a value of your type should match a sentinel it is not equal to.
**Unwrap() error**: a type that holds a cause exposes it, as *fs.PathError and *url.Error do, so callers can still match fs.ErrNotExist underneath.
**Unwrap() []error**: for a type holding several errors; Is and As search every branch. errors.Unwrap does not call this form and returns nil.
**Is(error) bool**: custom equivalence, for example every *StatusError with code 429 counts as ErrRateLimited. Compare only against targets you own and do not unwrap inside it: the walk already does that.
**As(any) bool: rarely needed; lets a type present itself as another target type.
Receivers**: with Error on a pointer receiver only *NotFound is an error, so the target must be *NotFound (var nf *NotFound; errors.As(err, &nf)); a NotFound target panics. With Error on a value receiver both forms are errors, and As matches only the exact type you ask for.
Use errors.Join (Go 1.20+) or several %w verbs in one fmt.Errorf. The result exposes Unwrap() []error, and errors.Is and errors.As search every branch, so each joined error stays matchable.
errors.Join drops nil arguments and returns nil when all of them are nil, so you can append unconditionally and end with return errors.Join(errs...).
Its message puts each error on its own line. If your logs are line-based, format the list yourself.
errors.Unwrap(joined) returns nil, because the joined error has Unwrap() []error, not Unwrap() error. To read the parts, assert interface{ Unwrap() []error }.
fmt.Errorf("import row %d: %w, %w", n, errA, errB) builds the same kind of multi-error but lets you write the sentence yourself.
Typical uses: a validator that reports every bad field at once, and a deferred Close whose error must not hide the main one.
Four things: it logs an error and also returns it, it discards the json.Marshal error, it throws away the write error's cause by concatenating its text, and it ignores the error from Close on a file it has written to.
Log and return: the caller will log it again, so one failure becomes two log lines, and the returned copy has no context. Either handle it (log and stop) or return it wrapped.
**data, _ :=**: if Marshal fails, data is nil, an empty file is written, and the function returns nil. An explicit _ on an error needs a reason next to it.
**errors.New("Failed to ..." + err.Error())**: the text survives, the cause does not: errors.Is(err, fs.ErrPermission) upstream is false. Wrap with %w, lowercase, no Failed to.
**defer f.Close() on a written file**: on some filesystems (NFS, quota checks) a failed write is reported only by close; ignoring it returns success for a broken file. Merge it into a named result. For durability, also call f.Sync().
fmt.Errorf returns a non-nil error even when the value passed to %w is nil, so a successful commit comes back as transfer: commit: %!w(<nil>). The money has moved, the caller sees a failure and retries, and the transfer runs twice.
Why: fmt.Errorf never checks its operands: it formats the text and always returns a new error value. A nil operand only turns into %!w(<nil>) in the message; it does not make the result nil.
The fix: check tx.Commit() first, wrap only a non-nil error and return nil explicitly on success. The deferred tx.Rollback() stays: after a successful Commit it is a no-op that returns sql.ErrTxDone.
Same trap elsewhere: return fmt.Errorf("close: %w", f.Close()) and any wrap(err, msg) helper that does not start with if err == nil { return nil }. Only errors.Join returns nil when all its arguments are nil.
How to catch it: go vet stays silent, because an error operand is valid for %w. A happy-path test asserting err == nil fails at once, and %!w(<nil>) in production logs is the tell.
An unrecovered panic in any goroutine crashes the whole process, and only a recover in that same goroutine's deferred calls can stop it; a recovery middleware or a recover in the parent never sees it.
The runtime runs the panicking goroutine's defers, prints the panic and the stack, and exits with code 2. Other goroutines never run their defers: in-flight requests are cut, buffers are not flushed.
net/http recovers panics only on the connection's goroutine. A goroutine that a handler starts is unprotected.
Every go statement whose code may panic (yours or a third-party library's) needs its own deferred recover. Put it in one helper and use that instead of a bare go.
fatal error: concurrent map writes and all goroutines are asleep are not panics; recover cannot catch them.
Recovering keeps the process alive but does not fix the state: log the stack from debug.Stack(), count it in a metric, and treat it as a bug.
The result of runJob is unnamed, so the deferred closure writes to a local err that nothing returns. After a panic return err never runs, the function returns the zero value nil, and a crashed job is counted as a success.
Why: recover stops the unwinding, and the function then returns to its caller with whatever its result parameters hold. An unnamed result is assigned only by a return statement, and that statement was skipped by the panic.
The fix: name the result: func runJob(job Job) (err error). A deferred function can read and change named results after a return or a panic, so the err = fmt.Errorf(...) in the closure becomes the returned value.
Same rule elsewhere: a deferred Close or Rollback whose error must reach the caller (err = errors.Join(err, f.Close())) also needs a named result; on an unnamed one the assignment is silently lost.
How to catch it: neither the compiler nor go vet complains, because err is used. A unit test with a job that panics and an assertion err != nil fails immediately.
No: an error from errors.New or fmt.Errorf holds only a message and its wrapped cause. Go relies on each layer adding context, so the message reads like a trace of operations, and you capture a real stack yourself only where it pays off.
Why not by default: capturing a stack with runtime.Callers costs time and allocations on every error, and many errors are expected and handled on the spot (io.EOF, a cache miss). That cost on the hot path buys nothing.
Context instead: wrapping at every layer gives handle order: load user 42: query: connection refused, which usually locates the failure without line numbers.
When you need lines: record the stack once, where the error enters your code: a small wrapper type around runtime.Callers, or a library such as github.com/cockroachdb/errors. The archived github.com/pkg/errors still shows up in older code, printing its stack with %+v.
Panics do have stacks: take debug.Stack() in the deferred recover. slog with AddSource: true records where the log call is, not where the error was created.
In one function at the transport layer: the domain returns its own sentinels and typed errors, and the handler or interceptor translates them with errors.Is and errors.As into a status and a safe message, with 500 or codes.Internal for anything unknown.
The domain and storage layers know nothing about HTTP. ErrNotFound means "not found" whether the caller is REST, gRPC or a CLI.
One mapping function per transport, so every endpoint reports the same failure the same way and a new error is a one-line change.
The client gets a stable code and a message written for it. The full chain (with SQL, hostnames, file paths) goes only to the log, next to a request id the client also sees.
Typed errors carry what the client may see: *ValidationError becomes 400 with the field name.
For gRPC return status.Error(codes.NotFound, "order not found") from an interceptor or the handler; a plain Go error reaches the client as codes.Unknown.
Loop up to N attempts, return on success or on an error that cannot succeed later, and between attempts wait in a select on ctx.Done() and a timer whose delay doubles each time, with a cap and random jitter.
Stop on cancellation: the select returns as soon as the caller gives up; time.Sleep would sleep through it.
Retry only what can change: timeouts, 503, connection resets. A 400 or a validation error fails the same way every time, and a cancelled context must never be retried.
Jitter: without it, clients that failed together retry together and hit the recovering service in waves.
Cap the delay and the attempts: and return the last error wrapped, so the caller can still match it with errors.Is.
Idempotency: retry only operations that are safe to repeat; a retried POST /charges can charge a card twice.
Every error a caller can match with errors.Is or errors.As is part of your API, as binding as a function signature. Export the least you can: sentinels for identity, types when callers need data, behaviour interfaces when they only need to ask a question.
Sentinel values
var ErrNotFound = errors.New("not found") is the simplest, carries no data, and makes callers import your package just to compare.
You can never stop returning it or remove the %w that exposes it. Changing that %w to %v compiles, passes vet and breaks every caller at run time.
Exported types
A *ValidationError carries fields callers read with errors.As. Those fields are now API too: renaming one is a breaking change.
Behaviour interfaces
Export a question and keep the type private: callers check interface{ NotFound() bool } with errors.As and need not import your package at all, because the interface can be declared on their side.
net.Error with Timeout() is this pattern. Its Temporary() was deprecated because "temporary" had no clear meaning; name behaviours precisely.
Keeping the promise
Document which errors each function may return; anything undocumented is opaque, and callers should only check err != nil.
Translate dependency errors at your boundary instead of wrapping them, so a dependency upgrade cannot change your API.
Write one errors.Is or errors.As test per promised error; nothing else catches a deleted %w.
It must re-panic http.ErrAbortHandler, which handlers use on purpose to abort a response, and it may write a 500 only if nothing has been sent yet. Once a status is on the wire it must abort the connection, not let the response finish as a truncated 200.
**http.ErrAbortHandler: the server recovers it itself and closes the connection without logging a stack. Swallowing it turns every deliberate abort, such as a reverse proxy losing its upstream, into a logged 500.
Already written**: a second WriteHeader is ignored with superfluous response.WriteHeader call. If the middleware just returns, a chunked response is closed cleanly and the client accepts half a JSON body as a complete 200. Panicking with http.ErrAbortHandler instead makes the client see a broken response.
Track writes in a wrapper: embed http.ResponseWriter and add Unwrap() http.ResponseWriter, so http.NewResponseController still reaches Flush, Hijack and the deadlines; a plain struct hides them and breaks streaming and websockets.
Log inside the deferred function: the stack from debug.Stack(), the route and the request id, plus a metric; after it returns the frames are gone.
It covers only the request goroutine; goroutines the handler starts need their own recovery.