Go handles errors differently from most languages — no try/catch like Java, no exceptions like Python. Functions return error as an ordinary return value and the caller decides how to handle it. Simple in theory, but as a project grows, the lack of a clear strategy turns debugging into a nightmare.
The last web app project I worked on had 5 developers. In the first week, everyone did their own thing: some used errors.New, others wrapped with fmt.Errorf, others defined their own structs. The result? Tracing a single production error took an entire morning because messages like “something went wrong” told us nothing. Once the team sat down and agreed on a consistent approach, the same type of error took about 10 minutes to track down.
Three Common Error Handling Approaches in Go
Approach 1: Plain errors.New and fmt.Errorf
The simplest approach, suitable for scripts or small internal helpers:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("record not found")
func getUserByID(id int) (string, error) {
if id <= 0 {
return "", fmt.Errorf("invalid user id: %d", id)
}
if id == 999 {
return "", ErrNotFound
}
return "Alice", nil
}
func main() {
_, err := getUserByID(0)
if err != nil {
fmt.Println(err) // invalid user id: 0
}
_, err = getUserByID(999)
if errors.Is(err, ErrNotFound) {
fmt.Println("User does not exist")
}
}
Sentinel errors like ErrNotFound are defined at the package level. Callers use errors.Is(err, ErrNotFound) to check — avoid direct == comparison since it fails when the error is wrapped.
Approach 2: Custom Error Type
When you need to attach metadata to an error — HTTP status code, error code, operation name — a custom struct is the better choice:
type AppError struct {
Code int
Message string
Op string // operation name
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%s] %d: %s", e.Op, e.Code, e.Message)
}
func getUserByID(id int) (string, error) {
if id <= 0 {
return "", &AppError{
Code: 400,
Message: "invalid user id",
Op: "getUserByID",
}
}
return "Alice", nil
}
func main() {
_, err := getUserByID(-1)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("HTTP %d: %s\n", appErr.Code, appErr.Message)
}
}
}
errors.As traverses the entire error chain to find a matching type — unlike a direct type assertion that only checks the surface level.
Approach 3: Wrapping Errors with %w
Since Go 1.13, fmt.Errorf supports the %w verb to wrap the original error while preserving the full call stack context:
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("readConfig: %w", err)
}
_ = data
return nil
}
func initApp() error {
if err := readConfig("/etc/app/config.yaml"); err != nil {
return fmt.Errorf("initApp: %w", err)
}
return nil
}
func main() {
err := initApp()
if err != nil {
fmt.Println(err)
// initApp: readConfig: open /etc/app/config.yaml: no such file or directory
if errors.Is(err, os.ErrNotExist) {
fmt.Println("Config file does not exist")
}
}
}
The error message prints the full call path — one line tells you exactly which functions the error passed through.
Pros and Cons Analysis
- errors.New / plain fmt.Errorf — quick to write, minimal boilerplate. But callers can’t distinguish between error types: no way to tell if it’s a 404, 401, or a database timeout. Conditional handling is nearly impossible. Reasonable for small scripts or internal helpers.
- Custom Error Type — attach metadata (HTTP status, error code, operation name) to errors. Callers use
errors.Asto catch the right type and handle each case. The cost is ~10–15 lines of boilerplate per error type — worth it for the API layer and business logic. - Wrap Error with %w — automatically builds a context chain like
initApp: readConfig: open /etc/app/config.yaml: no such file. One log line traces the entire call path. The chain can get long with deep nesting, but that’s a trade-off worth accepting.
These three approaches aren’t mutually exclusive — real-world projects typically combine all three, each playing its own role.
Choosing the Right Approach for Each Situation
Here’s the layered rule I use with my team:
- Repository / Database layer: Wrap the original error with
%w, prefix the function name. Example:fmt.Errorf("UserRepo.GetByID: %w", err). Errors from the database driver are preserved but enriched with context. - Service / Business logic layer: Define Custom Error Types for specific business errors. Example:
ErrUserSuspended,ErrQuotaExceeded. Callers at higher layers useerrors.Asto handle each type. - Handler / API layer: Use
errors.Asto catch Custom Errors and convert them into appropriate HTTP responses. For unrecognized errors, return 500 and log the full error chain.
For sentinel errors, I only create them when a caller genuinely needs to compare with errors.Is. Avoid creating dozens of Err* variables nobody uses — that’s package-level pollution.
Implementation Guide for Real-World Projects
Step 1: Create a Dedicated apperrors Package
// internal/apperrors/errors.go
package apperrors
import "fmt"
type ErrorCode int
const (
ErrCodeNotFound ErrorCode = 404
ErrCodeUnauthorized ErrorCode = 401
ErrCodeInternal ErrorCode = 500
)
type AppError struct {
Code ErrorCode
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
// Unwrap allows errors.Is and errors.As to traverse through
func (e *AppError) Unwrap() error {
return e.Err
}
func NotFound(msg string) *AppError {
return &AppError{Code: ErrCodeNotFound, Message: msg}
}
func Unauthorized(msg string) *AppError {
return &AppError{Code: ErrCodeUnauthorized, Message: msg}
}
func Wrap(err error, msg string) *AppError {
return &AppError{Code: ErrCodeInternal, Message: msg, Err: err}
}
Step 2: Use It in the Service Layer
// internal/service/user.go
package service
import (
"errors"
"fmt"
"myapp/internal/apperrors"
"myapp/internal/repository"
)
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, apperrors.NotFound(fmt.Sprintf("user %d not found", id))
}
return nil, apperrors.Wrap(err, "UserService.GetUser")
}
return user, nil
}
Step 3: Handle Errors in the HTTP Handler
// internal/handler/user.go
package handler
import (
"errors"
"net/http"
"myapp/internal/apperrors"
)
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id := parseID(r)
user, err := h.service.GetUser(id)
if err != nil {
var appErr *apperrors.AppError
if errors.As(err, &appErr) {
http.Error(w, appErr.Message, int(appErr.Code))
return
}
http.Error(w, "Internal Server Error", 500)
return
}
writeJSON(w, user)
}
Key Best Practices to Remember
- Log only at the top layer — if every function logs the error, the same error shows up 5 times in the log, making it hard to read. Log exactly once, at the handler or main level.
- Don’t strip context when wrapping —
return fmt.Errorf("something went wrong: %w", err)is meaningless. Always include the function name or specific information:fmt.Errorf("UserService.Create: %w", err). - Implement Unwrap() if you want to support error chains — without
Unwrap(),errors.Isanderrors.Aswon’t traverse through your custom struct. - Sentinel errors must be exported with the Err prefix — per Go convention:
var ErrNotFound = errors.New(...). Don’t name them likenotFoundErrorsince callers won’t be able to import them. - Don’t silence errors —
_ = someFunc()or catching an error and returning without logging anything is a dangerous pattern. At minimum, wrap and return it so the layer above can decide what to do.
One small convention my team finds highly effective: every error message starts with PackageName.FunctionName:. Reading production logs immediately tells you which file and function to look at. No special tools or frameworks needed — just team alignment and discipline.
Error handling in Go isn’t syntactically hard. The challenge is staying consistent as the codebase grows. The approach I usually recommend: start with fmt.Errorf + %w for simplicity, add Custom Error Types when you need branching logic, then extract an apperrors package once the team grows beyond 3 people. The final criterion — a new joiner should be able to look at the code for 5 minutes and understand: what to do when an error occurs, where to log it, and how to trace its origin.

