Skip to main content

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 panic to 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 / throwif err != nil { return err }
Exception type hierarchyerror interface / sentinel errors / custom types
InnerExceptionfmt.Errorf("%w") / errors.Unwrap
catch (X ex) whenerrors.Is / errors.As
finally / usingdefer
AggregateExceptionerrors.Join
throw new ArgumentNullExceptionValidate the value and return an error (programmer bugs use panic)
Exception.StackTraceNot 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 exceptionpanic / 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 err first
  • 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.As is a pointer to the target type (&ve, the address of a *ValidationError variable).
  • If a matching type is found in the chain, it is assigned to ve and true is returned.

Choosing between them​

GoalTool
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 callerfmt.Errorf("...: %w", err)
Do not want to expose details to the callerWrap 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 defer calls run in last-in, first-out (LIFO) order.
  • The arguments of a defer are evaluated when the defer statement executes.
  • defer inside 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.MustCompile for a required regular expression, or template.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/http recovers 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 error at 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 recover and 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 to or error: 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​

  1. 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.

  2. Errors can be ignored: The compiler does not warn when you ignore a returned error. Detect it with errcheck (included in golangci-lint).

  3. Comparing with ==: A wrapped error does not match with ==. Use errors.Is / errors.As.

  4. %w vs %v: %v breaks the chain and errors.Is stops working.

  5. Putting a nil concrete type into error: The following code makes err != nil true.

    func doSomething() error {
    var e *MyError = nil
    return e // the error interface holds (type=*MyError, value=nil), which is not a nil interface
    }

    When there is no error, always return nil directly. Do not return a *MyError variable as an error.

  6. 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.

  7. The errors.As target must be a non-nil pointer: Passing the variable itself, as in errors.As(err, ve), panics. Pass the address of the variable, as in &ve (go vet also detects this).

  8. Do not use panic as if it were an exception: Panicking on external input or I/O failures brings down the server.

  9. Errors inside defer: defer f.Close() discards the error. Check the result for writable resources.

  10. Custom error types usually use pointer receivers: Keep the errors.As target as *ValidationError too. 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 explicit if err != nil. There is no implicit exception control flow.
  • Wrap with context using fmt.Errorf("...: %w", err), and inspect with errors.Is (sentinel checks) and errors.As (extracting custom types). Do not use ==.
  • Clean up with defer. Combine multiple errors with errors.Join.
  • Limit panic / recover to 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).

References​