Errors are values, not exceptions
You write a function to fetch user data. In Python, you wrap the call in a try block and hope for the best. In JavaScript, you chain a .catch() or wrap it in async/await. Go takes a different path. The function signature returns two values. You ignore the second one, and the compiler rejects the code with assignment count mismatch: 1 assignment but 2 values returned. You can't hide the error. You have to handle it right there, or pass it up. Errors in Go are values, not control flow.
Think of an error like a form returned from a government office. You hand in your application. The clerk stamps it "Approved" and hands back your permit. Or the clerk stamps "Incomplete" and hands back the form with a note saying "Missing signature." You hold the form. You decide what to do. You can fix the signature and resubmit, or you can tell the applicant they need to come back later. The error is just a piece of paper with information. It doesn't blow up the building. It doesn't teleport you to a different room. It sits in your hand until you process it.
Go embraces this pattern with if err != nil. It looks repetitive compared to try/catch. The community accepts the boilerplate because it makes the unhappy path visible. You see exactly where things can go wrong. You don't have to hunt through stack traces to find the source of a failure. The error handling is right there in the code, linear and explicit.
Errors are values. Treat them like data, not disasters.
The error interface
The error type is an interface defined in the standard library. It has one method: Error() string. Any type that implements this method is an error. This means you can create custom error types that carry extra data. You are not limited to plain strings.
package main
import "fmt"
// ValidationError represents a problem with input data.
// It carries the field name and a message.
type ValidationError struct {
Field string
Msg string
}
// Error returns the error message.
// The receiver name is short and matches the type.
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Msg)
}
// Validate checks if the input is valid.
// It returns a ValidationError if the name is empty.
func Validate(name string) error {
if name == "" {
// Return a custom error with structured data.
return &ValidationError{Field: "name", Msg: "cannot be empty"}
}
return nil
}
func main() {
// Call the function and capture the error.
err := Validate("")
// Check the error immediately.
if err != nil {
// Print the error message.
fmt.Println(err)
}
}
The receiver name is usually one or two letters matching the type: (e *ValidationError), not (this *ValidationError) or (self *ValidationError). This is a community convention that keeps method signatures clean.
You can also use the underscore _ to discard a value intentionally. If a function returns a result and an error, but you only care about the error, write _, err := DoSomething(). This tells the compiler you considered the return value and chose to drop it. Use it sparingly with errors; ignoring an error without acknowledging it is a bug waiting to happen.
Interfaces are accepted, structs are returned. Errors follow the rule.
Wrapping errors for context
In real code, you rarely return errors from the lowest layer directly to the user. You add context as the error bubbles up. Go provides fmt.Errorf with the %w verb to wrap errors. Wrapping preserves the error chain. You can unwrap it later to check for specific causes.
package main
import (
"encoding/json"
"fmt"
"os"
)
// Config holds application configuration.
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
// LoadConfig reads and parses a configuration file.
// It wraps errors to add context about the file path.
func LoadConfig(path string) (*Config, error) {
// Read the file data.
data, err := os.ReadFile(path)
if err != nil {
// Wrap the error to preserve the chain and add context.
// The %w verb marks the error as wrapped.
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var config Config
// Parse the JSON data.
if err := json.Unmarshal(data, &config); err != nil {
// Wrap the error to indicate parsing failed.
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &config, nil
}
func main() {
// Load the config.
config, err := LoadConfig("missing.json")
if err != nil {
// Print the full error chain.
fmt.Println("Error:", err)
}
}
When you print a wrapped error, Go shows the chain. The output looks like read config missing.json: open missing.json: no such file or directory. You get the context from every layer. This makes debugging much easier. You know exactly which function failed and why.
If you pass the wrong type to fmt.Errorf, the compiler complains with cannot use x (type string) as error value in argument. Always ensure the wrapped value implements the error interface.
Wrap errors at the boundary. Add context, don't just pass through.
Inspecting errors
Checking errors with == is fragile. It fails when errors are wrapped or created dynamically. Use errors.Is to check for a specific error. errors.Is traverses the error chain and returns true if any error in the chain matches the target.
package main
import (
"errors"
"fmt"
"os"
)
// ErrNotFound is a sentinel error for missing resources.
var ErrNotFound = errors.New("not found")
// FindItem searches for an item.
// It returns ErrNotFound if the item doesn't exist.
func FindItem(id int) (string, error) {
if id == 42 {
return "magic item", nil
}
// Return the sentinel error.
return "", ErrNotFound
}
func main() {
// Search for an item.
item, err := FindItem(99)
// Check for the specific error using errors.Is.
if errors.Is(err, ErrNotFound) {
fmt.Println("Item not found")
return
}
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Found:", item)
}
Sentinel errors are defined as package-level variables. Public names start with a capital letter. ErrNotFound is exported so other packages can check for it. Private names start lowercase. No keywords like public or private.
Use errors.As when you need to extract a custom error type from the chain. errors.As finds the first error in the chain that matches the target type and assigns it to a pointer.
package main
import (
"errors"
"fmt"
)
// ValidationError represents a problem with input data.
type ValidationError struct {
Field string
Msg string
}
// Error returns the error message.
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Msg)
}
// ValidateInput checks the input.
func ValidateInput(name string) error {
if name == "" {
return &ValidationError{Field: "name", Msg: "cannot be empty"}
}
return nil
}
func main() {
// Validate input.
err := ValidateInput("")
// Try to extract the ValidationError.
var ve *ValidationError
if errors.As(err, &ve) {
// Access the structured data.
fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Msg)
}
}
If you pass a non-pointer to errors.As, the compiler rejects the code with target must be a non-nil pointer to a type that implements error. Always pass a pointer to the variable you want to fill.
Use errors.Is for checks. Use errors.As for extraction. Never compare with ==.
Pitfalls and conventions
A common mistake is ignoring errors. You can't do it easily because of the compiler, but you can write _, _ = func(). This is bad practice. The compiler allows it, but it hides bugs. Always handle errors or document why you are ignoring them.
Another mistake is panicking on recoverable errors. Don't panic just because a file is missing or a database query failed. Return the error. Let the caller decide. Panic is for programming bugs, not runtime failures. If you panic, the program crashes. Use panic only when the program cannot continue, such as a configuration failure at startup or an invariant violation.
Context errors are special. context.Canceled and context.DeadlineExceeded indicate that the operation was interrupted. Check for these errors to stop work early. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
package main
import (
"context"
"fmt"
"time"
)
// DoWork performs a long-running task.
// It checks for context cancellation.
func DoWork(ctx context.Context) error {
// Simulate work.
select {
case <-time.After(1 * time.Second):
return nil
case <-ctx.Done():
// Return the context error.
return ctx.Err()
}
}
func main() {
// Create a context with a deadline.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Call the function.
err := DoWork(ctx)
if err != nil {
fmt.Println("Work failed:", err)
}
}
If you forget to import a package, you get undefined: pkg from the compiler. If you import a package and don't use it, you get imported and not used. Go is strict about imports. Clean imports keep the codebase tidy.
Context is plumbing. Run it through every long-lived call site.
When to use what
Use fmt.Errorf with %w when you need to add context to an error from a lower layer. Use errors.New when you define a sentinel error that callers can check with errors.Is. Use a custom error type when you need to attach extra data to the error, like a field name or a status code. Use panic only when the program cannot continue, such as a configuration failure at startup or a programming bug. Use log.Fatal when you want to log the error and exit the program immediately.
The compiler forces you to handle errors. Respect the noise.