Error Handling in Go: Error Values, Wrapping, errors.Is/As/AsType and panic
In Go an error is a return value: sentinel errors are recognised with errors.Is, custom error types with errors.As or Go 1.26's errors.AsType, and %w adds context while keeping the chain. ErrOutOfStock and ValidationError in Bookstore, collecting validation problems with errors.Join, the trap of wrapping a pointer error with %w and the Go 1.27 vet check that catches it, mapping errors to 404, a handler panic producing an empty reply, and a recover middleware.
Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the code also compiles with Go 1.26.11. errors.AsType arrived in Go 1.26. The go vet check that catches calls wrapping a pointer error with %w is specific to Go 1.27 and only runs in files whose go line is 1.27 or higher.
Guess First
Before we get to the order rules of Bookstore, the online bookstore used as the shared example across this site's series, here is a small program. We declare a validation error type, a function wraps a pointer to it with %w and returns it, and the caller asks with errors.As whether the error is a ValidationError:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string { return e.Field + ": " + e.Message }
func checkQuantity(q int) error {
if q <= 0 {
return fmt.Errorf("check quantity: %w", &ValidationError{Field: "quantity", Message: "must be positive"})
}
return nil
}
func main() {
err := checkQuantity(0)
var ve ValidationError
fmt.Println(err, errors.As(err, &ve))
}
It is clear that the error message will contain "quantity: must be positive". The question is the bool at the end of the line: does errors.As find the error? Second question: does go vet see a problem in this code? Think about it separately with the module's go line at 1.26.0 and at 1.27.0.
go run .
go vet . # go 1.26.0
go mod edit -go=1.27.0
go vet .
check quantity: quantity: must be positive false
main.go:17:38: %w wants operand of error type ValidationError, not pointer type *ValidationError (defeats errors.Is)
errors.As returned false. The error chain holds a *ValidationError, while we looked for a ValidationError; in Go these are two different types. Since the Error() method is declared with a value receiver, both satisfy the error interface and the compiler says nothing. go vet stayed silent while the go line was 1.26.0 and found the bug once it was 1.27.0. This check, added to Go 1.27's vet, depends on the file's language version. In this post we look at Go's approach to error handling by adding sentinel errors, custom error types and wrapping to Bookstore's order rules.
Return Values Instead of Exceptions
In Go, error is an interface with one method: Error() string. If an operation can fail, its last return value is an error, and the caller checks it right away. The try/catch we covered in the C# Fundamentals series post "Exception Handling in C#" relied on the exception walking up the call stack by itself. Go has no such walk: an error is a value, and you carry it up to the next layer yourself.
The Go team's reasoning in the FAQ is that exceptions make ordinary failures (a file that cannot be opened, a book that is out of stock) look exceptional and make control flow invisible. In Go code you can see where a function may return by looking at its lines. The price is clear too: if err != nil blocks make code longer. New syntax for this was debated for years; according to Robert Griesemer's Go blog post of 3 June 2025, the Go team stopped pursuing new error handling syntax for the foreseeable future because no consensus emerged. So if err != nil is here to stay, and the job is to write it well.
Writing it well rests on three rules. Either handle the error or add context and pass it up, never both. Add to the error the information the caller does not have: which order, which book. Make the situations the caller must decide on (no stock, no record) recognisable. We will apply these three rules to Bookstore in turn.
Sentinel Errors and errors.Is
For situations the caller must recognise, you declare constant error values at package level. These are called sentinel errors; io.EOF and sql.ErrNoRows in the standard library are the best known. I added a separate file to the orders package:
package orders
import "errors"
// Sentinel errors callers can test with errors.Is.
var (
ErrNotFound = errors.New("order not found")
ErrOutOfStock = errors.New("out of stock")
ErrInvalidTransition = errors.New("invalid status transition")
)
// ValidationError reports one invalid field of a request.
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return e.Field + ": " + e.Message
}
I also added ErrBookNotFound and a method that finds a book by ISBN to catalog:
// ErrBookNotFound is returned when no book has the requested ISBN.
var ErrBookNotFound = errors.New("book not found")
// ByISBN returns the book with the given ISBN.
func (Store) ByISBN(isbn string) (Book, error) {
for _, b := range books {
if b.ISBN == isbn {
return b, nil
}
}
return Book{}, fmt.Errorf("isbn %s: %w", isbn, ErrBookNotFound)
}
MarkPaid from 04 now returns an error instead of a bool, and memstore's ByID wraps the sentinel with fmt.Errorf("id %s: %w", id, orders.ErrNotFound) instead of plain text:
// MarkPaid moves a pending order to Paid.
func (o *Order) MarkPaid(now time.Time) error {
if o.Status != StatusPending {
return fmt.Errorf("pay order %s in status %v: %w", o.ID, o.Status, ErrInvalidTransition)
}
o.Status = StatusPaid
o.Touch(now)
return nil
}
You can map sentinels to C# exception types: ErrOutOfStock is the counterpart of what a catch (OutOfStockException) block catches. The difference is that it is a value, not a type, so it carries no extra information. We add extra information through wrapping.
Wrapping: Adding Context with %w
The rules for placing an order live in Service.Place. In 04 the caller supplied the price and title itself; now only the ISBN and quantity come in, and the rest is read from the catalog. For that I added to Service a small Catalog interface, declared on the consuming side as in 04:
// Catalog is what Service needs from the catalog.
type Catalog interface {
ByISBN(isbn string) (catalog.Book, error)
}
// Line is one requested book in a new order.
type Line struct {
ISBN string
Quantity int
}
// Place validates the request, checks stock and saves a new order.
func (s *Service) Place(ctx context.Context, customerID string, lines []Line) (*Order, error) {
if err := validate(customerID, lines); err != nil {
return nil, err
}
items := make([]Item, 0, len(lines))
for _, l := range lines {
b, err := s.books.ByISBN(l.ISBN)
if err != nil {
return nil, fmt.Errorf("place order: %w", err)
}
if b.Stock < l.Quantity {
return nil, fmt.Errorf("place order: %q has %d in stock, %d requested: %w",
b.Title, b.Stock, l.Quantity, ErrOutOfStock)
}
items = append(items, Item{ISBN: b.ISBN, Title: b.Title, UnitPrice: b.Price, Quantity: l.Quantity})
}
s.nextID++ // not safe for concurrent use yet; fixed in 08
now := s.now()
o := NewOrder(fmt.Sprintf("ORD-%d", s.nextID), customerID, now)
for _, it := range items {
o.AddItem(it, now)
}
if err := s.repo.Save(ctx, o); err != nil {
return nil, fmt.Errorf("save order %s: %w", o.ID, err)
}
return o, nil
}
The %w verb in fmt.Errorf writes the given error into the message and also keeps it inside the new error. That builds a chain: the outermost error carries the "place order" context, the inner one the "isbn ..." context, and the innermost the sentinel. errors.Is walks this chain from start to end. Had we used %v, the message would look the same, but the chain would break and errors.Is could not find the sentinel. The rule is simple: %w when the caller needs to recognise the error, %v when it is only for the message.
One more design detail: I increase the ID counter only after all checks pass. In my first attempt the counter came before the loop, and every order rejected for stock used up a number; the first successful order in the lab had become ORD-1003. Error paths are behaviour too, and need to be written with that in mind. The lab program tries them all in turn:
// Lab 05: sentinel errors, wrapping, errors.Is/As/AsType and errors.Join.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"time"
"bookstore/internal/catalog"
"bookstore/internal/orders"
"bookstore/internal/orders/memstore"
)
func main() {
ctx := context.Background()
repo := memstore.New()
svc := orders.NewService(repo, catalog.Store{})
// 1) Not enough stock: a wrapped sentinel error.
_, err := svc.Place(ctx, "ada", []orders.Line{{ISBN: "9780141439549", Quantity: 5}})
fmt.Println(err)
fmt.Println(errors.Is(err, orders.ErrOutOfStock), err == orders.ErrOutOfStock)
// 2) Unknown ISBN: the catalog's sentinel passes through two wraps.
_, err = svc.Place(ctx, "ada", []orders.Line{{ISBN: "9780000000000", Quantity: 1}})
fmt.Println(err)
fmt.Println(errors.Is(err, catalog.ErrBookNotFound))
// 3) Several validation problems at once: errors.Join.
_, err = svc.Place(ctx, "", []orders.Line{{ISBN: "9780441172719", Quantity: 0}})
fmt.Printf("%v\n---\n", err)
var ve orders.ValidationError
if errors.As(err, &ve) {
fmt.Println("errors.As:", ve.Field)
}
if v, ok := errors.AsType[orders.ValidationError](err); ok {
fmt.Println("errors.AsType:", v.Field, "-", v.Message)
}
// 4) The happy path, then an invalid status transition.
o, err := svc.Place(ctx, "ada", []orders.Line{{ISBN: "9780441172719", Quantity: 1}})
if err != nil {
fmt.Println("unexpected:", err)
return
}
err = o.MarkPaid(time.Now())
fmt.Println(o, err)
err = o.MarkPaid(time.Now())
fmt.Println(err, errors.Is(err, orders.ErrInvalidTransition))
// 5) Logging and returning: the same failure appears twice.
logger := log.New(os.Stdout, "", 0)
if err := placeAndLog(ctx, svc, logger); err != nil {
logger.Printf("handler: %v", err)
}
}
func placeAndLog(ctx context.Context, svc *orders.Service, logger *log.Logger) error {
_, err := svc.Place(ctx, "grace", []orders.Line{{ISBN: "9780547928227", Quantity: 1}})
if err != nil {
logger.Printf("place failed: %v", err) // logs here...
return err // ...and the caller logs it again
}
return nil
}
go run ./labs/05-errors
place order: "Middlemarch" has 4 in stock, 5 requested: out of stock
true false
place order: isbn 9780000000000: book not found
true
customerId: is required
items[0].quantity: must be positive
---
errors.As: customerId
errors.AsType: customerId - is required
order ORD-1001 (Paid): 1 items, 349.90 TRY <nil>
pay order ORD-1001 in status Paid: invalid status transition true
place failed: place order: "The Hobbit" has 0 in stock, 1 requested: out of stock
handler: place order: "The Hobbit" has 0 in stock, 1 requested: out of stock
The first two lines show both faces of wrapping. The message tells the reader what happened in one line: there are 4 copies of Middlemarch and 5 were requested. errors.Is found ErrOutOfStock in the chain, while the == comparison did not, because the outer error is now a different value. Do not use == with wrapped errors. On the third and fourth lines, the catalog's sentinel passed through two layers and was still recognised. In Go these messages start with a lowercase letter and do not end with a period, because they will be embedded in other messages.
Custom Error Types, errors.Join and errors.AsType
A sentinel is a value, so it carries no extra information. To carry which field is invalid and why, you declare a struct like ValidationError; any type with an Error() string method is an error. It is the counterpart of your own exception class derived from Exception in C#, minus the base class. The validation function collects every problem instead of stopping at the first one:
// validate collects every problem instead of stopping at the first one.
func validate(customerID string, lines []Line) error {
var errs []error
if customerID == "" {
errs = append(errs, ValidationError{Field: "customerId", Message: "is required"})
}
if len(lines) == 0 {
errs = append(errs, ValidationError{Field: "items", Message: "must not be empty"})
}
for i, l := range lines {
if l.Quantity <= 0 {
errs = append(errs, ValidationError{Field: fmt.Sprintf("items[%d].quantity", i), Message: "must be positive"})
}
}
return errors.Join(errs...) // nil when errs is empty
}
errors.Join (Go 1.20) combines several errors into one; it prints the messages on separate lines and keeps all of them in the chain. If the list is empty, it returns nil. That detail matters: remember the nil interface trap from 04. Had we written var e *ValidationError; return e, a non-nil error would come back even with no problems, and every order would be rejected. errors.Join returns a truly nil interface when there are no errors.
There are two ways to find a type in the chain. errors.As wants a target variable and writes the first match into it. errors.AsType, new in Go 1.26, does the same job with a generic function: func AsType[E error](err error) (E, bool). You no longer declare a variable and pass its address; the result and the bool come back in one line, and the 1.26 release notes present it as faster and type-safe. Both look inside the errors.Join result and find the first match, the customerId error. If you want every field, you walk the chain yourself; we will do that in 10, when we turn validation errors into an HTTP response.
Pointer or Value? Go 1.27's New vet Check
Back to Guess First. The Error() method of ValidationError has a value receiver. Following the method set rule from 04, value receiver methods belong to the method sets of both the value and the pointer; so both ValidationError and *ValidationError are errors. When the function returns &ValidationError{...}, the pointer type sits in the chain, while errors.As(err, &ve) looks for the value type and cannot find it. In tests everything looks right, because the message is the same; the bug appears when someone tries to decide based on the error's type.
The printf check in Go 1.27's vet looks for exactly this pattern: if the argument given to %w has type *E and E is already an error, it warns. Looking at the source, I saw that the check requires the file's Go version to be at least 1.27; that is why it stayed silent with the go 1.26.0 line. Bookstore is also at go 1.26.0, so it does not benefit from this check. Set a team rule: if an error type's method has a value receiver, use its value everywhere; if it has a pointer receiver, use its pointer everywhere. ValidationError is a small type that does not change, so in Bookstore we use it as a value.
Turning Errors into HTTP Responses
The service layer made the error recognisable; the HTTP layer turns it into a status code. I added the GET /api/books/{isbn} endpoint to the API. {isbn} is a path wildcard, and r.PathValue("isbn") gives its value; we will look at routing in detail in 09. I also made the fix I promised in 01: the error returned by Encode is no longer ignored.
mux.HandleFunc("GET /api/books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
book, err := store.ByISBN(r.PathValue("isbn"))
if errors.Is(err, catalog.ErrBookNotFound) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err != nil {
log.Printf("get book: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, book)
})
// writeJSON writes v as the response body. Once the body has started there
// is no way to change the status code, so an encoding error is only logged.
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("write json: %v", err)
}
}
curl -i http://127.0.0.1:52710/api/books/9780141439587
curl -i http://127.0.0.1:52710/api/books/9780000000000
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 87
{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":19900,"stock":7}
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 35
isbn 9780000000000: book not found
A known situation (no such book) became a 404 and the message went to the client. An unknown error is written to the log, and the client only gets a generic message; not leaking the details of internal errors is also right from a security point of view. In ASP.NET Core we would do this mapping centrally in an exception middleware. In Go it can be moved to a central place too; in 10 we will write a shared function that returns error responses in the Problem Details format (RFC 9457).
panic and recover: When and Where?
The closest thing to an exception in Go is panic. A panic stops the function, climbs up the call stack running the defers on the way, and ends the program unless someone stops it with recover. We saw this in 03 when we wrote to a nil map. But a panic is for programmer errors, not business rule failures: a nil pointer, an index out of range, a situation you said "cannot happen". No stock, record not found, invalid request are errors. The rule is short: a library should not push its caller into a panic because of a failure.
What happens when a handler panics in an HTTP server? I tried it with a lab: the same broken handler is registered once as is, and once wrapped with the Recover middleware I put in the internal/httpapi package:
// Lab 05: what a panic in a handler does with and without httpapi.Recover.
package main
import (
"log"
"net/http"
"bookstore/internal/httpapi"
)
func main() {
broken := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("price list not loaded") // a programmer error, not a request error
})
mux := http.NewServeMux()
mux.Handle("GET /plain", broken)
mux.Handle("GET /safe", httpapi.Recover(broken))
addr := "127.0.0.1:52711"
log.Printf("lab listening on http://%s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
curl -sS -i http://127.0.0.1:52711/plain
curl -i http://127.0.0.1:52711/safe
curl: (52) Empty reply from server
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 22
internal server error
The server's log (stack trace shortened, file paths replaced with placeholders):
2026/11/15 04:15:40 http: panic serving 127.0.0.1:54449: price list not loaded
goroutine 20 [running]:
net/http.(*conn).serve.func1()
/usr/local/go/src/net/http/server.go:1963 +0xac
panic({0x100ed31d0?, 0x100e840f8?})
/usr/local/go/src/runtime/panic.go:859 +0x120
main.main.func1({0x9f770881e0?, 0x9f770a013c?}, 0x9f770dda98?)
/home/user/bookstore/labs/05-recover/main.go:13 +0x2c
...
2026/11/15 04:15:40 panic serving GET /safe: price list not loaded
The process did not crash: net/http runs each connection in its own goroutine, recovers panics in that goroutine itself, logs the stack trace and closes the connection. But the client got no HTTP response at all; curl said "Empty reply from server". The Angular client sees this as a network error, not as a 500. The Recover middleware caught the panic, wrote a proper 500 response and left a single log line:
// Package httpapi holds HTTP helpers shared by the Bookstore commands.
package httpapi
import (
"log"
"net/http"
)
// Recover turns a panic in next into a 500 response and one log line.
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v) // net/http uses this value to abort a response on purpose
}
log.Printf("panic serving %s %s: %v", r.Method, r.URL.Path, v)
http.Error(w, "internal server error", http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
})
}
recover only works inside a deferred function and only catches a panic in the same goroutine. http.ErrAbortHandler is a special panic value that net/http uses to cut a response short on purpose; we panic with it again. In the API I wrapped the whole mux with this middleware: http.ListenAndServe(addr, httpapi.Recover(mux)). It is the Go counterpart of the exception middleware in the ASP.NET Core Web API series post "Global Error Handling in ASP.NET Core". We will add the stack trace to the log in 13, when we set up structured logging with log/slog. We will discuss the middleware chain itself in 09.
In Production
Every error should be logged once, at the place where it is handled. The last two lines of the lab wrote the same stock error twice: once in placeAndLog and once in the handler. In a real service that means doubled error counts in alerts and the same event showing up three or four times in the log. Lower layers add context and pass the error up; the top-level handler writes the log.
Error messages go to logs and sometimes to clients. Do not put passwords, tokens or personal data in them. In Bookstore, error messages contain the order ID and the book title; never the customer's address or card details.
Treat sentinels and error types as part of the package's public API. Once you export orders.ErrOutOfStock, other packages make decisions based on it. Renaming or removing it is a breaking change. Do not make errors the caller need not recognise into sentinels; a plain fmt.Errorf is enough.
Common Pitfalls
Swallowing an error with _. result, _ := strconv.ParseInt(s, 10, 64) compiles and quietly uses 0 on invalid input. If you ignore an error on purpose, write down why in a comment.
Messages without context. An error that climbs up five layers as "not found" does not tell the log what was not found. Each layer adds its own information: "place order: isbn 9780000000000: book not found".
Comparing a wrapped error with ==. As we saw in the lab, err == orders.ErrOutOfStock returns false. Use errors.Is.
Mixing up an error type's value and pointer. That is the errors.As bug from Guess First. Choose one form per type; once you raise the module's go line to 1.27, vet catches this mix-up for you.
Using panic for control flow. Someone coming from C# sometimes imitates the "throw an exception, catch it higher up" habit with panic and recover. In Go that code becomes unreadable, and defers run in unexpected order.
A Small Experiment
★☆☆ Add a step to labs/05-errors that calls repo.ByID with an order ID that does not exist, and show that errors.Is(err, orders.ErrNotFound) returns true. Then change the %w in memstore to %v and compare the result.
★★☆ Fix placeAndLog in the lab so that it adds context to the error and returns it instead of logging: fmt.Errorf("place for %s: %w", customerID, err). How many lines are left in the output, and what does the last one tell you?
★★★ Write a function ValidationErrors(err error) []ValidationError that collects every ValidationError in the combined error returned by validate into a slice. Hint: the error returned by errors.Join has an Unwrap() []error method; you can reach it with a type assertion.
We have covered the core parts of the language; one post of the language block remains. In the next post we move on to generics and iterators: type parameters and constraints, how they differ from C# generics, range-over-func with iter.Seq, and the type parameters that methods gained in Go 1.27. As the example we will write a generic Page[T] pagination type and an iterator that groups books by author.