Go (Golang) Error Handling Best Practices 2025: Complete Guide
Error handling is one of the most important parts of Go programming – and also one of the easiest to get wrong. In 2025, with Go 1.21+ and an increasingly mature ecosystem, we finally have a set of battle‑tested patterns for building production‑grade error handling in Go services.
In this guide you’ll learn:
- ✅ Modern Go error handling patterns (beyond just
if err != nil) - ✅ When to use sentinel errors vs wrapped errors vs custom types
- ✅ How to design error stacks that are debuggable in production
- ✅ How to connect errors with logging, metrics, and tracing
- ✅ Common anti‑patterns that still appear in real projects
Before diving in, if you haven't yet hardened your overall security posture, you should also read 10 Golang Security Gotchas — And the Fixes That Actually Work – proper error handling and security are tightly coupled in production systems.
1. Philosophy: Errors Are Values, Not Exceptions
Go’s design deliberately avoids exceptions. Instead:
- Functions return errors explicitly
- Callers must decide how to handle each error
- Errors are just values that can be:
- compared
- wrapped
- logged
- transported
The core principle for 2025 remains:
“Handle errors as close as possible to where you have enough context to make a decision.”
- At low levels: annotate / wrap and bubble up
- At boundaries (HTTP handler / RPC / CLI): convert to:
- response codes
- user‑facing messages
- metrics / logs
2. Basic Pattern – Still the Foundation
The canonical way is still:
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
return data, nil
}Key points:
- Always return
erroras the last return value - Don’t hide the error – bubble it up with context (
%wfor wrapping) - Use
fmt.Errorf("operation: %w", err)to build a useful error stack
In 2025, we strongly recommend you wrap every non‑trivial error going up a boundary:
func loadConfig(path string) (*Config, error) {
data, err := readConfig(path)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
}3. Sentinel Errors vs Wrapped Errors vs Custom Types
3.1 Sentinel Errors – Use Sparingly
Sentinel errors are package‑level variables:
var ErrNotFound = errors.New("not found")Use them when:
- The error has global semantic meaning for your domain
- Callers need to do branching logic based on it
func GetUser(id string) (*User, error) {
u, err := repo.FindByID(id)
if errors.Is(err, repo.ErrNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return u, nil
}Don’t overuse sentinel errors – scattered globals become hard to manage.
3.2 Wrapped Errors – The Default Choice
For most code in 2025:
- Use
fmt.Errorf("context: %w", err)to add layers - Use
errors.Is/errors.Asat decision boundaries
if err := svc.Process(ctx, req); err != nil {
if errors.Is(err, domain.ErrRateLimited) {
return http.StatusTooManyRequests, "too many requests"
}
logger.Error("process request failed", zap.Error(err))
return http.StatusInternalServerError, "internal error"
}3.3 Custom Error Types – For Rich Semantics
When you need structured information, define custom types:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Message)
}And use errors.As to inspect:
var vErr *ValidationError
if errors.As(err, &vErr) {
return http.StatusBadRequest, vErr.Error()
}Rule of thumb (2025):
- Sentinel: domain‑level flags (
ErrNotFound,ErrConflict) - Wrapped: default for most IO / infra errors
- Custom types: when HTTP / gRPC / logs need extra fields
4. Designing an Error Stack That Works in Production
In real systems, you don’t care only about the top error message – you care about:
- Where it failed (service / module / function)
- Why it failed (upstream / downstream / user input / config)
- How often it happens (metrics)
- What to show to the user vs what to log
4.1 A Practical Pattern
// domain/service.go
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
if err := s.validator.ValidateCreateOrder(req); err != nil {
return nil, fmt.Errorf("validate create order: %w", err)
}
order, err := s.repo.Create(ctx, req)
if err != nil {
return nil, fmt.Errorf("persist order: %w", err)
}
return order, nil
}
// transport/http.go
func (h *Handler) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
// ...
order, err := h.svc.CreateOrder(r.Context(), &req)
if err != nil {
status, msg := h.mapError(err)
h.logger.Error("create order failed", zap.Error(err))
http.Error(w, msg, status)
return
}
// ...
}func (h *Handler) mapError(err error) (int, string) {
var vErr *ValidationError
switch {
case errors.As(err, &vErr):
return http.StatusBadRequest, vErr.Error()
case errors.Is(err, repo.ErrConflict):
return http.StatusConflict, "order already exists"
default:
return http.StatusInternalServerError, "internal server error"
}
}This separates:
- Where to log (handler)
- Where to enrich errors (service)
- Where to decide user‑facing messages (transport)
5. Logging, Metrics, Tracing – Errors in the Observability Loop
Error handling doesn’t live alone – it must integrate with observability. If你还没有建立一套完整的可观测性方案,可以参考 From Trace to Insight: A Closed-Loop Observability Practice for Go Projects。
5.1 Logging with Context
func (s *Service) ProcessPayment(ctx context.Context, req *PaymentRequest) error {
if err := s.gateway.Charge(ctx, req); err != nil {
s.logger.Error("charge failed",
zap.String("order_id", req.OrderID),
zap.String("user_id", req.UserID),
zap.Error(err),
)
return fmt.Errorf("charge order %s: %w", req.OrderID, err)
}
return nil
}5.2 Metrics for Error Rates
var (
errCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "service_errors_total",
Help: "Total number of service errors",
},
[]string{"operation", "reason"},
)
)
func (s *Service) wrapError(op, reason string, err error) error {
if err != nil {
errCounter.WithLabelValues(op, reason).Inc()
return fmt.Errorf("%s: %w", op, err)
}
return nil
}5.3 Tracing with Error Spans
When using OpenTelemetry:
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)This allows you to:
- Quickly see where errors happen in a trace
- Correlate with latency and resource usage
6. Common Anti‑Patterns in 2025 (Still Everywhere)
6.1 Swallowing Errors
// ❌ Don't do this
if err := doSomething(); err != nil {
// ignore
}Always either:
- Return the error
- Log with enough context
6.2 Panics Instead of Errors
Use panic only for:
- Programmer errors (impossible states)
- Initialization failures that must abort the process
For everything else – return error.
6.3 Over‑Logging the Same Error
Don’t log the same error at every layer:
- Pick one place (usually transport boundary) for ERROR level
- Inner layers can use DEBUG logs or no logs, just wrapping
7. Error Handling Patterns for Concurrency
In concurrent code, error handling becomes trickier. If you’re not comfortable with advanced patterns yet,建议先阅读 Advanced Go Concurrency Patterns for Scalable Applications。
7.1 Error Group Pattern
type Result struct {
Data string
Err error
}
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]string, len(urls))
for i, url := range urls {
i, url := i, url
g.Go(func() error {
data, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
results[i] = data
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}7.2 Channel‑Based Error Fan‑in
func processWorkers(ctx context.Context, jobs <-chan Job) error {
errCh := make(chan error, 1)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
if err := handleJob(ctx, job); err != nil {
select {
case errCh <- err:
default:
}
return
}
}
}()
}
go func() {
wg.Wait()
close(errCh)
}()
if err := <-errCh; err != nil {
return err
}
return nil
}8. Checklist: Production‑Ready Error Handling in Go
Use this checklist to review your services:
9. Where to Go Next
To build truly production‑ready Go services, error handling must be combined with:
- Security – see
10 Golang Security Gotchas — And the Fixes That Actually Work - Observability – see
From Trace to Insight: A Closed-Loop Observability Practice for Go Projects - Performance & deployment – see
Go Containerization Best Practices: From 800MB to 10MB Docker Images
Error handling is not “just boilerplate” – it’s how your system explains failures to you in production.
Investing a bit of design time now will save you countless hours of debugging later.
Happy (and safe) Go coding in 2025! 🚀

