Error as a Value: Return error, Check Immediately, Keep the Happy Path Clear
In Go, errors are ordinary values. Functions that can fail typically return an error as their last return value. Callers are responsible for checking it immediately and deciding what to do next. This style keeps control flow explicit and avoids hidden exceptions.
Idiomatic pattern: guard clauses
A common goal is to keep the “happy path” (the successful flow) unindented and easy to read. You do this by checking errors early and returning as soon as something goes wrong.
package main
import (
"fmt"
"os"
)
func readConfig(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return b, nil
}
func main() {
b, err := readConfig("config.json")
if err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("bytes:", len(b))
}Step-by-step: how to structure a function that can fail
- Do one operation.
- Immediately check
err. - If it failed, return a useful error (often wrapping the original).
- Continue with the next operation only after success.
This approach scales well in production code because it makes failure points obvious and keeps stack traces unnecessary: the error value itself carries context.
Creating Errors and Wrapping with Context
When you detect an error condition, you create an error value. When you receive an error from a lower-level function, you often want to add context (what you were trying to do) while preserving the original error for inspection.
errors.New: simple static errors
import "errors"
var ErrEmptyInput = errors.New("empty input")errors.New is best when the message is fixed and you do not need formatting.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
fmt.Errorf: formatted errors and wrapping
fmt.Errorf can format messages and can wrap another error using %w. Wrapping is important because it preserves the original error so callers can still detect it with errors.Is or extract types with errors.As.
import (
"fmt"
)
func loadUser(id string) error {
err := doDBLookup(id)
if err != nil {
return fmt.Errorf("load user %q: %w", id, err)
}
return nil
}When to wrap vs when to replace
- Wrap (
%w) when the underlying error is meaningful to callers (e.g., they may want to detect a category like “not found”). - Replace (new error without wrapping) when you intentionally want to hide internal details or the underlying error is not actionable.
Common mistake: using %v instead of %w
If you use %v, the message may look fine, but the original error is not wrapped, so errors.Is/errors.As will not work through it.
// Not wrappable:
return fmt.Errorf("load user %q: %v", id, err)
// Wrappable:
return fmt.Errorf("load user %q: %w", id, err)Sentinel Errors vs Custom Error Types
There are two common strategies for communicating error meaning: sentinel errors (a shared package-level variable) and custom error types (a struct that implements Error() string and may carry fields).
Sentinel errors: simple categories
A sentinel error is a named variable that represents a specific condition. Callers can compare using errors.Is (preferred) rather than ==, especially when wrapping is involved.
package parse
import "errors"
var ErrInvalidFormat = errors.New("invalid format")
var ErrOutOfRange = errors.New("out of range")Use sentinel errors when:
- The error condition is simple and does not need extra data.
- You want callers to branch on a small set of well-known categories.
- You can keep the set stable (changing it is a breaking API change).
Custom error types: structured details
A custom error type is useful when callers need more information than a message: which field failed, which value was invalid, what limit was exceeded, etc.
package parse
import "fmt"
type ValidationError struct {
Field string
Value string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}Use custom error types when:
- You need to attach structured data to the error.
- Callers should be able to extract that data with
errors.As. - You want to keep error messages user-friendly while still enabling programmatic handling.
Combining both approaches
You can combine a custom type with a sentinel “category” by wrapping a sentinel or by including a code field. A common approach is to wrap a sentinel error to preserve a stable category while still adding context.
package parse
import (
"errors"
"fmt"
)
var ErrInvalidInput = errors.New("invalid input")
type ValidationError struct {
Field string
Value string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}
func (e *ValidationError) Unwrap() error {
return ErrInvalidInput
}With Unwrap, errors.Is(err, ErrInvalidInput) can succeed even when the concrete error is *ValidationError.
Inspecting Errors with errors.Is and errors.As
Go’s standard library provides tools to work with wrapped errors. These functions walk the chain created by wrapping (%w) or by types implementing Unwrap() error.
errors.Is: “does this error represent X?”
Use errors.Is to check whether an error is (or wraps) a specific sentinel error.
import (
"errors"
"fmt"
"os"
)
func openFile(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
return nil
}
func handle(path string) {
err := openFile(path)
if err == nil {
return
}
if errors.Is(err, os.ErrNotExist) {
fmt.Println("missing file")
return
}
fmt.Println("other error:", err)
}errors.As: “can I treat this as type T?”
Use errors.As when you want to extract a specific error type from the chain to access its fields or methods.
import (
"errors"
"fmt"
"strconv"
)
type ValidationError struct {
Field string
Value string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}
func parseAge(s string) (int, error) {
age, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Field: "age", Value: s, Msg: "must be an integer"}
}
if age < 0 || age > 130 {
return 0, &ValidationError{Field: "age", Value: s, Msg: "must be between 0 and 130"}
}
return age, nil
}
func handleAge(s string) {
_, err := parseAge(s)
if err == nil {
return
}
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field, "value:", ve.Value, "reason:", ve.Msg)
return
}
fmt.Println("unexpected:", err)
}Practical guidance
- Prefer
errors.Isfor stable categories (sentinel errors). - Prefer
errors.Aswhen you need structured details (custom types). - Wrap errors at boundaries (I/O, parsing, external calls) to add context like operation and identifiers.
- Avoid over-wrapping at every line; add context where it helps debugging and logs.
defer for Cleanup: Close Resources Even on Error
Many operations allocate resources that must be released: files, network connections, locks, temporary directories. In Go, defer is the idiomatic way to ensure cleanup happens even if you return early due to an error.
Pattern: acquire, check, defer cleanup immediately
import (
"fmt"
"os"
)
func countBytes(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return 0, fmt.Errorf("stat %s: %w", path, err)
}
return int(info.Size()), nil
}Handling cleanup errors (when it matters)
Some cleanup operations can fail (for example, flushing a buffered writer). If the cleanup error is important, capture it in a deferred function and combine it with the main error.
import (
"bufio"
"fmt"
"os"
)
func writeLine(path, line string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
cerr := f.Close()
if err == nil && cerr != nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
w := bufio.NewWriter(f)
defer func() {
ferr := w.Flush()
if err == nil && ferr != nil {
err = fmt.Errorf("flush %s: %w", path, ferr)
}
}()
if _, err := w.WriteString(line + "\n"); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}This pattern uses a named return value (err error) so the deferred functions can update it if the main body succeeded but cleanup failed.
Exercise 1: Parse and Validate Input with Well-Wrapped Errors
Goal: build a small parsing function that returns actionable errors with context. You will practice: early returns, wrapping with %w, and using a custom error type for validation details.
Task
Write ParsePort(s string) (int, error) that:
- Trims spaces.
- Rejects empty input.
- Parses an integer.
- Validates range 1..65535.
- Wraps underlying parse errors with context.
- Returns a
*ValidationErrorfor validation failures.
Starter types and sentinel
package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
var ErrInvalidInput = errors.New("invalid input")
type ValidationError struct {
Field string
Value string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}
func (e *ValidationError) Unwrap() error {
return ErrInvalidInput
}Implement step-by-step
- Normalize:
s = strings.TrimSpace(s). - If empty: return
&ValidationError{Field: "port", Value: s, Msg: "must not be empty"}. - Parse:
n, err := strconv.Atoi(s); if error, wrap it:fmt.Errorf("parse port %q: %w", s, err). - Validate range; on failure return
&ValidationError{Field: "port", Value: s, Msg: "must be between 1 and 65535"}.
Reference implementation
func ParsePort(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, &ValidationError{Field: "port", Value: s, Msg: "must not be empty"}
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, &ValidationError{Field: "port", Value: s, Msg: "must be between 1 and 65535"}
}
return n, nil
}Try it
func main() {
for _, s := range []string{"", " 8080 ", "99999", "abc"} {
p, err := ParsePort(s)
fmt.Printf("in=%q port=%d err=%v\n", s, p, err)
}
}Exercise 2: Distinguish Error Categories with errors.Is and errors.As
Goal: write a function that classifies errors into categories for control flow (e.g., retry, user message, internal alert). You will practice: sentinel detection with errors.Is and extracting details with errors.As.
Task
Implement Classify(err error) string that returns:
"ok"iferr == nil"invalid_input"iferrors.Is(err, ErrInvalidInput)"not_found"iferrors.Is(err, os.ErrNotExist)"validation(field=...)"iferrors.Asfinds a*ValidationError(include the field name)"unknown"otherwise
Note: the order matters. Decide whether you want the more specific type-based classification (ValidationError) to win over the broader sentinel (ErrInvalidInput), or vice versa, and implement accordingly.
Reference implementation (type wins over sentinel)
import (
"errors"
"os"
)
func Classify(err error) string {
if err == nil {
return "ok"
}
var ve *ValidationError
if errors.As(err, &ve) {
return "validation(field=" + ve.Field + ")"
}
if errors.Is(err, ErrInvalidInput) {
return "invalid_input"
}
if errors.Is(err, os.ErrNotExist) {
return "not_found"
}
return "unknown"
}Test cases to run
func main() {
err1 := &ValidationError{Field: "port", Value: "", Msg: "must not be empty"}
err2 := fmt.Errorf("load config: %w", os.ErrNotExist)
err3 := fmt.Errorf("top: %w", ErrInvalidInput)
for _, err := range []error{nil, err1, err2, err3} {
fmt.Printf("err=%v class=%s\n", err, Classify(err))
}
}