Errors & Panics
error values, wrapping with %w, errors.Is/As, panic and recover
- 01
A table test asserts
err == errors.New("user not found")and fails even though the printed message is character-for-character identical. What iserrors.Newhanding back?Easyerrors.Newallocates a new*errorStringon every call, and an interface comparison compares the pointer, not the text — so two errors with the same message are never==.var ErrUserNotFound = errors.New("user not found") func TestIdentity(t *testing.T) { a, b := errors.New("user not found"), errors.New("user not found") fmt.Println(a == b) // false — two distinct *errorString pointers fmt.Println(a.Error() == b.Error()) // true — same text … - 02
Review blocks
return errors.New("Failed to open config file: " + path + "."). Which rules from Go Code Review Comments does that single line break, and what does the fixed version look like?EasyError strings are fragments, not sentences: lowercase, no trailing punctuation, no
failed toorerror:prefix — and context is added withfmt.Errorf("...: %w", err), never string concatenation.// ❌ capitalised, punctuated, "Failed to", and the cause is concatenated away if err != nil { return errors.New("Failed to open config file: " + path + ".") } // ✅ lowercase fragment, the operand the caller can't guess, %w keeps the cause … - 03
A CLI writes its report through a
bufio.Writerwithdefer w.Flush()and ends the error path withlog.Fatalf. The output file is truncated — but only on runs that failed. What is eating the tail?Easylog.Fatalfcallsos.Exit(1), andos.Exitterminates the process immediately: no deferred function runs, so the buffered bytes are never flushed.// ❌ log.Fatalf -> os.Exit(1): w.Flush() never runs, the tail is lost func main() { f, err := os.Create("report.csv") if err != nil { log.Fatalf("create report: %v", err) } … - 04
Someone adds
if r := recover(); r != nil { log.Println(r) }at the top of a function "defensively". It never fires, and the process still dies. What doesrecoveractually require?Easyrecoverreturns the panic value only when it is called directly by a deferred function while that goroutine is panicking — everywhere else it returnsniland changes nothing.// ❌ not deferred: the goroutine is not panicking here, so r is always nil func handle(job Job) { if r := recover(); r != nil { log.Println("recovered:", r) } process(job) // a panic in here is not caught by anything above … - 05
A cleanup commit changed one
fmt.Errorf("query user: %w", err)to%v. Nothing failed in CI, and three packages awayerrors.Is(err, sql.ErrNoRows)silently started returning false. What does%wdo that%vdoes not?Medium%wmakes the new error wrap the operand — it keeps a reference to it and gives the result anUnwrap() errormethod — while%vonly renders its text, cutting the chain thaterrors.Isanderrors.Aswalk.// %v and %w print exactly the same thing — that is why this is a silent break. base := sql.ErrNoRows lost := fmt.Errorf("query user: %v", base) kept := fmt.Errorf("query user: %w", base) … - 06
Storage returns
fmt.Errorf("get order %d: %w", id, ErrNotFound)and the handler still answers 500, because it compareserr == ErrNotFound. What doeserrors.Isdo that==cannot, and where does its walk stop?Mediumerrors.Iswalks the unwrap chain — at every level it compares against the target and asks that error's ownIs(error) boolmethod — so it finds a sentinel that==can only see on a bare, unwrapped error.// The handler cannot see the sentinel through the wrap with ==. err := store.GetOrder(ctx, id) // fmt.Errorf("get order %d: %w", id, ErrNotFound) fmt.Println(err == ErrNotFound) // false — err is a *fmt.wrapError fmt.Println(errors.Is(err, ErrNotFound)) // true — Is unwraps … - 07
A handler needs the offending field out of a
*ValidationError, butve, ok := err.(*ValidationError)is false whenever the service layer added context. What is the right extraction, and what does it demand of the target you pass?Mediumerrors.As— it walks the same chain aserrors.Isand assigns the first assignable error into the pointer you give it, which a type assertion (one level, no unwrapping) cannot do.type ValidationError struct { Field string Reason string } func (e *ValidationError) Error() string { … - 08
func do() error { e := validate(); return e }wherevalidate() *ValidationErrorreturns nil on success. Callers reporterr != nilwith the message<nil>. What is inside that interface value?MediumAn interface value is a (type, value) pair and is nil only when both halves are nil — returning a nil
*ValidationErroras anerrorstores the type*ValidationErrorwith a nil pointer, soerr != nilis true.type ValidationError struct{ Field string } func (e *ValidationError) Error() string { return "invalid " + e.Field } func validate(f Form) *ValidationError { // concrete return type — the trap if f.Email == "" { … - 09
Your package defines
type NotFound struct{ Key string }withfunc (e *NotFound) Error() string. Half the codebase callserrors.As(err, &nf)withvar nf NotFoundand it panics at runtime. What rule about receivers is biting, and how should the type be shaped?MediumA method with a pointer receiver puts
Errorin*NotFound's method set only — soNotFoundis not anerror, anderrors.Asrejects that target witherrors: *target must be interface or implement error.type NotFound struct { Key string cause error } func (e *NotFound) Error() string { return "not found: " + e.Key } … - 10
A config validator must report every bad field in one pass, not just the first, and callers still want
errors.Is(err, ErrMissingField)to work on the result. What did Go 1.20 add for this?Mediumerrors.Join— it returns one error holding a slice of errors, exposed throughUnwrap() []error, whicherrors.Isanderrors.Assearch depth-first across every branch.var ( ErrMissingField = errors.New("missing field") ErrBadFormat = errors.New("bad format") ) func Validate(c Config) error { … - 11
defer f.Close()on a file you just wrote passes every test, then production starts losing the last few kilobytes of some uploads. What is that deferred call throwing away?MediumCloseon a writer is where the final flush happens and its error is reported — a baredefer f.Close()discards that error, so a full disk or a broken network filesystem returns success to the caller.// ❌ the flush error is dropped: a full disk returns nil to the caller func Save(path string, rows []Row) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("create %s: %w", path, err) } … - 12
A library author panics on an invalid argument "because the caller obviously has a bug". When is that defensible in Go, and what is the rule at the package boundary?
MediumPanic is for programmer error the caller could not have handled — impossible state inside your own package — and it must not escape an exported function: a public API returns an
error.// The Must convention: panic at init where the caller cannot act, error otherwise. var slugRE = regexp.MustCompile(`^[a-z0-9-]+$`) // a bad literal is a build-time bug func Compile(pattern string) (*Matcher, error) { // runtime input -> error re, err := regexp.Compile(pattern) if err != nil { … - 13
The HTTP recovery middleware has been in place for a year, and the service still died at 03:00 with a stack trace from a background worker. Why didn't the middleware help?
Hardrecoveronly stops a panic on its own goroutine — a panic in a goroutine the handler started unwinds that goroutine alone and then terminates the entire process, and no deferred function on any other goroutine runs.// ❌ the middleware's recover is on the request goroutine; this one is not func handler(w http.ResponseWriter, r *http.Request) { go func() { // a nil map write in here takes down the whole process metrics[r.URL.Path]++ }() … - 14
Three services now match on your package's exported
ErrNotFound,ErrConflictandErrExpired, and you can no longer change how any of them is produced. What did exporting those values commit you to, and what are the alternatives?HardAn exported sentinel is part of your API, exactly like a function signature: every value a caller can
errors.Isagainst is a promise about which operation fails how, and it creates an import edge from their package to yours.// Sentinel: one symbol, no payload, and now part of your API forever. var ErrNotFound = errors.New("not found") // Behaviour assertion: the caller depends on a question, not on your package. // Only this interface is exported; notFoundError stays private and free to change. type NotFounder interface{ NotFound() bool } … - 15
Your recovery middleware turns panics into a clean 500, but now the logs are full of 500s from cancelled uploads, and some responses arrive as a half-written JSON body with a 200 status. Name both bugs.
HardIt swallows
http.ErrAbortHandler, whichnet/httppanics deliberately to drop a connection without a stack trace, and it writes a 500 after the handler already sent the header — onceWriteHeaderhas run, the status is on the wire and a second call is a no-op that logssuperfluous response.WriteHeader.type recorder struct { http.ResponseWriter // embedded: ResponseController can still reach Flush/deadlines wrote bool } … - 16
In production the recovery handler logs
recovered: assignment to entry in nil map; on an older binary the same handler loggedrecovered: <nil>. What types isrecover()actually handing you, and which of them should you not be papering over?Hardrecover()returnsany— whatever was passed topanic— and for a runtime fault that value implementsruntime.Error, which means a bug in your code, not a condition to translate into a 500 and forget.func classify(r any) (kind string, err error) { switch v := r.(type) { case nil: return "", nil // not panicking (pre-1.21 panic(nil) also landed here) case runtime.Error: return "runtime", v // a bug in our code: nil map, bad index, nil deref …