Error Handling
For developers with .NET (C#) experience, this guide covers the error type, wrapping, errors.Is / errors.As, and when to use panic / recover.
1. Why Go Avoids Exceptions
Go has no try / catch / throw. A function that can fail returns an error as its last return value, and the caller checks it explicitly.
f, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("open config: %w", err)
}
defer f.Close()
The design has these goals:
- Visible control flow: You can see in the code which calls can fail (in C#, it is hard to tell where an exception may come from)
- Errors are values: Like any other value, you can store, compare, transform, and aggregate them
- Exceptions are for bugs: Limit
panicto unrecoverable situations
The error interface
error is a built-in interface defined as follows.
type error interface {
Error() string
}
Any type with an Error() string method can be used as an error. There is no giant base class like Exception, and stack traces are not attached by default.
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| try / catch / throw | if err != nil { return err } |
| Exception type hierarchy | error interface / sentinel errors / custom types |
| InnerException | fmt.Errorf("%w") / errors.Unwrap |
| catch (X ex) when | errors.Is / errors.As |
finally / using | defer |
AggregateException | errors.Join |
throw new ArgumentNullException | Validate the value and return an error (programmer bugs use panic) |
Exception.StackTrace | Not attached by default (add with slog or external libraries if needed) |
Result<T> pattern (OneOf, etc.) | Multiple return values (T, error) are the standard form |
Environment.FailFast / unhandled exception | panic / log.Fatal |
3. Creating and Returning Errors
Creating errors
import (
"errors"
"fmt"
)
err1 := errors.New("something went wrong") // fixed message
err2 := fmt.Errorf("user %d not found", 42) // formatted
err3 := fmt.Errorf("load user: %w", err2) // wraps the original error
The standard pattern
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
return &cfg, nil
}
Key points:
- On error, return zero values (or nil) for the other results. Callers check
errfirst - Narrow the scope with
if err := ...; err != nil { } - Do not swallow errors. If you intentionally ignore one, make that explicit, such as
_ = f.Close()
4. Wrapping and Inspecting Errors
Wrapping with %w (like InnerException)
%w in fmt.Errorf wraps the original error so you can add context as it travels up the call stack. With %v or %s, the original error becomes just a string and can no longer be inspected.
return fmt.Errorf("get user %d: %w", id, err) // inspectable
return fmt.Errorf("get user %d: %v", id, err) // not inspectable (breaks the chain)
Sentinel errors and errors.Is
Compare against a predefined error value (a sentinel error) that represents a specific failure. It follows the chain, so wrapped errors still match.
var ErrNotFound = errors.New("not found")
func FindUser(id int) (*User, error) {
// ...
return nil, fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
user, err := FindUser(1)
if errors.Is(err, ErrNotFound) {
// treat as 404, for example
}
Always use errors.Is, not err == ErrNotFound. A wrapped error does not match with ==.
Standard library examples: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled, context.DeadlineExceeded
Custom error types and errors.As
An error that carries extra information (fields) is defined as a type implementing the Error() method. Use errors.As to extract it by type (like catch (X ex)).
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s: %s", e.Field, e.Message)
}
func Validate(u User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "required"}
}
return nil
}
err := fmt.Errorf("create user: %w", Validate(u))
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field, ve.Message) // name required
}
- The second argument of
errors.Asis a pointer to the target type (&ve, the address of a*ValidationErrorvariable). - If a matching type is found in the chain, it is assigned to
veandtrueis returned.
Choosing between them
| Goal | Tool |
|---|---|
| Only need to know "is it this failure?" | Sentinel error + errors.Is |
| Need failure details (fields, codes, etc.) | Custom type + errors.As |
| Pass context to the caller | fmt.Errorf("...: %w", err) |
| Do not want to expose details to the caller | Wrap with %v (breaks the chain) |
When you use %w in a public API, the inner error becomes part of your API. To avoid leaking implementation details (for example, the error of a specific DB driver), convert it into your own error at that boundary.
errors.Join (like AggregateException)
Combines several errors (Go 1.20 and later). errors.Is / errors.As also work against each element of the combined error.
var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
}
}
return errors.Join(errs...) // nil if errs is empty
5. Cleanup with defer
defer registers work to run when the function returns. It corresponds to finally and using.
func Process(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // always runs when the function exits
// ...
return nil
}
- Multiple
defercalls run in last-in, first-out (LIFO) order. - The arguments of a
deferare evaluated when thedeferstatement executes. deferinside a loop accumulates until the function ends. Extract the loop body into its own function.Close()on a writable resource can return an error. Decide whether it is safe to ignore, and if not, reflect it through a named return value.
func WriteFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = f.Write(data)
return err
}
6. When panic / recover Is Acceptable
panic looks like an exception, but it is not used for normal error handling.
When panic is appropriate
- To signal a programmer bug (unreachable code, a broken invariant)
- When initialization at startup fails and the program cannot continue (for example,
regexp.MustCompilefor a required regular expression, ortemplate.Must) - Runtime panics (nil pointer dereference, slice out of range, writing to a nil map) generally mean a bug
When recover is appropriate
recover only works inside a defer and catches a panic. It is mainly used at these boundaries:
- HTTP servers: One request's panic should not bring down the whole process (
net/httprecovers by default and closes the connection) - Top of a goroutine: A panic in a goroutine crashes the whole process, so catch it at the worker's entry point and record it as an error
- Inside libraries: Use panic internally (for example, in recursive processing) and convert it to an
errorat the public API boundary
func SafeHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"panic", rec,
"stack", string(debug.Stack()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
When not to use them
- Expressing expected failures such as "file not found," "invalid input," or "network error" with panic
- Swallowing a panic with
recoverand carrying on (state may be corrupted) - Control flow (as with using exceptions for flow control in C#, this is an anti-pattern)
7. Writing Error Messages and Logs
Message conventions
- Start with a lowercase letter and do not end with punctuation (messages get concatenated into other messages)
- Describe what operation was being attempted when it failed. Redundant prefixes such as
failed toorerror:are unnecessary - Each layer adds only its own context
// Bad
return fmt.Errorf("Failed to get user: %w", err)
// Good
return fmt.Errorf("get user %d: %w", id, err)
When every layer wraps, the final message becomes a human-readable call path such as handle request: get user 42: query users: connection refused. It serves in place of a stack trace.
Log once
For an error, choose either to handle it or return it. Do not do both (log and then return). Doing both logs the same error repeatedly in every layer.
// Bad: logs and returns in every layer
if err != nil {
slog.Error("query failed", "err", err)
return err
}
// Good: intermediate layers add context and return. Log once at the top (handler / main)
if err != nil {
return fmt.Errorf("query users: %w", err)
}
Mapping to HTTP status codes
Centralize error classification in one place at the top.
func writeError(w http.ResponseWriter, err error) {
var ve *ValidationError
switch {
case errors.As(err, &ve):
http.Error(w, ve.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
default:
slog.Error("unhandled error", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Do not return internal error details (SQL, file paths, and so on) to clients. Write them only to the log.
8. Common Pitfalls for C# Developers
-
Lots of repeated
if err != nil: It is verbose, but it is the intended design in Go. Do not hide it behind helper functions; return errors with appropriate context at each site. -
Errors can be ignored: The compiler does not warn when you ignore a returned
error. Detect it witherrcheck(included in golangci-lint). -
Comparing with
==: A wrapped error does not match with==. Useerrors.Is/errors.As. -
%wvs%v:%vbreaks the chain anderrors.Isstops working. -
Putting a nil concrete type into
error: The following code makeserr != niltrue.func doSomething() error {var e *MyError = nilreturn e // the error interface holds (type=*MyError, value=nil), which is not a nil interface}When there is no error, always return
nildirectly. Do not return a*MyErrorvariable as anerror. -
No stack traces like exceptions: Trace the call path with the context messages you add via
%w. A stack trace is printed only on panic. -
The
errors.Astarget must be a non-nil pointer: Passing the variable itself, as inerrors.As(err, ve), panics. Pass the address of the variable, as in&ve(go vetalso detects this). -
Do not use
panicas if it were an exception: Panicking on external input or I/O failures brings down the server. -
Errors inside
defer:defer f.Close()discards the error. Check the result for writable resources. -
Custom error types usually use pointer receivers: Keep the
errors.Astarget as*ValidationErrortoo. Mixing value and pointer forms is a common cause of non-matches.
9. Code Example
C#
public class UserNotFoundException(int id) : Exception($"user {id} not found");
public User GetUser(int id)
{
try
{
return _repo.Find(id) ?? throw new UserNotFoundException(id);
}
catch (SqlException ex) when (ex.Number == 1205)
{
throw new RetryableException("deadlock", ex);
}
}
// Caller
try
{
var user = service.GetUser(1);
}
catch (UserNotFoundException)
{
return NotFound();
}
Go
var ErrUserNotFound = errors.New("user not found")
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
u, err := s.repo.Find(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user %d: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}
// Caller
user, err := service.GetUser(ctx, 1)
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("get user failed", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_ = user
10. Summary
- Go errors are values. Return
(T, error)and handle with an explicitif err != nil. There is no implicit exception control flow. - Wrap with context using
fmt.Errorf("...: %w", err), and inspect witherrors.Is(sentinel checks) anderrors.As(extracting custom types). Do not use==. - Clean up with
defer. Combine multiple errors witherrors.Join. - Limit
panic/recoverto bugs, initialization failures, and process boundaries (HTTP handlers, goroutine entry points). - Either log an error or return it. Log once at a single place at the top, and do not leak internal details to clients.
- Detect swallowed errors mechanically with static analysis such as
errcheck(see Tooling and Linting).