
How to Add Extra Information to Errors in Go
Error handling in Go can quickly become verbose and unhelpful when you’re dealing with complex applications spanning multiple layers of abstraction. While Go’s built-in error interface is simple and elegant, it often leaves developers wanting more context about where things went wrong and why. This post will walk you through proven techniques for enriching your errors with stack traces, custom context, and structured information that will save you hours of debugging time and make your applications more maintainable.
How Error Wrapping Works in Go
Go 1.13 introduced the errors
package with built-in support for error wrapping, fundamentally changing how we handle error context. The key concept revolves around creating error chains where each layer can add its own context while preserving the original error.
package main
import (
"errors"
"fmt"
)
func connectDatabase() error {
return errors.New("connection refused")
}
func initializeApp() error {
if err := connectDatabase(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
return nil
}
func main() {
if err := initializeApp(); err != nil {
fmt.Printf("Error: %v\n", err)
// Unwrap to get the original error
if originalErr := errors.Unwrap(err); originalErr != nil {
fmt.Printf("Original: %v\n", originalErr)
}
}
}
The magic happens with the %w
verb in fmt.Errorf
, which creates a wrapped error that implements the Unwrap()
method. This allows you to traverse the error chain while maintaining type information for error checking.
Building Custom Error Types with Rich Context
While error wrapping is useful, custom error types give you complete control over the information you capture. Here’s a practical implementation that includes HTTP status codes, error categories, and debugging context:
package errors
import (
"fmt"
"runtime"
"time"
)
type DetailedError struct {
Code string
Message string
HTTPCode int
Category string
Timestamp time.Time
File string
Line int
Function string
Cause error
Context map[string]interface{}
}
func (e *DetailedError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *DetailedError) Unwrap() error {
return e.Cause
}
func New(code, message string) *DetailedError {
pc, file, line, _ := runtime.Caller(1)
fn := runtime.FuncForPC(pc)
return &DetailedError{
Code: code,
Message: message,
Timestamp: time.Now(),
File: file,
Line: line,
Function: fn.Name(),
Context: make(map[string]interface{}),
}
}
func (e *DetailedError) WithHTTPCode(code int) *DetailedError {
e.HTTPCode = code
return e
}
func (e *DetailedError) WithCategory(category string) *DetailedError {
e.Category = category
return e
}
func (e *DetailedError) WithContext(key string, value interface{}) *DetailedError {
e.Context[key] = value
return e
}
func (e *DetailedError) Wrap(cause error) *DetailedError {
e.Cause = cause
return e
}
This approach gives you a fluent interface for building rich error objects:
func processUserData(userID string) error {
if userID == "" {
return New("INVALID_USER_ID", "User ID cannot be empty").
WithHTTPCode(400).
WithCategory("validation").
WithContext("userID", userID)
}
if err := validateUser(userID); err != nil {
return New("USER_VALIDATION_FAILED", "Failed to validate user").
WithHTTPCode(422).
WithCategory("business_logic").
WithContext("userID", userID).
Wrap(err)
}
return nil
}
Implementing Stack Traces for Better Debugging
Stack traces are invaluable for tracking down issues in production environments. Here’s how to implement automatic stack trace capture:
package errors
import (
"fmt"
"runtime"
"strings"
)
type StackFrame struct {
Function string
File string
Line int
}
type ErrorWithStack struct {
Err error
Stack []StackFrame
Message string
}
func (e *ErrorWithStack) Error() string {
if e.Message != "" {
return e.Message
}
return e.Err.Error()
}
func (e *ErrorWithStack) Unwrap() error {
return e.Err
}
func (e *ErrorWithStack) StackTrace() string {
var builder strings.Builder
for i, frame := range e.Stack {
builder.WriteString(fmt.Sprintf("%d. %s\n", i+1, frame.Function))
builder.WriteString(fmt.Sprintf(" %s:%d\n", frame.File, frame.Line))
}
return builder.String()
}
func WithStack(err error) *ErrorWithStack {
if err == nil {
return nil
}
const depth = 32
var pcs [depth]uintptr
n := runtime.Callers(2, pcs[:])
var stack []StackFrame
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
stack = append(stack, StackFrame{
Function: frame.Function,
File: frame.File,
Line: frame.Line,
})
if !more {
break
}
}
return &ErrorWithStack{
Err: err,
Stack: stack,
}
}
func Errorf(format string, args ...interface{}) *ErrorWithStack {
return WithStack(fmt.Errorf(format, args...))
}
Integration with Popular Error Handling Libraries
Several third-party libraries can supercharge your error handling. Here’s a comparison of the most popular options:
Library | Stack Traces | Error Wrapping | Structured Data | Performance Impact | Best Use Case |
---|---|---|---|---|---|
pkg/errors | Yes | Yes | No | Low | Simple stack traces |
emperror/errors | Yes | Yes | Yes | Medium | Enterprise applications |
hashicorp/go-multierror | No | No | No | Very Low | Aggregating multiple errors |
cockroachdb/errors | Yes | Yes | Yes | Medium | Production systems |
The pkg/errors
library remains popular for its simplicity:
import "github.com/pkg/errors"
func readConfigFile(filename string) (*Config, error) {
data, err := ioutil.ReadFile(filename)
if err != nil {
return nil, errors.Wrapf(err, "failed to read config file %s", filename)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, errors.Wrap(err, "failed to parse config JSON")
}
return &config, nil
}
Real-World Use Cases and Examples
Let’s examine how enhanced error handling works in common scenarios like HTTP APIs, database operations, and microservice communication.
HTTP API Error Handling
package api
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
TraceID string `json:"trace_id"`
}
func (e APIError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func handleUserRegistration(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiErr := APIError{
Code: "INVALID_REQUEST_BODY",
Message: "Failed to parse request body",
Details: map[string]string{"error": err.Error()},
TraceID: traceID,
}
writeErrorResponse(w, http.StatusBadRequest, apiErr)
return
}
if err := validateRegistration(req); err != nil {
if validationErr, ok := err.(*ValidationError); ok {
apiErr := APIError{
Code: "VALIDATION_FAILED",
Message: "Request validation failed",
Details: validationErr.Fields,
TraceID: traceID,
}
writeErrorResponse(w, http.StatusUnprocessableEntity, apiErr)
return
}
}
}
Database Operation Error Context
package database
type DBError struct {
Operation string
Table string
Query string
Args []interface{}
Cause error
Duration time.Duration
}
func (e *DBError) Error() string {
return fmt.Sprintf("database %s on table %s failed: %v", e.Operation, e.Table, e.Cause)
}
func (db *Database) CreateUser(user *User) error {
start := time.Now()
query := "INSERT INTO users (email, name, created_at) VALUES ($1, $2, $3)"
args := []interface{}{user.Email, user.Name, time.Now()}
_, err := db.conn.Exec(query, args...)
duration := time.Since(start)
if err != nil {
return &DBError{
Operation: "INSERT",
Table: "users",
Query: query,
Args: args,
Cause: err,
Duration: duration,
}
}
return nil
}
Best Practices and Common Pitfalls
Performance Considerations
Stack trace generation can be expensive. Here are some benchmarks comparing different approaches:
BenchmarkSimpleError-8 100000000 10.2 ns/op
BenchmarkWrappedError-8 50000000 28.4 ns/op
BenchmarkStackTrace-8 1000000 1847 ns/op
BenchmarkDetailedError-8 5000000 312 ns/op
Key performance guidelines:
- Only capture stack traces for unexpected errors, not business logic errors
- Use error codes instead of string matching for error type checking
- Avoid deep error chains in hot paths
- Consider lazy stack trace generation for frequently created errors
Error Handling Anti-Patterns
Avoid these common mistakes:
// DON'T: Log and return errors
func badExample() error {
if err := someOperation(); err != nil {
log.Printf("Error in badExample: %v", err) // Don't log here
return err // Let the caller decide what to do
}
return nil
}
// DON'T: Lose error context
func anotherBadExample() error {
if err := someOperation(); err != nil {
return errors.New("something went wrong") // Original error is lost
}
return nil
}
// DO: Add context and let caller handle logging
func goodExample() error {
if err := someOperation(); err != nil {
return fmt.Errorf("failed to perform operation in goodExample: %w", err)
}
return nil
}
Structured Logging Integration
Combine rich errors with structured logging for maximum debugging power:
import (
"github.com/sirupsen/logrus"
)
func logDetailedError(err error, logger *logrus.Logger) {
entry := logger.WithField("error", err.Error())
if detailedErr, ok := err.(*DetailedError); ok {
entry = entry.WithFields(logrus.Fields{
"error_code": detailedErr.Code,
"error_category": detailedErr.Category,
"http_code": detailedErr.HTTPCode,
"file": detailedErr.File,
"line": detailedErr.Line,
"context": detailedErr.Context,
})
}
if stackErr, ok := err.(*ErrorWithStack); ok {
entry = entry.WithField("stack_trace", stackErr.StackTrace())
}
entry.Error("Operation failed")
}
Advanced Techniques and Tools
For production systems, consider implementing error aggregation and monitoring:
package monitoring
import (
"context"
"sync"
"time"
)
type ErrorAggregator struct {
errors map[string]*ErrorStats
mutex sync.RWMutex
}
type ErrorStats struct {
Code string
Count int64
LastSeen time.Time
FirstSeen time.Time
Examples []string
}
func (ea *ErrorAggregator) Record(err error) {
ea.mutex.Lock()
defer ea.mutex.Unlock()
code := "UNKNOWN"
if detailedErr, ok := err.(*DetailedError); ok {
code = detailedErr.Code
}
stats, exists := ea.errors[code]
if !exists {
stats = &ErrorStats{
Code: code,
FirstSeen: time.Now(),
Examples: make([]string, 0, 10),
}
ea.errors[code] = stats
}
stats.Count++
stats.LastSeen = time.Now()
if len(stats.Examples) < 10 {
stats.Examples = append(stats.Examples, err.Error())
}
}
The techniques covered here will transform your Go applications from black boxes that fail silently into transparent, debuggable systems. Start with error wrapping using the standard library, then gradually introduce custom error types and stack traces where they add the most value. Remember that good error handling is about finding the right balance between information richness and performance overhead.
For additional resources, check out the official Go documentation on error handling and the Go 1.13 errors blog post for deeper insights into the standard library's capabilities.

This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.
This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.