Errors are values, not exceptions
You are writing a function that reads a configuration file. The file is missing. You return an error. The caller receives open config.yaml: no such file or directory. That message tells the operator exactly what went wrong and where. Now imagine you return a bare string, or you panic, or you swallow the error and return nil. The program crashes later with a cryptic message, or it runs with bad data. Go forces you to handle errors because they are values, not exceptions. You create them, you pass them up the call stack, and you inspect them. The two tools you reach for most are errors.New for static messages and fmt.Errorf for dynamic context.
The error interface and allocation
In Go, an error is any value that implements the error interface. That interface has a single method: Error() string. Any type with that method satisfies the interface. The standard library provides errors.New to create a simple error with a fixed message, and fmt.Errorf to build an error from a format string.
Every call to errors.New or fmt.Errorf allocates memory on the heap. The runtime creates a small struct that holds the message and implements Error(). In a tight loop, creating errors can slow you down. That is why sentinel errors exist. You define a sentinel error once as a package-level variable. You return the variable. No allocation happens when you return it. The compiler sees the variable and reuses the pointer. This pattern keeps hot paths fast while keeping error handling explicit.
Creating static errors with errors.New
Sentinel errors are best for conditions that callers might want to check. If a function can fail in a specific way that the caller needs to handle differently, define a sentinel error. The name usually starts with Err by convention. This signals that the value is an error. Export the variable if other packages need to compare it.
Here is the simplest way to define and return a sentinel error.
package main
import (
"errors"
"fmt"
)
// ErrNotFound is a sentinel error for missing items.
var ErrNotFound = errors.New("item not found")
// FindItem returns the item or an error.
func FindItem(id int) (string, error) {
if id == 0 {
// Return the sentinel error for a known condition.
return "", ErrNotFound
}
return "item", nil
}
func main() {
_, err := FindItem(0)
if err != nil {
// Print the error message.
fmt.Println(err)
}
}
The variable ErrNotFound holds a pointer to the error struct. Every time FindItem returns ErrNotFound, it returns the same pointer. Callers can compare this pointer to detect the condition.
Sentinel errors are for comparison. Formatted errors are for context.
Adding context with fmt.Errorf
Real code rarely returns a bare error. You usually get an error from a lower layer, like the operating system or a database driver. That error tells you what failed technically, but not what your function was doing. You wrap the error with context so the caller understands the operation that failed.
fmt.Errorf works like fmt.Sprintf, but it returns an error. It supports the %w verb to wrap an existing error. When you use %w, the new error stores the original error inside. The resulting error implements Unwrap() error, which returns the wrapped value. This chain lets you peel back layers of context without losing the root cause.
Here is how you wrap an error with context.
package main
import (
"fmt"
"os"
)
// ReadConfig loads configuration from a file path.
func ReadConfig(path string) error {
// Check if the file exists before opening.
if _, err := os.Stat(path); err != nil {
// Wrap the OS error with context about the operation.
// %w preserves the original error for unwrapping.
return fmt.Errorf("check config %s: %w", path, err)
}
// Open the file for reading.
f, err := os.Open(path)
if err != nil {
// Wrap the open error.
return fmt.Errorf("open config %s: %w", path, err)
}
// Close the file when done.
defer f.Close()
return nil
}
func main() {
err := ReadConfig("missing.yaml")
if err != nil {
// Print the full error chain.
fmt.Println(err)
}
}
The output shows the chain: check config missing.yaml: stat missing.yaml: no such file or directory. Each function adds one layer of context. The root cause remains visible at the end.
Wrap with context. Unwrap with purpose.
How the chain works at runtime
When you wrap an error with %w, the runtime builds a linked list of errors. The outermost error holds the message and a pointer to the inner error. The inner error holds its message and a pointer to the next one. This continues until you reach the root error, which has no inner error.
The errors package provides functions to traverse this chain. errors.Is(err, target) walks the chain and checks if any error matches the target. It returns true if the target is found. errors.As(err, target) walks the chain and checks if any error matches the type of the target. It returns true if a match is found and sets the target to that error value.
These functions handle the unwrapping for you. You do not need to call Unwrap manually. If you use == to compare errors, you only compare the outermost value. The comparison fails if the error is wrapped. Always use errors.Is for sentinel comparisons. Always use errors.As for type assertions on errors.
Here is how you check for a sentinel error in a wrapped chain.
package main
import (
"errors"
"fmt"
)
// ErrPermissionDenied indicates access was blocked.
var ErrPermissionDenied = errors.New("permission denied")
// AccessResource checks permissions and returns an error.
func AccessResource(user string) error {
if user != "admin" {
// Return the sentinel error.
return ErrPermissionDenied
}
return nil
}
// TryAccess wraps the access call with context.
func TryAccess(user string) error {
err := AccessResource(user)
if err != nil {
// Wrap the error before returning.
return fmt.Errorf("try access for %s: %w", user, err)
}
return nil
}
func main() {
err := TryAccess("guest")
if errors.Is(err, ErrPermissionDenied) {
// Handle the specific error case.
fmt.Println("access blocked")
}
}
errors.Is finds ErrPermissionDenied inside the wrapped error. The check succeeds even though the returned error is not the sentinel itself.
Errors are values. Treat them like data.
Realistic error flow
In production code, errors flow through multiple layers. Each layer adds context. The top layer decides how to report the error to the user. The decision matrix depends on whether you need to check the error or just log it.
If you need to check the error, use errors.Is or errors.As. If you just need to log it, print the error. The chain provides all the details. Do not wrap errors that you are about to return immediately. If a function just calls another function and returns the error, return the error as is. Adding redundant context clutters the message.
The if err != nil pattern is verbose by design. Go removes implicit control flow. Every error is a branch you must write. This makes the code harder to skim but easier to reason about. You can see exactly where failures happen. The boilerplate is the price of clarity. Most editors run gofmt on save, so you never argue about indentation. Focus on the logic.
Pitfalls and compiler feedback
A common mistake is using %v instead of %w when you need to wrap. If you use %v, the error gets formatted into the string. The original error is lost. You cannot unwrap it. You cannot check it with errors.Is. The chain breaks. Use %w whenever you pass an error through a function. Use %v only when you are intentionally discarding the cause and creating a new root error.
Another pitfall is creating sentinel errors inside functions. If you call errors.New inside a function, every call creates a new error value. Comparisons fail because the pointers differ. Define sentinels at package level. The compiler catches type mismatches. If you try to return a string where an error is expected, you get cannot use "message" (untyped string constant) as error value in return argument. You must wrap the string in errors.New or fmt.Errorf.
The compiler also rejects unused imports with imported and not used. If you import errors but only use fmt.Errorf, remove the import. The linter usually flags unused variables too. If you ignore an error, the linter complains. The discipline is cultural. The compiler enforces types, but the community enforces error handling.
Don't fight the type system. Wrap the value or change the design.
Conventions that pay off
Start error messages with a lowercase verb. fmt.Errorf("failed to read %s: %w", name, err). The verb describes the action. The lowercase makes it read naturally when chained. failed to read config: open config.yaml: no such file or directory. If you capitalize the message, the chain looks weird. Failed to read config: open config.yaml: no such file or directory.
Use the Err prefix for sentinel errors. var ErrNotFound = errors.New("not found"). This signals it is an error value. Export the variable if other packages need to check it. Keep the message concise. The message is for humans. The value is for code.
The receiver name is usually one or two letters matching the type. (b *Buffer) Write(...) is standard. Not (this *Buffer). This keeps method signatures short. Public names start with a capital letter. Private names start lowercase. No keywords like public or private. Visibility is controlled by capitalization.
Trust gofmt. Argue logic, not formatting.
Decision matrix
Use errors.New when you define a sentinel error that callers can compare with errors.Is. Use errors.New when the error message is static and never changes. Use fmt.Errorf when you need to include dynamic values like file paths or IDs in the message. Use fmt.Errorf with %w when you want to wrap an existing error and preserve the chain for errors.Is or errors.As. Use fmt.Errorf with %v or %s when you want to discard the original error and treat the new message as the root cause. Use a custom error type when you need to attach structured data or specific methods to the error. Use errors.Is when you check for a specific sentinel error value. Use errors.As when you need to extract a custom error type from a wrapped chain.