If you’ve written code in Java, Python, or JavaScript, Go’s way of handling errors will seem strange. No “try-catch” blocks. In Go, errors are values that functions return, and you check them right where they happen.
But there’s a catch: Go doesn’t give you automatic stack traces. Error handling is clear in your code, sure. But debugging production errors without proper context? That’s rough.
Without automatic stack traces, you need proper APM instrumentation
to see errors in production.
This guide covers how to handle errors in Go. Wrapping errors with context, custom error types, making errors observable in production.
How Go Error Handling Works
Error interfaces, creating error values, wrapping them with context, and inspecting them when something goes wrong are fundamental ideas of error handling in Go. To effectively handle application failures, you must understand all of these.
The Go Error Interface
Error handling in Go starts with a minimal built-in interface:
type error interface {
Error() string
}
Any type that implements an `Error()` method returning a string is an error in Go. This means errors aren’t a special language feature; they’re just regular values.
This gives you flexibility. When a function returns an error, it could be:
- A simple text message
- A custom struct containing error codes, timestamps, or other data
- A type that wraps multiple errors together
If a type has an Error() method that returns a string, Go treats it as an error.
Creating Errors
Go’s standard library gives you two main ways to create errors: `errors.New` and `fmt.Errorf`.
- Using `errors.New`
The `errors.New` function creates a simple error from a string:
import "errors"
func validateAge(age int) error {
if age < 0 {
return errors.New("age cannot be negative")
}
return nil
}
Comparing errors by message or equality is wrong as each call to errors.New is unique. It generates a distinct error value, even for similar messages.
err1 := errors.New("connection failed")
err2 := errors.New("connection failed")
fmt.Println(err1 == err2) // false
- Using fmt.Errorf
Use `fmt.Errorf` to format error messages, like `fmt.Sprintf`.
import "fmt"
func connectToDatabase(host string, port int) error {
return fmt.Errorf("failed to connect to %s:%d", host, port)
}
This makes error messages more clear by include dynamic values.
How to Wrap Errors in Go Using %w
Error wrapping lets you provide an error context as it runs through your code while keeping the underlying issue. If a deep call stack error occurs, you must know what happened and where.
You can wrap errors using the `%w` verb in `fmt.Errorf`:
Wrap errors with %w so you don’t lose the original. Then unwrap it to check what actually failed.
func saveUser(db *Database, user User) error {
if err := db.Connect(); err != nil {
return fmt.Errorf("saveUser: connection failed: %w", err)
}
if err := db.Insert(user); err != nil {
return fmt.Errorf("saveUser: insert failed: %w", err)
}
return nil
}
func main() {
err := saveUser(db, user)
if err != nil {
fmt.Println(err) // "saveUser: insert failed: duplicate key violation"
// Can still access the original error
originalErr := errors.Unwrap(err)
fmt.Println(originalErr) // "duplicate key violation"
}
}
Each layer provides context for what's wrong, but the underlying fault remains. In production, this is crucial for identifying error sources.
When errors propagate across services, wrapping alone isn't enough you need full observability to trace where each error originated.
Creating Custom Error Types in Go
In many situations, basic string errors are sufficient, but occasionally more structure is required. Error codes, retry flags, timestamps, and any other metadata that help you handle errors differently depending on their type can be attached to custom error types.
When to think about custom error types is as follows:
- Network and validation errors demand distinct approaches.
- Structured data (affected resources, error codes, and HTTP status codes) is needed.
- Programmatic error details beyond the message string are needed.
Creating a custom error type
A struct that implements the `Error()` method is all that makes up a custom error type:
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 validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "must contain @ symbol",
}
}
return nil
}
Now your callers can check for this specific error type and access its fields:
err := validateEmail("invalid-email")
if err != nil {
if ve, ok := err.(*ValidationError); ok {
fmt.Printf("Field: %s, Issue: %s\n", ve.Field, ve.Message)
// Handle validation errors specifically
} else {
// Handle other errors
}
}
Use custom error types to handle errors based on their properties, not just their messages.
Checking and Grouping Errors
Wrapping errors and constructing custom types requires reliable inspection. Go provides errors.Is and errors.As for checking error types via wrapped layers. It also uses errors. Join to combine multiple errors into one.
- Using errors.Is
Even after several wraps, the 'errors.Is' method checks if an error matches a value:
var ErrNotFound = errors.New("resource not found")
func getUser(id int) error {
return fmt.Errorf("failed to fetch user %d: %w", id, ErrNotFound)
}
func main() {
err := getUser(123)
if errors.Is(err, ErrNotFound) {
fmt.Println("User doesn't exist")
return
}
}
This validates the error value, not the message, across any wrapping layer, unlike string matching.
- Using errors.As
A wrapped error chain is used to retrieve a specified error type using the `errors.As` function. This helps access custom error type fields and methods.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
func processRequest(data map[string]string) error {
if data["email"] == "" {
valErr := &ValidationError{Field: "email", Message: "required"}
return fmt.Errorf("request validation failed: %w", valErr)
}
return nil
}
func main() {
err := processRequest(map[string]string{})
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Invalid field: %s\n", valErr.Field)
fmt.Printf("Reason: %s\n", valErr.Message)
}
}
- Handling multiple failures with errors.Join
Sometimes one procedure causes many errors. Go 1.20 introduced errors.Join which combines many errors into one.
func validateUser(user User) error {
var errs []error
if user.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if user.Age < 18 {
errs = append(errs, errors.New("age must be 18 or older"))
}
if user.Username == "" {
errs = append(errs, errors.New("username is required"))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
errors.Join is useful for cleanup processes where many failures occur separately and all failures should be reported.
Go Error Handling Best Practices
Go error handling is straightforward with a few procedures. Use these to improve the dependability and debugging ease of your code.
- Always check and handle errors explicitly
Even if you believe mistakes are unlikely to happen, you should never ignore them. Each error return value should be examined.
// Bad
data, _ := os.ReadFile("config.json")
// Good
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
- Add relevant context to errors
To identify the source of the errors, add context at every layer. Use `%w` rather than `%v` to pressure the original error:
// Loses original error
if err != nil {
return fmt.Errorf("operation failed: %v", err)
}
// Preserves original error
if err != nil {
return fmt.Errorf("failed to process user %d: %w", userID, err)
}
Each layer should specify its purpose (operation name, resource, identification) on error. This creates a breadcrumb trail linking surface error to cause.
- Use panic only for unrecoverable situations
Reserve 'panic' for programming bugs and rare situations, not for predicted failures such as network timeouts or incorrect user input.
// Bad - panic for expected failure
func getUser(id int) User {
user, err := db.Query(id)
if err != nil {
panic(err) // Don't do this
}
return user
}
// Good - return error for expected failure
func getUser(id int) (User, error) {
user, err := db.Query(id)
if err != nil {
return User{}, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
- Maintain critical context with the call stack
Add request IDs, user IDs, resource names, and operation types to debug errors in your code:
func processOrder(ctx context.Context, orderID string) error {
user, err := getCurrentUser(ctx)
if err != nil {
return fmt.Errorf("processOrder: failed to get user for order %s: %w", orderID, err)
}
if err := validateOrder(order); err != nil {
return fmt.Errorf("processOrder: validation failed for order %s (user %s): %w",
orderID, user.ID, err)
}
return nil
}
Now the error message describes what failed, which resource, and which operation. Production debugging is much faster.
Adding request IDs and user context to errors becomes even more powerful when paired with a Go monitoring stack that correlates them to traces and logs automatically.
Observing and Debugging Go Errors with Middleware
Instrumenting your Go application using Middleware lets you see and explain production errors. This provides context, stack traces, and service tracing automatically.
If you haven’t instrumented your app, figuring out errors is a pain. That “database query failed” message doesn’t tell you which query or what happened before it.
This gets messy fast in distributed systems. One service times out, another retries, a third one throws an error, and your logs don’t make it obvious how any of that is connected. So you need observability.
Setting Up Middleware for Go Applications
Middleware provides automatic instrumentation for Go applications. Middleware collects errors, traces, and analytics as requests pass through your system, rather than manually inserting logging code.
Install the Middleware SDK:
Then initialize Middleware in your `main.go`:
import (
mwhttp "github.com/middleware-labs/golang-apm-http/http"
track "github.com/middleware-labs/golang-apm/tracker"
)
func getUserHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "missing user id", http.StatusBadRequest)
return
}
var id int
fmt.Sscanf(idStr, "%d", &id)
user, exists := users[id]
if !exists {
http.Error(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
// Initialize Middleware with your service details
_, err := track.Track(
track.WithConfigTag(track.Service, "middleware-go-demo"),
track.WithConfigTag("accessToken", "<your-access-token>"),
track.WithConfigTag("target", "<your-target-endpoint>"),
)
if err != nil {
log.Fatalf("failed to initialize Middleware APM: %v", err)
}
http.Handle("/user", mwhttp.MiddlewareHandler(
http.HandlerFunc(getUserHandler),
"getUserHandler",
))
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
The `mwhttp.MiddlewareHandler` wrapper tracks HTTP requests automatically. For context on failures, connect custom data like user IDs or request details.
Check out this demo Go app with Middleware configured.
For the full setup including gRPC support, structured logging, and continuous profiling see the official Go APM configuration guide.
Automatic error tracking
Middleware detects bugs without code changes after installation.
Example:
This will return 404 because user 999 doesn't exist.
Check your Middleware dashboard. It shows the error, HTTP method, status, time, and others.

The error detail page provides everything you need to quickly identify and fix the error, including stack traces, request parameters, response time, and the complete request trace.
Setting Up Alerts for Error Patterns
Setting up notifications lets you catch issues without manually monitoring dashboards.
Middleware controls alert error rates, types, and conditions. When an alert is activated, you can quickly identify what went wrong by connecting to the right traces via Slack, email, or PagerDuty.
Middleware Error Tracking lets you customize alert thresholds per error type and delivers notifications to Slack, email, and other channels automatically no manual setup per error. [Lear more → ]
Start Tracking Errors in Your Go Applications
Middleware provides Go services with automated error tracking, distributed tracing, and real-time alerting. Instrument your first Go app in minutes with a free account. Check error locations, propagation, and context.
FAQ
When should I use custom error types in Go?
Use custom types when you need structured data beyond a message string, error codes, affected fields, retry flags, or HTTP status codes. For simple failures, errors.New or fmt.Errorf is enough.
What is the difference between panic and error in Go?
An error is a value your function returns for expected failures (validation, network timeouts). A panic is for unexpected bugs, nil-pointer dereferences, and out-of-bounds indices that signal the program is in a broken state. Use panic only when continuing would be unsafe.
How can I make errors easier to debug by adding request IDs or user context?
Keep request IDs and user data inside context. Context, and pull them when creating errors. Tools like Middleware can also attach this context for you.
How do you debug production Go errors with stack traces?
You can use tools like Middleware, as it lets you see the stack trace and the path the request took.
What is the difference between %w and %v in Go error wrapping?
%w wraps the original error so it remains accessible via errors.Is, errors.As, and errors.Unwrap. %v only formats it as a string, losing the original error entirely.
Does Go have stack traces for errors?
Not natively. Go doesn't attach stack traces to errors automatically. You need third-party libraries, like pkg/errors or observability tools like Middleware, to capture them in production.
When do I need to implement Unwrap() on a custom error type?
Any time you want errors.Is or errors.As to be able to inspect errors wrapped inside your custom type. Without Unwrap(), the chain stops at your type and the underlying error is invisible.



