Errors & Panics
Irbisa · cheatsheetSeptember 19, 2026

Errors & Panics

error values, wrapping with %w, errors.Is/As, panic and recover

Junior Developer16 itemscompressed for a skim
  1. 01

    A table test asserts err == errors.New("user not found") and fails even though the printed message is character-for-character identical. What is errors.New handing back?

    Easy

    errors.New allocates a new *errorString on 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
    …
  2. 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?

    Easy

    Error strings are fragments, not sentences: lowercase, no trailing punctuation, no failed to or error: prefix — and context is added with fmt.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
    …
  3. 03

    A CLI writes its report through a bufio.Writer with defer w.Flush() and ends the error path with log.Fatalf. The output file is truncated — but only on runs that failed. What is eating the tail?

    Easy

    log.Fatalf calls os.Exit(1), and os.Exit terminates 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)
    	}
    …
  4. 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 does recover actually require?

    Easy

    recover returns the panic value only when it is called directly by a deferred function while that goroutine is panicking — everywhere else it returns nil and 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
    …
  5. 05

    A cleanup commit changed one fmt.Errorf("query user: %w", err) to %v. Nothing failed in CI, and three packages away errors.Is(err, sql.ErrNoRows) silently started returning false. What does %w do that %v does not?

    Medium

    %w makes the new error wrap the operand — it keeps a reference to it and gives the result an Unwrap() error method — while %v only renders its text, cutting the chain that errors.Is and errors.As walk.

    // %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)
    …
  6. 06

    Storage returns fmt.Errorf("get order %d: %w", id, ErrNotFound) and the handler still answers 500, because it compares err == ErrNotFound. What does errors.Is do that == cannot, and where does its walk stop?

    Medium

    errors.Is walks the unwrap chain — at every level it compares against the target and asks that error's own Is(error) bool method — 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
    …
  7. 07

    A handler needs the offending field out of a *ValidationError, but ve, 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?

    Medium

    errors.As — it walks the same chain as errors.Is and 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 {
    …
  8. 08

    func do() error { e := validate(); return e } where validate() *ValidationError returns nil on success. Callers report err != nil with the message <nil>. What is inside that interface value?

    Medium

    An interface value is a (type, value) pair and is nil only when both halves are nil — returning a nil *ValidationError as an error stores the type *ValidationError with a nil pointer, so err != nil is 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 == "" {
    …
  9. 09

    Your package defines type NotFound struct{ Key string } with func (e *NotFound) Error() string. Half the codebase calls errors.As(err, &nf) with var nf NotFound and it panics at runtime. What rule about receivers is biting, and how should the type be shaped?

    Medium

    A method with a pointer receiver puts Error in *NotFound's method set only — so NotFound is not an error, and errors.As rejects that target with errors: *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. 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?

    Medium

    errors.Join — it returns one error holding a slice of errors, exposed through Unwrap() []error, which errors.Is and errors.As search depth-first across every branch.

    var (
    	ErrMissingField = errors.New("missing field")
    	ErrBadFormat    = errors.New("bad format")
    )
    
    func Validate(c Config) error {
    …
  11. 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?

    Medium

    Close on a writer is where the final flush happens and its error is reported — a bare defer 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. 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?

    Medium

    Panic 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. 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?

    Hard

    recover only 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. 14

    Three services now match on your package's exported ErrNotFound, ErrConflict and ErrExpired, 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?

    Hard

    An exported sentinel is part of your API, exactly like a function signature: every value a caller can errors.Is against 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. 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.

    Hard

    It swallows http.ErrAbortHandler, which net/http panics deliberately to drop a connection without a stack trace, and it writes a 500 after the handler already sent the header — once WriteHeader has run, the status is on the wire and a second call is a no-op that logs superfluous response.WriteHeader.

    type recorder struct {
    	http.ResponseWriter // embedded: ResponseController can still reach Flush/deadlines
    
    	wrote bool
    }
    …
  16. 16

    In production the recovery handler logs recovered: assignment to entry in nil map; on an older binary the same handler logged recovered: <nil>. What types is recover() actually handing you, and which of them should you not be papering over?

    Hard

    recover() returns any — whatever was passed to panic — and for a runtime fault that value implements runtime.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
    …