Errors as Values
Go does not have exceptions. There is no try/catch, no throw, no finally. Instead, functions that can fail return an error as their last return value. You check it immediately after the call. This is the most distinctive aspect of Go's design and the one that provokes the most debate. Understanding why Go made this choice is essential to writing idiomatic Go.
The (result, error) Pattern
The fundamental pattern is simple: a function returns the result it computed and an error value. If the error is nil, the operation succeeded. If it is not nil, something went wrong.
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
return data, nil
}
The caller checks the error immediately:
data, err := readConfig("/etc/myapp/config.yaml")
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Use data here -- we know it's valid
This is the bread and butter of Go code. You will write this pattern hundreds of times. It is intentionally verbose because the Go authors believe error handling should be visible, not hidden.
Why Go Chose This Approach
Explicit Control Flow
With exceptions, a function call can fail in ways that are invisible at the call site. Any function might throw. The caller has no way to know which functions throw and which do not without reading documentation or source code.
Go's approach makes every potential failure visible:
user, err := db.GetUser(ctx, userID)
if err != nil {
return fmt.Errorf("fetching user %d: %w", userID, err)
}
perms, err := auth.GetPermissions(ctx, user.RoleID)
if err != nil {
return fmt.Errorf("fetching permissions for role %d: %w", user.RoleID, err)
}
Every call that can fail is followed by error handling. You see exactly where failures are possible and exactly what happens at each failure point.
No Hidden Surprises
With exceptions, an unhandled exception propagates up the call stack until something catches it -- or the program crashes. This makes it hard to reason about which code runs and which does not.
In Go, if you forget to check an error, the program continues with a zero-value result. This can still cause bugs, but the failure mode is predictable: the code proceeds with the default value, and you can spot the missing check in code review.
Errors Are Just Values
The error type in Go is an interface with a single method:
type error interface {
Error() string
}
Because errors are values, you can store them in variables, pass them to functions, aggregate them in slices, compare them, and wrap them with additional context. There is no special language machinery -- just a value.
Creating Errors
errors.New
For simple static error messages:
import "errors"
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
func getItem(id string) (*Item, error) {
item, ok := store[id]
if !ok {
return nil, ErrNotFound
}
return &item, nil
}
fmt.Errorf
For errors with dynamic context:
import "fmt"
func connectDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("opening database connection: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("pinging database at %s: %w", dsn, err)
}
return db, nil
}
The %w Verb for Wrapping
The %w verb in fmt.Errorf wraps the original error, preserving it in a chain. This is critical for two reasons: it adds context (what was happening when the error occurred) and it preserves the original error for later inspection.
func processOrder(ctx context.Context, orderID string) error {
order, err := fetchOrder(ctx, orderID)
if err != nil {
return fmt.Errorf("processing order %s: %w", orderID, err)
}
if err := validateOrder(order); err != nil {
return fmt.Errorf("processing order %s: %w", orderID, err)
}
if err := chargeCustomer(ctx, order); err != nil {
return fmt.Errorf("processing order %s: %w", orderID, err)
}
return nil
}
When this error reaches the top of your application, it reads like a stack trace:
processing order ORD-123: charging customer CUST-456: connecting to payment gateway: dial tcp 10.0.0.5:443: connection refused
Each layer adds its own context. The original error (connection refused) is preserved at the end.
%v vs %w
Use %w when you want callers to be able to inspect the wrapped error with errors.Is or errors.As. Use %v when you want to include the error text but intentionally break the chain (the original error becomes an opaque string).
// Wrapping: callers can unwrap and inspect the original error
return fmt.Errorf("failed to connect: %w", err)
// Not wrapping: original error is converted to a string
return fmt.Errorf("failed to connect: %v", err)
Use %v when exposing the internal error would leak implementation details (e.g., a database error in an API response).
The if err != nil Pattern
This is the pattern you will write most often:
result, err := someFunction()
if err != nil {
return fmt.Errorf("context about what we were doing: %w", err)
}
// continue with result
A realistic example showing a complete function:
func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("validating request: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
user := &User{
Email: req.Email,
Name: req.Name,
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
if err := s.db.InsertUser(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
if err := s.mailer.SendWelcome(ctx, user.Email); err != nil {
// Log but don't fail -- the user was created successfully
s.logger.Error("sending welcome email", "error", err, "email", user.Email)
}
return user, nil
}
Notice the decision at the end: the welcome email failure is logged but does not cause the function to return an error. The user was created successfully, and email is a secondary concern. This kind of decision-making is what Go's error handling forces you to do explicitly.
Handling Errors at Different Levels
Not every error should be wrapped and returned. Depending on where you are in the call stack, you have different options.
// Low-level: return the error with context
func (r *repo) GetUser(ctx context.Context, id int) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scanning user %d: %w", id, err)
}
return &u, nil
}
// Mid-level: wrap and return, or handle specific cases
func (s *service) GetUserProfile(ctx context.Context, id int) (*Profile, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("getting user profile: %w", err)
}
return toProfile(user), nil
}
// Top-level: translate to HTTP response
func (h *handler) handleGetUser(w http.ResponseWriter, r *http.Request) {
profile, err := h.service.GetUserProfile(r.Context(), userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
h.logger.Error("getting user profile", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(profile)
}
Common Pitfalls
- Ignoring errors.
result, _ := riskyFunction()is sometimes necessary but should be rare and deliberate. If you are discarding an error, add a comment explaining why. - Adding redundant context.
return fmt.Errorf("GetUser failed: GetUser: %w", err)repeats information. Add context about what the current function was trying to do, not the name of the function that failed. - Wrapping every error with %w. Not every error should be part of a public error chain. Use
%vwhen the internal error is an implementation detail that callers should not depend on. - Checking for errors by string matching.
if err.Error() == "not found"is fragile. Use sentinel errors anderrors.Isinstead. - Logging and returning the same error. This produces duplicate log entries as the error propagates up. Either log and handle, or wrap and return -- not both.
// Bad: logged here AND propagated up (where it gets logged again)
if err != nil {
log.Printf("failed to connect: %v", err)
return fmt.Errorf("connecting: %w", err)
}
// Good: just return with context
if err != nil {
return fmt.Errorf("connecting: %w", err)
}
- Not checking errors from deferred calls.
defer file.Close()discards the error from Close. For writes, this can mean data loss.
// Better: capture the close error
func writeFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating %s: %w", path, err)
}
defer func() {
if closeErr := f.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("closing %s: %w", path, closeErr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("writing to %s: %w", path, err)
}
return nil
}
Key Takeaways
- Go functions return
(result, error). Check the error immediately. This is the language's defining idiom. - Errors are values (the
errorinterface). They can be created, wrapped, compared, and stored like any other value. - Use
errors.Newfor static errors andfmt.Errorfwith%wfor wrapping errors with context. - Every error wrapping layer should add context about what was being attempted, not just repeat the function name.
- Use
%wto preserve the error chain for inspection. Use%vto intentionally break the chain. - Handle errors at the appropriate level: return with context from libraries, translate to user-facing responses at the edges.
- The verbosity of
if err != nilis the price Go pays for explicit, visible error handling. It is a trade-off, not a flaw.