Errors in Go: why there are no exceptions and how to write clear code

Error handling in Go: why errors are explicit, how to use wrapping, sentinel errors and custom errors. No exceptions, with clarity.

Cover for Errors in Go: why there are no exceptions and how to write clear code

The first time I saw if err != nil repeated twenty times in a file, I thought error handling in Go was madness. I came from Java and Kotlin, where a try-catch would sort things out, and from Python, where exceptions fly through the air and you’ll catch them somewhere in a middleware. Seeing that Go forced you to check every error, on every line, felt like a step back twenty years.

Now I think it’s one of the best design decisions in the language.

Not because it’s elegant. It isn’t. But because it forces you to make the real flow of the program visible. Every point where something can fail is right there, in front of you, not hiding behind a call stack that nobody inspected. And when you’re debugging a production issue at three in the morning, that visibility is worth more than all the elegance in the world.

This article is about that: how error handling works in Go, why it’s designed this way, and how to write code that is clear without driving yourself mad with verbosity.


Why Go has no exceptions

The absence of exceptions in Go is not an oversight. It’s a deliberate and documented decision. The language creators (Rob Pike, Ken Thompson, Robert Griesemer) considered that exceptions create hidden control flows that make code harder to reason about.

In Java or Python, when a function throws an exception, that exception can propagate through ten levels of the call stack until someone catches it. Or nobody catches it and the program crashes. The problem is that between the point of throwing and the point of catching, there is code that doesn’t know something has failed. Resources that aren’t released, states left inconsistent, transactions left half-done.

Go takes a radical position: if something can fail, the calling function has to know immediately. No implicit propagation. No throws in the signature. No try-catch blocks wrapping fifteen lines of heterogeneous code. There is a return value that says “this has failed” and you decide what to do with it.

file, err := os.Open("config.yaml")
if err != nil {
    // Here you decide: do you return the error? Use a default value? Log it?
    return fmt.Errorf("could not open configuration: %w", err)
}
defer file.Close()

This is verbose. Absolutely. But it has a property that exceptions don’t have: the error flow is local. You don’t need to trace the call stack to know what happens when os.Open fails. It’s right there, in the three following lines.

If you come from a language with exceptions, this will make you uncomfortable at first. That’s normal. But I ask you to give it a real chance before dismissing it. In Effective Go explained I talk more about the general philosophy of the language, and error handling is perhaps where it shows the most.


The error interface: simplicity by design

In Go, an error is not a special class, nor a magic runtime type. It’s an interface with a single method:

type error interface {
    Error() string
}

Any type that implements the Error() string method is an error. That’s all. No exception hierarchies, no Throwable, no BaseException. An error is anything that can describe itself as text.

This has important consequences. The first is that you can create trivial errors with errors.New or fmt.Errorf:

import "errors"

var ErrNotFound = errors.New("resource not found")

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("invalid ID: %d", id)
    }
    // ...
}

The second is that you can create complex error types when you need them, with additional fields, structured context and whatever is needed. But you’re not forced to do it. The interface is so minimal that the barrier to entry for creating and handling errors is practically zero.

This fits with Go’s philosophy: simple abstractions that compose well are more valuable than complex abstractions that cover every case.


The basic pattern: if err != nil

This is the pattern you’ll write a hundred times a day in Go. And that’s no exaggeration:

result, err := doSomething()
if err != nil {
    return err
}
// Use result with the certainty that there is no error

The function returns two values: the result and an error. If the error is not nil, something has failed. If it is nil, you can use the result with confidence.

What seems repetitive is actually a guarantee: every failure point has explicit handling. No silent errors. No exceptions propagating out of control. No “I’ll catch it further up”.

Let’s look at a more realistic example. A function that reads a configuration file, parses it as JSON and returns a struct:

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading configuration: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing configuration: %w", err)
    }

    if cfg.Port == 0 {
        return nil, errors.New("port cannot be zero")
    }

    return &cfg, nil
}

Three operations that can fail, three explicit checks. Each one with a message that tells you exactly what was happening when it failed. When you see reading configuration: open config.yaml: no such file or directory in the logs, you’ll know exactly where to look.

An important detail: notice that I reuse the err variable inside the if. In Go it’s idiomatic to declare err in the if scope when you only need it for the check:

if err := json.Unmarshal(data, &cfg); err != nil {
    return nil, fmt.Errorf("parsing configuration: %w", err)
}

This keeps the scope of err limited to the if block, which is cleaner when you have multiple consecutive checks.


Error wrapping with fmt.Errorf and %w

One of the most important advances in Go error handling came in Go 1.13 with error wrapping. Before that, if you wanted to add context to an error, you lost the information of the original error:

// Before Go 1.13: the original error is lost
return fmt.Errorf("connection failed: %v", err)

With %v you create a new error whose message includes the text of the original error, but the error chain is broken. You can’t inspect what type the original error was.

The %w verb fixes this:

// With wrapping: the original error is preserved
return fmt.Errorf("connecting to the database failed: %w", err)

Now the resulting error contains the original error wrapped. You can inspect it with errors.Is or errors.As, traverse the error chain and make decisions based on the root error.

The rule is simple: use %w when you want the caller of your function to be able to inspect the original error. Use %v when you want to encapsulate the error and hide implementation details.

A practical example. Imagine a service that accesses a database:

func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("getting user %d: %w", id, err)
    }
    return user, nil
}

Here we use %w because we want the upper layer to be able to distinguish if the error is “not found” or a connection failure. But if you were in a public API layer and didn’t want to expose internal database details, you’d use %v to create an opaque error.

Wrapping can be chained. If the repository also wrapped the error with %w, you end up with a chain like:

getting user 42: querying database: dial tcp 127.0.0.1:5432: connection refused

Each layer adds its context, and the root error is still accessible through the functions of the errors package.


errors.Is and errors.As: inspecting error types

With wrapping comes the need to inspect wrapped errors. For that, errors.Is and errors.As exist.

errors.Is: checking identity

errors.Is traverses the chain of wrapped errors and checks if any matches a specific error:

import (
    "errors"
    "os"
)

_, err := os.Open("config.yaml")
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("File does not exist, using default configuration")
} else if err != nil {
    return fmt.Errorf("unexpected error opening config: %w", err)
}

The crucial thing here is that errors.Is works through layers of wrapping. If someone wrapped os.ErrNotExist with three layers of fmt.Errorf("...: %w", err), errors.Is still finds it. This is why you should always use errors.Is instead of comparing directly with ==:

// BAD: doesn't work with wrapped errors
if err == os.ErrNotExist {

// GOOD: works through wrapping
if errors.Is(err, os.ErrNotExist) {

errors.As: checking type

errors.As is the equivalent for custom error types. It traverses the error chain and checks if any is of the type you’re looking for:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Printf("Error at path: %s, operation: %s\n", pathErr.Path, pathErr.Op)
}

errors.As doesn’t just check the type, it assigns the value to the pointer you pass to it. It’s like a type assertion but works through layers of wrapping.

A common pattern in HTTP APIs is to define your own error type and use errors.As in the handler to decide the response code:

type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string {
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// In the handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
    result, err := service.DoSomething(r.Context())
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, appErr.Code)
            return
        }
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(result)
}

Sentinel errors: package-level error values

Sentinel errors are error variables declared at package level that represent specific, known error conditions. You’ve seen them in the standard library:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("conflict")
)

The convention in Go is clear: sentinel errors start with Err (except io.EOF, which is a historical exception). They are values, not types. You compare them with errors.Is, not with errors.As.

They are useful when your package needs to expose error conditions that consumers will want to check:

package user

var (
    ErrNotFound      = errors.New("user not found")
    ErrAlreadyExists = errors.New("user already exists")
    ErrInvalidEmail  = errors.New("invalid email")
)

func (s *Service) Create(ctx context.Context, u *User) error {
    existing, err := s.repo.FindByEmail(ctx, u.Email)
    if err != nil && !errors.Is(err, ErrNotFound) {
        return fmt.Errorf("checking existing email: %w", err)
    }
    if existing != nil {
        return ErrAlreadyExists
    }
    // ...
}

And in the layer that consumes this service:

err := userService.Create(ctx, newUser)
if errors.Is(err, user.ErrAlreadyExists) {
    http.Error(w, "Email is already registered", http.StatusConflict)
    return
}
if err != nil {
    http.Error(w, "Internal error", http.StatusInternalServerError)
    return
}

When to use sentinel errors

Use sentinel errors when:

  • The error condition is predictable and known (not found, already exists, unauthorized).
  • Consumers of your package need to make decisions based on the type of error.
  • The error doesn’t need additional context beyond its identity.

Don’t use sentinel errors for:

  • Errors you’re only going to log without making decisions.
  • Errors with dynamic context (like a user ID or a filename).
  • Internal errors that shouldn’t escape the package.

A sentinel error is part of the public API of your package. Treat it as such. Changing or removing it breaks consumers.


Custom error types: implementing the error interface

When you need more information than a simple string, you can create your own error type. You only need to implement the Error() string method:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field '%s': %s", e.Field, e.Message)
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return &ValidationError{Field: "name", Message: "cannot be empty"}
    }
    if !strings.Contains(u.Email, "@") {
        return &ValidationError{Field: "email", Message: "invalid format"}
    }
    return nil
}

The consumer can inspect the fields of the error:

err := ValidateUser(user)
if err != nil {
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        fmt.Printf("Field '%s' invalid: %s\n", valErr.Field, valErr.Message)
    }
}

Errors with multiple fields

For APIs, a pattern that works well is an error type that includes HTTP code, user-facing message and the internal error:

type APIError struct {
    StatusCode int    `json:"-"`
    Message    string `json:"message"`
    Detail     string `json:"detail,omitempty"`
    Internal   error  `json:"-"`
}

func (e *APIError) Error() string {
    if e.Internal != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Internal)
    }
    return e.Message
}

func (e *APIError) Unwrap() error {
    return e.Internal
}

// Constructors for common errors
func NewNotFoundError(resource string, id any) *APIError {
    return &APIError{
        StatusCode: http.StatusNotFound,
        Message:    fmt.Sprintf("%s not found", resource),
        Detail:     fmt.Sprintf("ID: %v", id),
    }
}

func NewInternalError(err error) *APIError {
    return &APIError{
        StatusCode: http.StatusInternalServerError,
        Message:    "Internal server error",
        Internal:   err,
    }
}

Notice the Unwrap() error method. This allows errors.Is and errors.As to traverse the error chain through your custom type. If your error type wraps another error, always implement Unwrap.


Common mistakes: what you shouldn’t do

After working with Go for a while, you start recognising error handling patterns that seem reasonable but end up causing problems. These are the most frequent.

Ignoring errors

The worst mistake. And the Go compiler warns you if you ignore a return value, but there are ways to silence it:

// BAD: explicitly ignoring the error
result, _ := doSomething()

// BAD: not checking the error from Close
file.Close()

// GOOD: handle the error, even if it's just logging it
result, err := doSomething()
if err != nil {
    log.Printf("doSomething failed: %v", err)
    // decide what to do
}

// GOOD: check the error from Close in defer
defer func() {
    if err := file.Close(); err != nil {
        log.Printf("error closing file: %v", err)
    }
}()

The _ to discard errors is only acceptable when you’re absolutely sure the error can’t happen or doesn’t affect you. In practice, that’s almost never the case.

Over-wrapping: adding redundant context

Adding context to errors is good. Adding too much context creates unreadable messages:

// BAD: each layer repeats information
return fmt.Errorf("error in GetUser: failed to get user: %w", err)
// Result: "error in GetUser: failed to get user: querying DB: dial tcp..."

// GOOD: add only the new context
return fmt.Errorf("getting user %d: %w", id, err)
// Result: "getting user 42: querying DB: dial tcp..."

The function name is already in the stack trace if you need it. What adds value is the context the function knows: IDs, filenames, specific operations. Don’t repeat the function name or use generic phrases like “error in” or “failed to”.

Using panic as an exception system

panic exists in Go, but it’s not an exception system. It’s for unrecoverable situations where the program cannot continue:

// BAD: using panic for business errors
func GetUser(id int) *User {
    user, err := db.Find(id)
    if err != nil {
        panic(err) // DON'T do this
    }
    return user
}

// GOOD: return the error
func GetUser(id int) (*User, error) {
    user, err := db.Find(id)
    if err != nil {
        return nil, fmt.Errorf("finding user %d: %w", id, err)
    }
    return user, nil
}

The only legitimate uses of panic are:

  • Programming errors that indicate a bug (out of bounds index, nil pointer in an impossible place).
  • Program initialisation that fails (can’t connect to the database on startup).
  • Tests, where panic is equivalent to t.Fatal.

If you’re using panic and recover as throw and catch, you’re fighting against the language. To understand more about defer, panic and recover, I recommend consulting the official Go documentation.

Not checking errors in goroutines

This one is subtle but dangerous. If you launch a goroutine and the code inside fails, the error is silently lost:

// BAD: lost error
go func() {
    result, err := doExpensiveWork()
    if err != nil {
        log.Printf("error: %v", err) // Who sees this log?
        return
    }
    processResult(result)
}()

// GOOD: communicate the error through a channel
errCh := make(chan error, 1)
go func() {
    result, err := doExpensiveWork()
    if err != nil {
        errCh <- fmt.Errorf("expensive work: %w", err)
        return
    }
    processResult(result)
    errCh <- nil
}()

if err := <-errCh; err != nil {
    // Handle the error
}

If you’re working with multiple goroutines, errgroup from the golang.org/x/sync/errgroup package is the right tool:

g, ctx := errgroup.WithContext(ctx)

g.Go(func() error {
    return fetchUsers(ctx)
})

g.Go(func() error {
    return fetchOrders(ctx)
})

if err := g.Wait(); err != nil {
    return fmt.Errorf("parallel operations failed: %w", err)
}

Practical patterns for APIs: errors in handlers and services

If you’re building a REST API with Go, error handling is where you’ll notice the biggest difference from other languages. Instead of a global middleware that catches exceptions, you need a deliberate strategy.

Pattern 1: handler with error switch

The most direct approach. The handler calls the service and decides the HTTP code based on the error:

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }

    user, err := h.service.GetUser(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, ErrNotFound):
            http.Error(w, "User not found", http.StatusNotFound)
        case errors.Is(err, ErrUnauthorized):
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
        default:
            log.Printf("error getting user %d: %v", id, err)
            http.Error(w, "Internal error", http.StatusInternalServerError)
        }
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

Simple and explicit. But if you have twenty handlers, you repeat the same switch in each one.

Pattern 2: handler wrapper with error type

A more scalable approach is to define a handler type that returns error and a middleware that translates it:

type AppHandler func(w http.ResponseWriter, r *http.Request) error

func HandleErrors(h AppHandler) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        err := h(w, r)
        if err == nil {
            return
        }

        var apiErr *APIError
        if errors.As(err, &apiErr) {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(apiErr.StatusCode)
            json.NewEncoder(w).Encode(apiErr)
            return
        }

        log.Printf("unhandled error: %v", err)
        http.Error(w, "Internal error", http.StatusInternalServerError)
    }
}

// Usage
mux.HandleFunc("GET /users/{id}", HandleErrors(func(w http.ResponseWriter, r *http.Request) error {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        return &APIError{StatusCode: 400, Message: "Invalid ID"}
    }

    user, err := h.service.GetUser(r.Context(), id)
    if err != nil {
        return err // The wrapper decides the HTTP code
    }

    w.Header().Set("Content-Type", "application/json")
    return json.NewEncoder(w).Encode(user)
}))

This pattern centralises the error-to-HTTP translation logic without losing the explicitness of error handling in each handler.

Pattern 3: errors in service layers

In the service layer, the rule is: add context and propagate. Don’t translate errors to HTTP codes here. That’s the handler’s responsibility.

func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("querying user %d: %w", id, err)
    }

    if !user.Active {
        return nil, ErrUnauthorized
    }

    return user, nil
}

Notice something important: the service layer transforms sql.ErrNoRows (an implementation detail of the database) into ErrNotFound (a domain concept). This decouples your business logic from the persistence technology. If tomorrow you switch PostgreSQL for MongoDB, the handlers don’t need to change.


When NOT to overcomplicate error handling

After all of the above, it’s tempting to build an elaborate error architecture with custom types, wrapping in every layer and an error code system worthy of an RFC. Don’t.

Most Go applications need very little:

  • Sentinel errors for two or three known conditions (ErrNotFound, ErrAlreadyExists).
  • Wrapping with %w to maintain the context chain.
  • One custom error type if you’re building an API and need to map errors to HTTP codes.

And that’s it. Nothing more.

Don’t create an error hierarchy like Java’s with IOException, FileNotFoundException, SocketException. Go isn’t designed for that. An error is a value that describes what failed. The simpler your error system, the easier it will be to work with.

For scripts, CLIs and internal tools, fmt.Errorf with %w is often all you need. There’s no need to create sentinel errors if nobody is going to inspect them. No need to create custom types if the error string is enough context.

The rule I follow: start with fmt.Errorf and %w. Add sentinel errors when a consumer needs to make decisions. Add custom types when you need structured fields. And when you write tests in Go, the simplicity of errors as values makes testing error conditions much more direct than mocking exceptions.


Comparison with exceptions (Java/Python/Kotlin)

I come from Kotlin and Java. I work with Python daily. I know all three exception systems and can say from experience that Go’s approach is neither better nor worse. It’s different, and has clear trade-offs.

Java: checked and unchecked exceptions

Java has the most formal exception system of the three. Checked exceptions force you to handle them or declare them in the method signature:

public User getUser(int id) throws UserNotFoundException, DatabaseException {
    // ...
}

In theory, this gives similar guarantees to Go: you know what can fail. In practice, most teams end up wrapping everything in RuntimeException to avoid declaring exceptions in every method in the chain. Checked exceptions were a good idea that the ecosystem rejected.

Python: exceptions as control flow

Python uses exceptions for everything. Not just for errors:

try:
    value = my_dict["key"]
except KeyError:
    value = "default"

This is idiomatic in Python (“easier to ask forgiveness than permission”). But it means any function can throw any exception at any time, and the only way to know is to read the documentation (which may not exist) or the source code.

Kotlin: unchecked exceptions with Result

Kotlin eliminated checked exceptions and trusts the programmer to handle errors. It has a Result<T> type that approaches the Go way:

fun getUser(id: Int): Result<User> {
    return runCatching { repository.findById(id) }
}

// Usage
getUser(42)
    .onSuccess { user -> println(user) }
    .onFailure { error -> println("Error: $error") }

But Result is optional. Most Kotlin code still uses exceptions.

The real trade-off

AspectGoJava (checked)Python / Kotlin
Error visibilityAlways visibleVisible in signatureInvisible without documentation
VerbosityHighMedium-highLow
PropagationExplicitSemi-explicitImplicit
Risk of silenced errorLow (_ is visible)Medium (empty catch)High (bare except)
CompositionNormal valuesSpecial mechanismSpecial mechanism
Automatic stack traceNo (requires wrapping)YesYes

The main advantage of Go is that errors are values. They’re not a special language mechanism with their own rules. They are normal return values that you can store in variables, pass to functions, put in slices, test with equality. This makes error handling normal code, not a parallel system with its own syntax.

The main disadvantage is verbosity and the lack of automatic stack traces. In Java or Python, when an exception blows up, you have the entire call stack. In Go, if you don’t wrap your errors with context at each layer, you end up with a cryptic message that doesn’t tell you where the problem originated.

That’s why wrapping with %w is not optional in practice. It’s mandatory if you want useful errors.


The cost of clarity

Error handling in Go is verbose. That’s undeniable. You come from Kotlin with its sealed classes or from Python with its try/except, and the first month with Go you feel like you’re writing more code than necessary just to handle failure cases.

But something changes over time. You start reading other people’s Go code and understand exactly what happens when something fails. No hidden flows, no exceptions jumping three layers up without anyone expecting them, no empty catches hidden in a middleware someone wrote two years ago. Everything is right there, in front of you, in every if err != nil.

That visibility has a cost when writing. But it has enormous value when maintaining. And maintaining is what we do 80% of the time.

If you come from another language, my advice is this: don’t try to replicate exceptions with panic/recover. Don’t build error hierarchies as if you were in Java. Start with fmt.Errorf and %w, add sentinel errors when a consumer needs to make decisions, and create custom types only when you need structured fields. Nothing more.

Error handling in Go seems repetitive until you understand that it forces you to make the real flow of the program visible. Every if err != nil is a conscious decision about what to do when something fails. And that, in production, is exactly what you want.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved