Error handling is one of the most important parts of writing reliable Go applications. Unlike many languages that use exceptions, Go takes a simple, explicit, and predictable approach: functions return errors as normal values. This design encourages developers to think carefully about failure cases, write cleaner control flow, and avoid hidden surprises.
Idiomatic error handling in Go is not just about checking if err != nil repeatedly. It’s about structuring your code so that:
-
Errors are clear and meaningful
-
Failures are easy to trace
-
Business logic remains readable
-
You avoid unnecessary panics or deep nesting
In this tutorial, you’ll learn the foundational concepts and modern techniques used by experienced Go developers, including sentinel errors, wrapping, custom error types, panic/recover patterns, and real-world examples. By the end, you’ll be able to design error flows that are clean, expressive, and easy to maintain—no matter how large your project grows.
Understanding Errors in Go
Go’s error handling model is intentionally simple. Instead of exceptions, Go represents errors using the built-in error type—an interface with a single method:
type error interface {
Error() string
}
This means any type that implements the Error() method can be treated as an error. Because errors are regular values, they’re easy to pass around, compare, wrap, or return.
1. Creating Basic Errors
The simplest way to create an error is with errors.New:
import "errors"
var ErrNotFound = errors.New("not found")
Or with fmt.Errorf when you need formatted text:
err := fmt.Errorf("failed to load user: %s", username)
2. Returning Errors from Functions
In Go, functions often return two values: the result and an error.
Example:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
Calling the function:
result, err := divide(10, 2)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
This explicit approach makes error handling predictable and avoids surprises that exceptions might cause.
3. Zero Value Convention
When an error occurs, the result value should be the zero value of its type.
Examples:
-
For
int→ return0 -
For pointer → return
nil -
For slice → return
nil
This ensures callers don’t accidentally use invalid results.
4. Why Go Avoids Exceptions
Go intentionally avoids try/catch because:
-
Exceptions hide control flow
-
They encourage “let it crash” patterns on expected errors
-
They complicate reasoning about the function behavior
Instead, Go’s philosophy is:
👉 Return errors as part of the function signature so the caller decides what to do.
5. Standard Library Error Values
Go’s standard library uses predefined errors (sentinel errors) such as:
if errors.Is(err, io.EOF) {
// end of file reached
}
This foundational understanding prepares you for Go’s more advanced patterns like wrapping, custom error types, and typed business logic.
Returning Errors Explicitly (Go’s Philosophy)
One of Go’s core design choices is to make error handling explicit. Instead of throwing exceptions, Go encourages developers to return errors as part of the normal function result. This leads to code that is predictable, readable, and easy to reason about.
Explicit error returns form the foundation of Go’s philosophy:
👉 Error handling is part of your program’s logic—not an afterthought.
1. The Multi-Return Pattern
Most Go functions return two values:
-
The expected result
-
An
error
Example:
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
Calling this function:
config, err := readConfig("config.json")
if err != nil {
log.Println("failed:", err)
return
}
fmt.Println("config loaded")
This pattern:
-
Makes error sources clear
-
Keeps the control flow transparent
-
Allows callers to handle errors based on context
2. Early Returns (Fail Fast)
Go developers avoid deeply nested if statements by returning early when an error occurs:
❌ Not idiomatic
if err == nil {
if value > 0 {
// logic...
}
}
✅ Idiomatic
if err != nil {
return nil, err
}
if value <= 0 {
return nil, errors.New("value must be positive")
}
This keeps your code flat, readable, and easy to scan.
3. Handling vs Returning Errors
A common beginner mistake is handling errors too early.
Ask yourself:
“Should this function fix the error or simply return it?”
Return the error if:
-
The function cannot decide the best way to recover
-
The caller has more context to decide what to do
Handle/log the error if:
-
The function can resolve the situation
-
Logging is specifically required there
-
It's a top-level handler (e.g., HTTP middleware, main loop)
Example of correct handling:
func startServer() {
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal("server crashed:", err)
}
}
4. Errors Are Values
Since errors are values, you can:
-
Wrap them
-
Compare them
-
Pass them to other functions
-
Store them in variables
Example:
var ErrUnauthorized = errors.New("unauthorized access")
This allows powerful patterns we'll explore in later sections.
5. Don’t Panic on Expected Errors
Using panic for normal errors, breaks Go’s design philosophy.
Only use panic when something is truly unrecoverable (covered later in Section 9).
❌ Not idiomatic
if err != nil {
panic(err)
}
Unless you’re sure the error means the system is in a bad state, returning the error is almost always correct.
func loadUser(id int64) (*User, error) {
user, err := db.GetUser(id)
if err != nil {
return nil, fmt.Errorf("load user %d: %w", id, err)
}
if user.Disabled {
return nil, ErrUserDisabled
}
return user, nil
}
This function:
-
Returns early
-
Wraps the error context
-
Leaves decision handling to the caller
-
Remains readable and clean
Sentinel Errors
Sentinel errors are predefined, immutable error values that represent specific failure conditions. They allow you to compare errors directly using == or errors.Is, giving you precise control over how to respond to particular error types.
Common examples from the Go standard library include:
-
io.EOF -
sql.ErrNoRows -
os.ErrNotExist -
http.ErrServerClosed
These errors act as signals—hence the name “sentinel.”
1. Defining Sentinel Errors
You create a sentinel error using errors.New:
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
They are usually declared as var at the package level, so all callers can use and compare them.
2. Using Sentinel Errors in Code
A function can return a sentinel error to indicate a known or expected failure:
func findUser(id int) (*User, error) {
if id == 0 {
return nil, ErrNotFound
}
// ...
return &User{}, nil
}
Caller:
user, err := findUser(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("user not found")
return
}
3. Comparisons Before Go 1.13
Historically, developers used == to check sentinel errors:
if err == ErrNotFound {
// handle error
}
This is still valid, but only when:
-
The error is not wrapped
-
You explicitly want to check identity
Since Go 1.13, errors.Is is preferred because it works with wrapped errors.
4. Sentinel Errors + Wrapping
When you wrap an error with %w, you can still detect a sentinel error:
func getUser(id int) (*User, error) {
if id == 0 {
return nil, fmt.Errorf("lookup: %w", ErrNotFound)
}
return &User{}, nil
}
_, err := getUser(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("not found!")
}
errors.Is unwraps the entire error chain to find the sentinel value.
5. Pros and Cons of Sentinel Errors
✅ Pros
-
Simple to define and use
-
Easy comparisons
-
Good for common, predictable failures
-
Ideal for shared APIs and libraries
❌ Cons
-
Too many sentinel errors lead to clutter
-
Harder to extend with dynamic information
-
Error messages are fixed (string can't vary)
If you need error context (e.g., item ID, username), sentinel errors become limiting. In those cases, prefer custom error types (covered in Section 6).
6. When Not to Use Sentinel Errors
Avoid sentinel errors when:
-
You need rich context (user ID, file path, details)
-
You need error categories instead of exact matches
-
The caller must inspect the error fields
-
The error message needs to vary
In those situations, using wrapped errors or typed errors is more idiomatic.
7. When Sentinel Errors Shine
Sentinel errors work best when:
-
You have a small number of fixed, universal failure states
-
The caller needs explicit behavior for a specific condition
-
Your package exposes a stable API with well-known outcomes
For example, the Go standard library itself heavily uses sentinel errors because they provide predictable, stable error signals for developers.
Error Wrapping
Error wrapping is one of the most powerful features in Go’s modern error-handling model. Introduced in Go 1.13, wrapping allows developers to add context to an error while preserving the original cause. This makes debugging clearer and enables callers to inspect the underlying error without losing information.
The key concept is:
👉 Wrap errors when passing them up the call stack so the caller knows both what happened and where it happened.
1. Why Wrap Errors?
Wrapping errors helps you answer two questions:
-
What happened?
Example: “user not found” -
Where did it happen?
Example: “while updating profile”, “while querying DB”, “while reading file”
Without wrapping, you lose the source context, and debugging becomes painful.
2. Wrapping with fmt.Errorf and %w
To wrap an error, use fmt.Errorf with the %w verb:
return fmt.Errorf("read config: %w", err)
This adds context but keeps err in the chain.
Example:
func loadConfig() error {
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("load config: %w", err)
}
_ = data
return nil
}
Caller:
if err := loadConfig(); err != nil {
fmt.Println(err)
}
Output:
load config: open config.json: no such file or directory
3. Unwrapping Errors
Go provides three main tools to inspect wrapped errors:
1. errors.Unwrap
Returns the next error in the chain.
inner := errors.Unwrap(err)
2. errors.Is
Checks whether an error (or anything it wraps) matches a target error:
if errors.Is(err, os.ErrNotExist) {
// file not found
}
This works even with wrapping.
3. errors.As
Extracts a specific error type from the chain:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("problem with file:", pathErr.Path)
}
Use errors.As to work with typed errors, not just values.
4. Example: Wrapping in a Real-World Flow
func getUser(id int) (*User, error) {
user, err := db.QueryUser(id)
if err != nil {
return nil, fmt.Errorf("getUser: %w", err)
}
return user, nil
}
Caller:
u, err := getUser(10)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
Wrapping allows the caller to distinguish:
-
DB-level errors (like
sql.ErrNoRows) -
Higher-level business errors (
ErrUserNotFound) -
Unexpected errors
5. Wrapping Multiple Layers Deep
Go handles deep wrapping chains automatically:
getUser: query DB: connect: dial tcp: connection refused
Each layer adds context, and errors.Is still works.
6. When to Wrap Errors
Wrap the error if:
-
You’re adding context from a lower-level call
-
You’re passing the error up to a higher layer
-
You want to preserve the underlying cause
-
You want to avoid deeply ambiguous messages like “failed to initialize”
Do NOT wrap when:
-
You want to replace the error entirely
-
You’re converting to a typed business error
-
You are logging the error at the same layer
(Wrapping + logging repeatedly = noisy logs)
7. Before vs After Go 1.13
Before Go 1.13
Libraries used custom wrappers like:
-
pkg/errors -
Stacked errors
-
Manual annotation
After Go 1.13
-
Native support using
%w -
Standard
errorspackage handles chains -
No external dependency required
Modern Go strongly prefers the built-in approach.
8. Best Practices for Wrapping
-
Wrap only when adding new context
-
Avoid wrapping the same error multiple times in a loop
-
Keep messages short (the chain will provide detail)
-
Avoid wrapping at the top level (just log instead)
-
Prefer
%wover%vfor wrapped errors
Wrapping lays the foundation for more advanced patterns, especially typed errors, which we’ll explore next.
Custom Error Types
While sentinel errors and wrapped errors cover many cases, sometimes you need richer information—fields, metadata, categories, or structured details. This is where custom error types shine.
A custom error type is any struct that implements the Error() method. This allows you to attach context such as IDs, paths, status codes, or other data that the caller may need.
Idiomatic Go code often uses custom error types for business logic, validation, and categorized domain errors.
1. Creating a Custom Error Type
A basic custom error type looks like this:
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}
Usage:
return nil, &NotFoundError{Resource: "User", ID: 42}
This error now carries structured context.
2. Using errors.As With Custom Errors
To check if an error is of a certain type—even if wrapped—use errors.As:
var nfErr *NotFoundError
if errors.As(err, &nfErr) {
fmt.Println("missing:", nfErr.Resource, nfErr.ID)
}
This pattern is essential for advanced domain logic.
3. Adding Categories to Your Errors
Sometimes you don’t need specific fields—just categories.
You can create simple typed error enums:
type ErrorCode int
const (
ErrCodeInvalidInput ErrorCode = iota
both
ErrCodeUnauthorized
)
type AppError struct {
Code ErrorCode
Message string
}
func (e *AppError) Error() string {
return e.Message
}
Usage:
return nil, &AppError{
Code: ErrCodeInvalidInput,
Message: "email is invalid",
}
Then you can route behavior based on Code.
4. Embedding Errors to Preserve Cause
You can embed another error to maintain a chain:
type ValidationError struct {
Field string
Err error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %v", e.Field, e.Err)
}
Caller:
if errors.Is(err, ErrRequired) {
// handle missing required field
}
This combines typed errors with wrapped errors.
5. Custom Errors for HTTP APIs
A common pattern in Go APIs is to map custom errors to HTTP status codes:
type HTTPError struct {
StatusCode int
Err error
}
func (e *HTTPError) Error() string {
return e.Err.Error()
}
Usage in handlers:
func GetUserHandler(w http.ResponseWriter, r *http.Request) error {
user, err := findUser(1)
if err != nil {
return &HTTPError{StatusCode: http.StatusNotFound, Err: err}
}
return json.NewEncoder(w).Encode(user)
}
Your middleware can later map that to:
var httpErr *HTTPError
if errors.As(err, &httpErr) {
http.Error(w, httpErr.Error(), httpErr.StatusCode)
return
}
This is a clean and scalable pattern.
6. When to Use Custom Error Types
Use custom errors when you need:
✔ Extra context (IDs, paths, fields, metadata)
✔ Structured data for logic decisions
✔ Domain-specific categories
✔ Better debugging information
✔ Error-to-HTTP or error-to-gRPC mapping
✔ Validation error groups
Avoid custom errors when:
-
A sentinel error is enough
-
You are only passing through context (wrapping is simpler)
7. Example: Domain-Specific Custom Error
type InsufficientBalanceError struct {
Required float64
Current float64
}
func (e *InsufficientBalanceError) Error() string {
return fmt.Sprintf(
"insufficient balance: need %.2f but have %.2f",
e.Required, e.Current,
)
}
Usage:
if account.Balance < price {
return &InsufficientBalanceError{
Required: price,
Current: account.Balance,
}
}
Caller:
var balErr *InsufficientBalanceError
if errors.As(err, &balErr) {
fmt.Println("Top up at least:", balErr.Required-balErr.Current)
}
This is far more expressive than a plain string error.
8. Best Practices for Custom Error Types
-
Keep error structures small and focused
-
Use pointers (
*MyError) to allowerrors.Asdetection -
Don’t log inside custom error types
-
Don’t export struct fields unless callers need them
-
Include only meaningful, structured context
-
Avoid mixing custom types with panic/recover
Error Handling Strategies
Now that you understand sentinel errors, wrapping, and custom types, it’s time to look at how to apply them effectively in real Go codebases. Error-handling strategy is crucial because it determines how readable, maintainable, and debuggable your application becomes.
Go developers commonly use a set of idiomatic patterns to keep error handling clean and intentional.
1. Fail Fast (Early Returns)
One of the most widely used strategies is returning early when an error occurs. Avoid deeply nested blocks:
❌ Not idiomatic:
if err == nil {
if data != nil {
if isValid(data) {
// logic
}
}
}
✅ Idiomatic:
if err != nil {
return nil, err
}
if data == nil {
return nil, ErrNoData
}
if !isValid(data) {
return nil, ErrInvalidData
}
The code remains flat, simple, and readable.
2. Handle Errors Only When You Can Do Something Useful
A common beginner mistake is handling errors too early—especially logging everywhere.
Ask yourself:
👉 Should this function handle the error or simply return it?
General rule:
-
Return the error when the caller has more context.
-
Handle/log the error at the top of the call stack (e.g., in
main, middleware, CLI entry point).
Example:
func loadUser(id int) (*User, error) {
user, err := db.GetUser(id)
if err != nil {
return nil, fmt.Errorf("load user: %w", err)
}
return user, nil
}
The handler decides what to do:
user, err := loadUser(10)
if err != nil {
log.Println("failed to load user:", err)
return
}
3. Avoid Double Logging
One of the most common issues in Go projects is logging the same error multiple times:
❌ Logging inside a helper function
❌ Logging again in the caller
❌ Logging again in the HTTP middleware
This leads to noisy logs like:
loadUser: query DB: record not found
failed to load user: loadUser: query DB: record not found
500 internal server error: loadUser: query DB: record not found
Rule of thumb:
👉 Log once, ideally at the top.
4. Avoid Panics in Normal Flow
Panics should never be used for expected errors. They interrupt control flow and make the code harder to reason about.
❌ Bad:
if err != nil {
panic("file missing")
}
Panics are appropriate only when:
-
The system is in a truly unrecoverable state
-
Initialization fails (e.g., no DB connection and the app can’t run)
-
You're handling programmer errors, not user errors
Everything else should be handled via returned errors.
5. Use Helper Functions to Reduce Repetition
Repeated patterns can be extracted:
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, wrap(err, "read file", "path", path)
}
return data, nil
}
Or using a reusable error wrapper:
func wrap(err error, msg string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
}
6. Keep Error Messages Clear and Consistent
Good messages:
-
They are short but descriptive
-
Include context (“while loading config”)
-
Avoid blame (“you passed the wrong input”)
-
Use lowercase and no punctuation (Go style)
Example:
connect to db: timeout
read config: open config.json: no such file or directory
7. Narrow Error Scope with Higher-Level Abstractions
Instead of leaking internal errors upward, map them to domain-level errors:
Low-level:
return nil, sql.ErrNoRows
Mid-level:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get user: %w", err)
High-level:
user, err := service.LoadUser(10)
if errors.Is(err, ErrUserNotFound) {
return respond404(w)
}
Each layer adds meaning appropriate to its level.
8. Group Errors in Concurrent Code
When running tasks in goroutines, you need a coordinated way to propagate errors.
Go’s errgroup simplifies this:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return fetchA()
})
g.Go(func() error {
return fetchB()
})
if err := g.Wait(); err != nil {
return err
}
The first error stops the group and cancels all other goroutines.
9. Avoid Ignoring Errors
The blank identifier _ should never be used to ignore errors unless truly safe:
❌ Dangerous:
_ = json.Unmarshal(data, &obj)
If you must ignore an error, add a comment explaining why:
_ = session.Close() // best-effort cleanup
10. Use Sentinel Errors Sparingly
Use them to signal specific known cases—not for every error scenario. Too many sentinel errors lead to clutter and confusion.
Better alternatives:
-
Wrapped errors with context
-
Custom error types
-
Domain-level error codes
11. Summary of Idiomatic Strategies
-
Fail fast, avoid nesting
-
Only handle errors where action can be taken
-
Don’t log multiple times
-
Don’t panic about expected problems
-
Use helper functions to keep code clean
-
Wrap errors when adding context
-
Use
errors.Isanderrors.Asfor comparisons -
Avoid ignoring errors
-
Keep error messages simple and consistent
Logging Errors Correctly
Logging is a crucial part of error handling in Go applications, but it’s often misused. Many beginner and intermediate Go codebases suffer from “log spam,” duplicated logs, missing context, or logging at the wrong level. Proper logging ensures that errors are traceable, meaningful, and actionable—especially in production environments.
This section covers the idiomatic way to log errors in Go so your logs stay clean and useful.
1. Log Only Where It Makes Sense
The most important rule:
👉 Don’t log an error more than once.
A common anti-pattern:
func readConfig() ([]byte, error) {
data, err := os.ReadFile("config.json")
if err != nil {
log.Println("readConfig error:", err) // ❌ Don't log here
return nil, err
}
return data, nil
}
func main() {
_, err := readConfig()
if err != nil {
log.Fatal("failed to start:", err) // ❌ Logged again
}
}
This leads to confusing, duplicated logs.
Rule of thumb:
-
Low-level functions → return errors
-
Entry points (
main, HTTP middleware, worker loops) → log errors once
2. Avoid Logging After Wrapping
When you wrap errors with context:
return fmt.Errorf("load user: %w", err)
Logging should happen later, when the wrapped chain is complete.
Otherwise, logs become noisy and inconsistent.
3. Log Errors with Context
Good logs always provide context. For example:
❌ Bad:
error reading file
✅ Good:
error reading file: path=config.json: open config.json: no such file or directory
Context should include:
-
What operation failed
-
Important parameters (path, ID, user email, etc.)
-
The underlying wrapped error
4. Use Structured Logging in Production
Plain log.Println works, but structured logging helps parsing, filtering, and real-time observability.
Popular structured logging libraries:
-
Zap (Uber) → fastest
-
zerolog → zero allocation
-
logrus → familiar API (but slower)
Example using Zap:
logger, _ := zap.NewProduction()
defer logger.Sync()
if err != nil {
logger.Error("failed to process order",
zap.Int("order_id", id),
zap.Error(err),
)
}
Structured logs help tools like ELK, Loki, or Datadog classify and search efficiently.
5. Use Log Levels Correctly
Choose the correct log level:
| Level | When to Use |
|---|---|
| DEBUG | Developer info, fine-grained detail |
| INFO | Normal app operation |
| WARN | Unexpected but recoverable situations |
| ERROR | Action needed—operation failed |
| FATAL | The app must stop immediately |
Example:
-
Missing optional config → WARN
-
User provides invalid input → INFO or ERROR (depending on context)
-
DB connection lost → ERROR
-
Config file missing on startup → FATAL
6. Avoid Logging Inside Libraries
Libraries should never log.
Instead, return structured errors or typed errors.
Your application layer decides:
-
Where to log
-
What to log
-
How to log
This keeps libraries reusable and clean.
7. Never Log Sensitive Data
Avoid logging:
-
Passwords
-
Tokens / API keys
-
User personal data (email, phone, address)
-
Raw request bodies
If needed, sanitize:
logger.Error("login failed", zap.String("username", sanitize(user)))
8. Ensure Logs Are Machine and Human-Friendly
Good logs:
-
They are short but meaningful
-
Include context keys
-
Don’t include stack traces unless necessary
-
Use consistent key names (e.g.,
user_idinstead ofuserId)
Example:
ERROR process payment: order_id=123 amount=49.90 err="insufficient funds"
9. Avoid Logging in Hot Paths
Functions called many times per second (middleware, validation, loops) should not log on common failures—only in exceptional cases. Too many logs may cause:
-
Increased latency
-
Disk usage spikes
-
Harder debugging
-
Log rate limits triggering
Instead, return errors to be logged once at a higher level.
10. Summary of Logging Best Practices
-
Log once, not everywhere
-
Add meaningful context
-
Use structured logs in production
-
Don’t log inside libraries
-
Don’t log sensitive data
-
Use proper log levels
-
Avoid logs in hot or tight loops
-
Log errors only after they’re fully wrapped
Panics and Recover
Go’s error-handling model encourages returning errors explicitly—but sometimes failures are so severe or unexpected that normal error returns don’t make sense. That’s where panic and recover come in.
However, panic is not Go’s exception system. It’s a mechanism reserved for truly unrecoverable conditions. Used improperly, panic can destabilize your application and make debugging harder.
This section explains when to use panic, how to recover safely, and how to avoid common misuse.
1. What Is a Panic?
A panic is Go’s way of signaling that the program has encountered a state it cannot safely continue from.
Triggers include:
-
Calling
panic()directly -
Runtime errors such as:
-
nil pointer dereference
-
out-of-bounds slice access
-
type assertions that fail without “comma ok”
-
When a panic occurs:
-
The current function stops executing
-
Deferred functions run
-
The panic propagates up the call stack
-
If unhandled, the program crashes
2. When Not to Use Panic
Go beginners often use panic like an exception system—this is wrong.
❌ Do not use panic for:
-
Normal, expected errors
-
Input validation
-
Missing files
-
Invalid JSON
-
Database query failures
-
Network timeouts
All of these should be handled with normal error returns.
3. When Panic Is Appropriate
Panics are acceptable when the program cannot reasonably continue:
a. Programmer Errors
Examples:
var arr []int
fmt.Println(arr[10]) // runtime panic
b. Truly Unrecoverable State
Example: your HTTP server fails to bind to a port in main.
func main() {
if err := loadCriticalConfig(); err != nil {
panic("failed to load config: " + err.Error())
}
}
c. Invariants or internal logic must never fail
Example: a switch statement where every case should be impossible:
switch state {
case StateReady, StateRunning, StateStopped:
// valid states
default:
panic(fmt.Sprintf("unknown state: %v", state))
}
d. Tests
Panics can be useful in tests when unexpected states occur.
4. What Is Recover?
recover() allows a deferred function to catch a panic and stop it from crashing the program.
But recover only works inside deferred functions:
func safeRun() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered from panic:", r)
}
}()
risky()
}
5. Using Recover Carefully
Recover can prevent your application from crashing—but misuse can hide bugs.
❌ Anti-pattern:
func risky() {
defer recover() // ❌ recover() return value ignored
panic("boom")
}
Correct usage:
func riskyWrapper() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic occurred: %v", r)
}
}()
risky()
return nil
}
6. Panic in HTTP Servers
Panic inside an HTTP handler will crash the entire server unless recovered.
Thus, a panic-recover middleware is idiomatic:
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Println("panic in handler:", rec)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
This keeps the server alive while reporting the problem.
7. Don’t Use Recover to Hide Real Problems
Recovering all panics and continuing blindly can lead to:
-
corrupted state
-
inconsistent memory
-
partial updates
-
hidden bugs
-
unpredictable behavior
Recover only where isolation is clear (e.g., HTTP handler, goroutine wrapper).
8. Panics in Goroutines
Panics inside goroutines do not propagate upward.
Example:
go func() {
panic("boom") // program may crash silently
}()
Solution: wrap goroutines with a recovery function:
func safeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Println("goroutine panic:", r)
}
}()
fn()
}()
}
Now all goroutines have safety guards.
9. Summary of Panic/Recover Best Practices
-
Don’t use panic for expected errors
-
Use panic only for:
-
programmer errors
-
invariant violations
-
unrecoverable initialization failures
-
-
Always wrap panic recovery inside
defer -
Use recovery at boundaries (HTTP handlers, goroutines)
-
Never recover internal library panics unless there is a clear strategy
-
Don’t swallow panics silently—log them
-
Convert panic to error only when appropriate
Error Handling in Goroutines
Goroutines are lightweight and powerful, but they introduce a key challenge:
👉 errors inside goroutines don’t automatically propagate to the parent function.
If a goroutine returns an error or panics, the caller won’t know unless you explicitly communicate it. This section explains clean, idiomatic patterns for handling errors in concurrent Go code.
1. Why Goroutine Errors Are Tricky
Example problem:
go func() {
err := doWork()
if err != nil {
// this error is lost forever!
}
}()
Because the goroutine runs asynchronously, the parent has no way of knowing the result. This leads to:
-
hidden failures
-
inconsistent state
-
debugging difficulty
So Go developers use channels, shared variables, or errgroup to propagate errors safely.
2. Using Channels to Return Errors
The simplest method is sending errors over a channel.
func doAsync(ch chan error) {
if err := doWork(); err != nil {
ch <- err
return
}
ch <- nil
}
Caller:
errCh := make(chan error)
go doAsync(errCh)
if err := <-errCh; err != nil {
log.Println("async task failed:", err)
}
Works, but gets messy with multiple goroutines.
3. Handling Multiple Goroutines with Channels
Example with three goroutines:
errCh := make(chan error, 3)
for i := 0; i < 3; i++ {
go func(i int) {
errCh <- doTask(i)
}(i)
}
for i := 0; i < 3; i++ {
if err := <-errCh; err != nil {
return err
}
}
Problems:
-
Hard to cancel remaining goroutines
-
Requires manual management
-
Easy to leak goroutines if not careful
This is why most modern Go projects use errgroup.
4. Using errgroup to Simplify Concurrent Error Handling
The errgroup package (from golang.org/x/sync/errgroup) is the idiomatic solution.
It runs multiple goroutines and returns the first error encountered.
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return callAPI(ctx)
})
g.Go(func() error {
return processData(ctx)
})
if err := g.Wait(); err != nil {
return err
}
Features:
-
Automatically waits for all goroutines
-
Cancels remaining goroutines on first error (via context)
-
Clean and readable
5. Error Propagation with Context Cancellation
errgroup.WithContext provides a ctx that gets canceled on the first error.
Example:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return fetchUser(ctx, 10)
})
g.Go(func() error {
return fetchOrders(ctx, 10)
})
If either fails:
-
ctxis canceled -
Other goroutines can stop early using
<-ctx.Done()
6. Protecting Goroutines from Panics
Panics inside goroutines can crash your entire program.
Always recover inside concurrent workers:
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("goroutine panic: %v", r)
}
}()
return riskyWork()
})
This ensures safe propagation of serious errors.
7. Using Worker Pools with Error Handling
Worker pools often use a channel for tasks and error aggregation.
Example:
tasks := make(chan int)
errCh := make(chan error, 1)
for i := 0; i < 5; i++ {
go func() {
for t := range tasks {
if err := process(t); err != nil {
errCh <- err
}
}
}()
}
Stop early on error:
select {
case err := <-errCh:
return err
default:
}
Better yet: wrap in errgroup for simplicity.
8. Common Concurrency Error Mistakes
❌ Ignoring goroutine errors
Forgetting to return results anywhere.
❌ Writing to an error variable from multiple goroutines
Causes race conditions.
❌ Blocking forever by forgetting to close channels
A classic concurrency bug.
❌ Using panic instead of returning errors
Panics crash all workers unless explicitly recovered.
❌ Ignoring context cancellation
Wastes CPU, network calls, or DB connections.
9. Best Practices for Error Handling in Goroutines
-
Use
errgroupfor most cases -
Always return errors to the parent
-
Wrap errors with context (
fmt.Errorf("task %d: %w", i, err)) -
Recover from panics inside goroutines
-
Use context to stop long-running tasks
-
Avoid global variables for sharing errors
-
Prefer buffered channels when multiple goroutines send errors
Error Handling in APIs
Error handling becomes even more important when building APIs, because errors must be translated into clear, meaningful, and secure responses for clients. Whether you're building a REST API, GraphQL service, or gRPC backend, consistent error handling improves debugging, client experience, and maintainability.
This section teaches you how to convert Go errors into proper HTTP responses, include structured JSON error messages, and avoid leaking internal information.
1. Return Errors, Don’t Write Responses in Business Logic
Your service or repository layer should not write HTTP responses.
Instead, you return errors up to the handler.
Service function:
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}
Handler decides the HTTP response:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.service.GetUser(10)
if err != nil {
h.respondError(w, err)
return
}
json.NewEncoder(w).Encode(user)
}
This clean separation keeps logic pure and testable.
2. Map Domain Errors to HTTP Status Codes
A common pattern is mapping typed or sentinel errors to status codes:
func (h *UserHandler) respondError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, ErrInvalidInput):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
This ensures consistent behavior across all handlers.
3. Avoid Leaking Internal Errors to Clients
Don’t return detailed internal messages like:
load user: query DB: connection refused
These messages may reveal:
-
internal structure
-
SQL queries
-
sensitive context
-
deployment details
Instead, return generic messages, and log the detailed ones server-side:
log.Println("load user:", err)
http.Error(w, "user not available", 500)
4. Structured JSON Error Responses (Recommended)
JSON error responses are easier for clients to parse.
Example:
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Field string `json:"field,omitempty"`
}
Usage:
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}
Handler:
if errors.Is(err, ErrUserNotFound) {
writeJSONError(w, http.StatusNotFound, "user not found")
return
}
5. Validation Errors
Validation errors commonly require field-level details:
type ValidationError struct {
Field string `json:"field"`
Msg string `json:"message"`
}
Bundled error response:
type ValidationErrors struct {
Errors []ValidationError `json:"errors"`
}
Return for invalid input:
writeJSON(w, 400, ValidationErrors{
Errors: []ValidationError{
{"email", "must be valid"},
{"username", "required"},
},
})
6. Using Error Wrapping in APIs
Wrapping provides excellent logs without exposing details:
return nil, fmt.Errorf("find user %d: %w", id, err)
But the handler should not expose that entire chain:
log.Println("failed:", err)
writeJSONError(w, 500, "internal error")
7. Custom HTTPError Type
Many production apps use a custom type to standardize responses:
type HTTPError struct {
Status int
Message string
Err error
}
func (e *HTTPError) Error() string { return e.Err.Error() }
Usage:
return nil, &HTTPError{
Status: http.StatusNotFound,
Message: "user not found",
Err: ErrUserNotFound,
}
Middleware:
var httpErr *HTTPError
if errors.As(err, &httpErr) {
writeJSONError(w, httpErr.Status, httpErr.Message)
return
}
This allows a single, centralized place to define error responses.
8. Gracefully Handling Panics in HTTP APIs
Use a panic recovery middleware (shown earlier) so your API doesn’t crash:
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Println("panic:", rec)
writeJSONError(w, 500, "server error")
}
}()
next.ServeHTTP(w, r)
})
}
This keeps panics from being exposed to clients.
9. gRPC Error Handling Briefly
If using gRPC:
-
Use
status.Errorandcodes.* -
Convert domain errors to proper gRPC codes
-
Example:
if errors.Is(err, ErrUserNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
This mirrors the HTTP mapping but via protocol-specific error codes.
10. Key Best Practices for API Error Handling
-
Keep business logic separate from HTTP logic
-
Return structured JSON errors
-
Do not expose wrapped/internal error chains
-
Map sentinel or typed errors to HTTP status codes
-
Log internal errors, but respond with safe messages
-
Use recovery middleware to avoid API crashes
-
Standardize errors across your entire API
-
Avoid writing different error formats in different handlers
Real-World Examples
Now that you’ve learned all the idiomatic techniques—sentinel errors, wrapping, typed errors, panic recovery, structured logs, and API mapping—let’s put everything together.
This section covers practical, real-world error-handling scenarios you’ll face in typical Go applications.
We'll go through:
-
File processing with wrapped + typed errors
-
Database operations with sentinel mapping
-
Validation errors in services
-
A complete REST API flow using all techniques
-
Concurrency with
errgroupin a real scenario
1. File Processing Example (Wrapping + Sentinel Errors)
Scenario:
Load and parse a JSON config file. Produce helpful errors for the caller.
Code:
var ErrConfigNotFound = errors.New("config not found")
func LoadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("read config %s: %w", path, err)
}
return data, nil
}
Caller:
config, err := LoadConfig("config.json")
switch {
case errors.Is(err, ErrConfigNotFound):
log.Println("config file missing!")
case err != nil:
log.Println("unexpected config error:", err)
default:
fmt.Println("config loaded")
}
Highlights:
-
Maps OS-level error → domain error
-
Wraps all other unexpected errors
-
The caller acts based on the error category
2. Database Example (Sentinel → Typed Error Mapping)
Scenario:
User lookup with DB-level sentinel errors mapped to business errors.
var ErrUserNotFound = errors.New("user not found")
func (r *UserRepo) FindByID(id int) (*User, error) {
user := &User{}
err := r.db.QueryRow("SELECT id, email FROM users WHERE id=?", id).
Scan(&user.ID, &user.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("query user %d: %w", id, err)
}
return user, nil
}
Highlights:
-
Converts low-level
sql.ErrNoRows→ domainErrUserNotFound -
Wraps unexpected DB errors with context
-
User-facing layer won’t accidentally leak SQL errors
3. Validation Example (Custom Typed Errors)
Scenario:
User input validation with field-level details.
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func ValidateUser(u *User) error {
if u.Email == "" {
return &ValidationError{Field: "email", Msg: "required"}
}
if !strings.Contains(u.Email, "@") {
return &ValidationError{Field: "email", Msg: "invalid format"}
}
return nil
}
Caller:
if err := ValidateUser(user); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Validation failed on %s: %s\n", ve.Field, ve.Msg)
}
}
4. Full REST API Flow Example (Complete Integration)
This combines:
-
Domain sentinel errors
-
Wrapping
-
Typed HTTP errors
-
Centralized API error response
-
Logging best practices
4.1 Domain Layer
var ErrUserDisabled = errors.New("user disabled")
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user.Disabled {
return nil, ErrUserDisabled
}
return user, nil
}
4.2 HTTPError Wrapper
type HTTPError struct {
Status int
Message string
Err error
}
func (e *HTTPError) Error() string {
return e.Err.Error()
}
4.3 Handler-Level Error Mapping
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
id := 10
user, err := h.service.GetUser(id)
if err != nil {
switch {
case errors.Is(err, ErrUserNotFound):
return &HTTPError{404, "user not found", err}
case errors.Is(err, ErrUserDisabled):
return &HTTPError{403, "user disabled", err}
default:
return &HTTPError{500, "server error", err}
}
}
return writeJSON(w, 200, user)
}
4.4 Central Error Middleware
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := h.route(w, r)
if err != nil {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
log.Println("handler error:", httpErr.Err)
writeJSONError(w, httpErr.Status, httpErr.Message)
return
}
// fallback
log.Println("unexpected:", err)
writeJSONError(w, 500, "internal error")
}
}
This pattern ensures:
-
Business logic → returns typed/sentinel errors
-
Handlers → map to HTTP
-
Middleware → logs once and writes JSON response
5. Concurrency Example (errgroup + Wrapping)
Scenario:
Fetch user profile, orders, and permissions concurrently.
func LoadDashboard(ctx context.Context, userID int) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var orders []Order
var perms []string
g.Go(func() error {
u, err := fetchUser(ctx, userID)
if err != nil {
return fmt.Errorf("fetchUser: %w", err)
}
user = u
return nil
})
g.Go(func() error {
o, err := fetchOrders(ctx, userID)
if err != nil {
return fmt.Errorf("fetchOrders: %w", err)
}
orders = o
return nil
})
g.Go(func() error {
p, err := fetchPermissions(ctx, userID)
if err != nil {
return fmt.Errorf("fetchPermissions: %w", err)
}
perms = p
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{User: user, Orders: orders, Perms: perms}, nil
}
This gives:
-
Clean propagation of the first error
-
Automatic cancellation via
ctx -
Wrapped context for debugging
6. Summary of Real-World Patterns
✔ Map low-level errors to domain-specific sentinel errors
✔ Wrap errors when adding operational context
✔ Use typed errors for validation & structured flows
✔ Convert errors to standardized JSON in APIs
✔ Protect handlers & goroutines using panic recovery
✔ Use errgroup to keep concurrency manageable
✔ Log errors once, at the right level
✔ Never leak internal error chains to clients
Best Practices Summary
This section distills everything you’ve learned into a clean, practical checklist you can apply to any Go project. These are the idiomatic patterns followed by experienced Go developers—not just stylistic preferences, but approaches proven to produce clean, maintainable, and debuggable Go code.
1. Core Principles
✅ 1. Errors Are Values
Treat errors as part of your function’s normal output.
No exceptions, no hidden control flow.
✅ 2. Return Early (Fail Fast)
Avoid nested if blocks.
Stop as soon as an error occurs.
✅ 3. Add Context with Wrapping
Use:
fmt.Errorf("load config: %w", err)
Context helps debugging without exposing details to clients.
✅ 4. Log Once, Preferably at the Top
Never log the same error multiple times.
Handlers or main() should handle logging—not deep service layers.
2. Sentinel Errors (Use Sparingly)
✔ Use them when:
-
You need to signal a specific known condition
-
The error message is universal across your package
✘ Avoid them when:
-
You need extra context (IDs, fields, details)
-
The error message must be dynamic
Use errors.Is when comparing sentinel errors.
3. Custom Error Types
Use typed errors when you need:
-
Metadata (field, user ID, amount, etc.)
-
Validation messages
-
Mapping between layers (e.g., to HTTP codes)
Implement:
func (e *MyError) Error() string
Check using errors.As.
4. Wrapping Errors Correctly
✔ Wrap errors when:
-
Adding context from lower-level functions
✘ Do NOT wrap when:
-
Converting to a higher-level domain error
-
Logging at that level
Use %w only once per layer.
5. Panics and Recover
✔ Use panic only for:
-
Programmer errors
-
Impossible states
-
Unrecoverable initialization failures
-
Test shortcuts
✘ Do NOT use panic for:
-
Validation
-
Missing files
-
Database errors
-
Network errors
Always recover in:
-
HTTP middleware
-
Goroutine wrappers
6. Goroutines and Concurrency
✔ Use errgroup for multiple goroutines
Cleaner, cancellable, and easy to propagate errors.
✔ Protect goroutines with deferred recover blocks
Panics in goroutines don’t propagate up.
✔ Use context to cancel long-running tasks
Cancel early when any error occurs.
7. API-Level Error Handling
✔ Map domain errors → HTTP status codes
Avoid leaking internal errors to clients.
✔ Return structured JSON errors
Consistent and easy to parse.
✔ Log detailed errors server-side
Return safe messages to users.
✔ Use a centralized error response middleware
Prevents duplicated logic across handlers.
8. Validation Best Practices
-
Use custom-typed errors for field-specific feedback
-
Aggregate validation errors in a list
-
Don’t return generic messages for validation problems
-
Don’t panic over user mistakes
9. Logging Best Practices
✔ Use structured logging in production
Zap, zerolog, or logrus.
✔ Include contextual fields
e.g., user_id, order_id, path
✔ Avoid logging in libraries
Leave logging to the application boundary.
✔ Don’t leak sensitive data
Mask or sanitize tokens, emails, etc.
10. Clean Code Guidelines
-
Keep error messages short and lowercase
-
Use consistent prefixes:
"read config:","fetch user:" -
Don’t ignore errors unless intentional (and comment why)
-
Don’t use overly clever error wrapping
-
Keep layers responsible for error mapping (e.g., repo → service → handler)
11. Quick Checklist
Here’s your at-a-glance checklist:
✅ Return errors, don’t panic
✅ Fail fast, avoid nesting
✅ Wrap errors when adding context
❌ Don’t log deep in the call stack
❌ Don’t log multiple times
🔍 Use errors.Is and errors.As
📦 Use typed errors when you need structure
🌐 In APIs, convert domain errors → HTTP codes
💥 Recover from panics at safe boundaries
🔁 Use errgroup for concurrent tasks
🧼 Keep errors clear, consistent, and lowercase
🔒 Don’t leak sensitive error details
Conclusion
Error handling is one of the most important skills in Go development. Unlike many languages that rely on exceptions or magic control flow, Go embraces a simple, explicit, and transparent approach: errors are values. This philosophy results in clearer code, predictable behavior, and fewer hidden surprises—especially in large, production systems.
Throughout this tutorial, you learned how to use every tool Go provides to build robust, idiomatic error flows:
✔ Returning errors explicitly
The foundation of Go’s error model—functions return errors as normal values, giving you complete control over how failures are handled.
✔ Sentinel errors for known conditions
Useful for simple, shared failure states like ErrNotFound.
✔ Wrapping errors with context
Using %w to preserve the underlying cause while adding meaningful context.
✔ Custom error types
Perfect for structured data like field names, validation messages, or domain-specific metadata.
✔ Proper logging practices
Log once, log with context, and avoid noisy or redundant messages.
✔ Safe use of panic and recover
Only for truly unrecoverable failures—never for normal error-handling flow.
✔ Concurrency-safe error handling
Using channels or errgroup to manage errors in goroutines without losing visibility.
✔ Clean, consistent API error responses
Mapping domain errors to HTTP codes, using JSON error structures, and avoiding leakage of sensitive system details.
✔ Real-world patterns
Combining all of the above in file operations, database logic, validation flows, REST APIs, and concurrent workloads.
The Go Philosophy in One Sentence
👉 Handle errors explicitly, consistently, and as close to the source as possible—while keeping your code clean, readable, and predictable.
Mastering these patterns not only makes your code more robust but also aligns you with the practices used by experienced Go engineers across the industry. With these tools, you'll be able to design error flows that scale smoothly—whether you're building microservices, CLIs, APIs, or distributed systems.
You can find the full source code on our GitHub.
That's just the basics. If you need more deep learning about Go/Golang, you can take the following cheap course:
- Go - The Complete Guide
- NEW-Comprehensive Go Bootcamp with gRPC and Protocol Buffers
- Backend Master Class [Golang + Postgres + Kubernetes + gRPC]
- Complete Microservices with Go
- Backend Engineering with Go
- Introduction to AI and Machine Learning with Go (Golang)
- Working with Concurrency in Go (Golang)
- Introduction to Testing in Go (Golang)
- Design Patterns in Go
- Go Bootcamp: Master Golang with 1000+ Exercises and Projects
Thanks!
