Errors #
Error handling is one of the aspects that most distinguishes Go from other languages. Instead of exceptions thrown and caught anywhere, Go chooses an explicit approach: every function that can fail returns an error as an ordinary value, and the caller is responsible for checking and handling it. The errors package — together with fmt.Errorf — is the foundation of this system. Since Go 1.13, this package has been strengthened with error wrapping: wrapping the original error in a new error that adds context, while still preserving the ability to inspect the original error type using errors.Is and errors.As. Understanding the errors package well means understanding how to build a system that gives informative error messages, is easy to debug, and can be handled differently based on its type.
The Error Philosophy in Go #
Before diving into the API, it’s important to understand why Go chose this approach:
flowchart LR
subgraph Exception["Languages with Exceptions\n(Java, Python, C++)"]
E1["function() throws Exception"] --> E2["...many layers..."]
E2 --> E3["catch (Exception e)"]
E3 --> E4["Who threw it?\nFrom which layer?\nWhat context?"]
end
subgraph Go["Go — Error as a Value"]
G1["err := function()"] --> G2{"err != nil?"}
G2 -- Yes --> G3["Handle here\nor wrap and return"]
G2 -- No --> G4["Continue"]
G3 --> G5["errors.Is / errors.As\nfor type inspection"]
end
style Exception fill:#fce4ec
style Go fill:#e8f5e9The advantages of the Go approach:
- Errors are always visible in the function signature — no hidden surprises
- Every layer can add context before passing the error on
- The caller can choose: handle, wrap, or ignore (with
_, but this is an anti-pattern) - An error is an ordinary value — it can be stored, compared, and inspected
errors.New — Creating Simple Errors #
errors.New creates an error with a fixed message. It’s the most basic way to define sentinel errors — errors representing a specific condition that can be compared with errors.Is.
package main
import (
"errors"
"fmt"
)
// Sentinel errors — error variables defined at the package level
// Convention: names start with "Err"
var (
ErrNotFound = errors.New("not found")
ErrNotAllowed = errors.New("not allowed")
ErrInvalidInput = errors.New("invalid input")
ErrConnectionIssue = errors.New("connection issue")
)
func findUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
if id > 1000 {
return nil, ErrNotFound
}
return &User{ID: id, Name: "Budi"}, nil
}
func main() {
_, err := findUser(-1)
// Compare with == — ANTI-PATTERN for wrapped errors
if err == ErrInvalidInput {
fmt.Println("wrong input") // works but not robust
}
// CORRECT: use errors.Is — works even if the error is wrapped
if errors.Is(err, ErrInvalidInput) {
fmt.Println("wrong input") // always correct
}
}
Why errors.Is, Not == #
flowchart TD
subgraph Without["err == ErrNotFound"]
T1["findUser: ErrNotFound"] --> T2["service: wrap with fmt.Errorf %w"]
T2 --> T3["handler: err == ErrNotFound"]
T3 --> T4["false ❌\nthe error is already wrapped,\nnot exactly equal"]
end
subgraph With["errors.Is(err, ErrNotFound)"]
D1["findUser: ErrNotFound"] --> D2["service: wrap with fmt.Errorf %w"]
D2 --> D3["handler: errors.Is(err, ErrNotFound)"]
D3 --> D4["true ✓\nerrors.Is opens all wrapping\nlayers recursively"]
end
style T4 fill:#fce4ec
style D4 fill:#e8f5e9var ErrNotFound = errors.New("not found")
// Repository layer
func repoFindBook(id int) error {
return ErrNotFound
}
// Service layer — adds context
func serviceFindBook(id int) error {
if err := repoFindBook(id); err != nil {
return fmt.Errorf("serviceFindBook %d: %w", id, err)
// The error is now: "serviceFindBook 42: not found"
}
return nil
}
// Handler layer
func handlerFindBook(id int) {
err := serviceFindBook(id)
// == doesn't work because the error is already wrapped
fmt.Println(err == ErrNotFound) // false
// errors.Is opens all wrapping layers
fmt.Println(errors.Is(err, ErrNotFound)) // true ✓
}
fmt.Errorf with %w — Error Wrapping #
fmt.Errorf with the %w verb is the idiomatic way to wrap an error while adding context. This differs from %v, which only converts the error to a string without unwrap capability.
// %v vs %w comparison
var ErrDB = errors.New("database error")
// With %v — the old error becomes a string, can't be unwrapped
err1 := fmt.Errorf("operation failed: %v", ErrDB)
fmt.Println(errors.Is(err1, ErrDB)) // false ❌
// With %w — the old error is wrapped, can be unwrapped
err2 := fmt.Errorf("operation failed: %w", ErrDB)
fmt.Println(errors.Is(err2, ErrDB)) // true ✓
// Naming convention in error messages
// Format: "functionName arguments: message" or "functionName: message"
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// Include the path as context — very useful when debugging
return fmt.Errorf("loadConfig %s: %w", path, err)
}
_ = data
return nil
}
func initializeApp(configPath string) error {
if err := loadConfig(configPath); err != nil {
return fmt.Errorf("initializeApp: %w", err)
}
return nil
}
// The error formed when called with a wrong path:
// "initializeApp: loadConfig /etc/app.yaml:
// open /etc/app.yaml: no such file or directory"
Multiple Wrapping (Go 1.20+) #
Since Go 1.20, one error can wrap several errors at once:
var (
ErrValidation = errors.New("validation failed")
ErrDB = errors.New("database error")
)
// Wrap two errors at once with two %w verbs
err := fmt.Errorf("saveData: %w and %w", ErrValidation, ErrDB)
fmt.Println(err)
// saveData: validation failed and database error
fmt.Println(errors.Is(err, ErrValidation)) // true
fmt.Println(errors.Is(err, ErrDB)) // true
// errors.Join — another way to combine several errors (Go 1.20+)
errs := []error{ErrValidation, ErrDB}
joined := errors.Join(errs...)
fmt.Println(joined)
// validation failed
// database error
fmt.Println(errors.Is(joined, ErrValidation)) // true
fmt.Println(errors.Is(joined, ErrDB)) // true
errors.Is — Checking Error Types #
errors.Is checks whether an error — or any error in its wrapping chain — matches the given target.
flowchart TD
Call["errors.Is(err, target)"] --> Step1["Check: err == target?"]
Step1 -- Yes --> True["return true"]
Step1 -- No --> Step2{"err has an\nIs(error) bool method?"}
Step2 -- Yes --> Step3["Call err.Is(target)"]
Step3 -- true --> True
Step3 -- false --> Step4{"err has an\nUnwrap() error method?"}
Step2 -- No --> Step4
Step4 -- Yes --> Step5["err = err.Unwrap()\nrepeat from the start"]
Step4 -- No --> Step6{"err has an\nUnwrap() []error method?"}
Step6 -- Yes --> Step7["Check every error\nin the slice"]
Step6 -- No --> False["return false"]
Step7 --> True
Step7 --> False
style True fill:#e8f5e9
style False fill:#fce4ecimport (
"errors"
"io"
"io/fs"
"os"
)
// Examples with various error types from the stdlib
func errorsIsExamples() {
// An error from the os package
_, err := os.Open("missing.txt")
// Check with sentinel errors from io/fs
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("file doesn't exist")
}
if errors.Is(err, fs.ErrPermission) {
fmt.Println("no permission")
}
// EOF — a sentinel error from io
_, err2 := fmt.Sscan("", new(int))
if errors.Is(err2, io.EOF) {
fmt.Println("no data")
}
// errors.Is also works with nil
var errNil error = nil
fmt.Println(errors.Is(errNil, nil)) // true
}
// A custom Is method — for more flexible comparisons
type HTTPError struct {
StatusCode int
Message string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}
// Implement Is so errors.Is works based on the status code
func (e *HTTPError) Is(target error) bool {
t, ok := target.(*HTTPError)
if !ok {
return false
}
// Match if the status code is the same (ignore the message)
return e.StatusCode == t.StatusCode
}
var ErrNotFoundHTTP = &HTTPError{StatusCode: 404}
var ErrUnauthorizedHTTP = &HTTPError{StatusCode: 401}
func checkResponse(err error) {
if errors.Is(err, ErrNotFoundHTTP) {
fmt.Println("resource not found")
}
if errors.Is(err, ErrUnauthorizedHTTP) {
fmt.Println("authentication needed")
}
}
errors.As — Extracting Error Types #
errors.As searches the error chain for an error of a specific type and extracts its value into a target variable. This is the way to access the fields or methods of a custom error type.
// errors.As enables access to error details
type ValidationError struct {
Field string
Message string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for field %q: %s (value: %v)",
e.Field, e.Message, e.Value)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{
Field: "age",
Message: "must not be negative",
Value: age,
}
}
if age > 150 {
return &ValidationError{
Field: "age",
Message: "not realistic",
Value: age,
}
}
return nil
}
func processRegistration(age int) error {
if err := validateAge(age); err != nil {
return fmt.Errorf("processRegistration: %w", err)
}
return nil
}
func main() {
err := processRegistration(-5)
// errors.Is isn't enough — we need the field and value details
// errors.As extracts *ValidationError from inside the wrapping chain
var errVal *ValidationError
if errors.As(err, &errVal) {
// Now we can access the fields of ValidationError
fmt.Printf("Problem field: %s\n", errVal.Field)
fmt.Printf("Message: %s\n", errVal.Message)
fmt.Printf("Sent value: %v\n", errVal.Value)
// Can create an appropriate HTTP response
// http.Error(w, errVal.Message, http.StatusBadRequest)
}
}
errors.Is vs errors.As — When to Use Each #
flowchart TD
Q{"What do you\nwant to check?"} --> V["Is the error a\nspecific condition?\n(no details needed)"]
Q --> D["Does the error have\na specific type and\ndo I need to access its fields?"]
V --> Is["errors.Is(err, ErrTarget)\n\nExamples:\nerrors.Is(err, ErrNotFound)\nerrors.Is(err, io.EOF)\nerrors.Is(err, fs.ErrPermission)"]
D --> As["errors.As(err, &target)\n\nExamples:\nerrors.As(err, &errValidation)\nerrors.As(err, &errHTTP)\nerrors.As(err, &errDB)"]
Is --> IsEx["Use for:\n- Sentinel errors\n- Binary conditions (present/absent)\n- Control flow based on error types"]
As --> AsEx["Use for:\n- Custom errors with extra data\n- Accessing status codes, field names, etc.\n- Responses tailored to error details"]
style Is fill:#e3f2fd
style As fill:#e8f5e9// A real example: an HTTP handler with full error handling
func findBookHandler(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid ID", http.StatusBadRequest)
return
}
book, err := serviceFindBook(id)
if err != nil {
// Check the error type for the right response
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "Book not found", http.StatusNotFound)
case errors.Is(err, ErrNotAllowed):
http.Error(w, "Access denied", http.StatusForbidden)
default:
// Check whether there are validation error details
var errVal *ValidationError
if errors.As(err, &errVal) {
http.Error(w,
fmt.Sprintf("Invalid input: %s", errVal.Message),
http.StatusBadRequest)
return
}
// Unknown error — log and return 500
log.Printf("unexpected error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
json.NewEncoder(w).Encode(book)
}
errors.Unwrap — Opening One Layer #
errors.Unwrap opens one wrapping layer — useful if you want to traverse the error chain manually.
err1 := errors.New("base error")
err2 := fmt.Errorf("layer 2: %w", err1)
err3 := fmt.Errorf("layer 3: %w", err2)
fmt.Println(err3) // layer 3: layer 2: base error
fmt.Println(errors.Unwrap(err3)) // layer 2: base error
fmt.Println(errors.Unwrap(errors.Unwrap(err3))) // base error
fmt.Println(errors.Unwrap(err1)) // nil — no more layers
// Traverse the whole error chain manually
func traceError(err error) {
fmt.Println("=== Error Chain ===")
for err != nil {
fmt.Printf(" %T: %v\n", err, err)
err = errors.Unwrap(err)
}
}
traceError(err3)
// === Error Chain ===
// *fmt.wrapError: layer 3: layer 2: base error
// *fmt.wrapError: layer 2: base error
// *errors.errorString: base error
errors.Join — Combining Several Errors (Go 1.20+) #
errors.Join is useful when doing validations that produce many errors at once, or when running several parallel operations that can each fail.
import "errors"
// Form validation — collect all errors at once
type RegistrationForm struct {
Name string
Email string
Password string
Age int
}
func validateForm(form RegistrationForm) error {
var errs []error
if form.Name == "" {
errs = append(errs, errors.New("name must not be empty"))
} else if len(form.Name) < 2 {
errs = append(errs, errors.New("name must be at least 2 characters"))
}
if form.Email == "" {
errs = append(errs, errors.New("email must not be empty"))
} else if !strings.Contains(form.Email, "@") {
errs = append(errs, errors.New("invalid email format"))
}
if len(form.Password) < 8 {
errs = append(errs, errors.New("password must be at least 8 characters"))
}
if form.Age < 18 {
errs = append(errs, errors.New("age must be at least 18"))
}
// errors.Join combines all errors
// returns nil if the slice is empty
return errors.Join(errs...)
}
func main() {
form := RegistrationForm{
Name: "A",
Email: "not-an-email",
Password: "123",
Age: 15,
}
if err := validateForm(form); err != nil {
fmt.Println("Validation failed:")
fmt.Println(err)
// Validation failed:
// name must be at least 2 characters
// invalid email format
// password must be at least 8 characters
// age must be at least 18
}
}
Running Parallel Operations with errors.Join #
import (
"errors"
"sync"
)
func runParallel(tasks []func() error) error {
var (
mu sync.Mutex
errs []error
wg sync.WaitGroup
)
for _, t := range tasks {
wg.Add(1)
go func(fn func() error) {
defer wg.Done()
if err := fn(); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(t)
}
wg.Wait()
return errors.Join(errs...)
}
// Usage
err := runParallel([]func() error{
func() error { return sendEmail("[email protected]") },
func() error { return sendNotification(123) },
func() error { return updateCache("key") },
})
if err != nil {
fmt.Println("Some operations failed:", err)
}
Custom Error Types #
For cases more complex than sentinel errors, you can define your own error type by implementing the error interface.
flowchart TD
Interface["interface error {\n Error() string\n}"] --> Basic["errors.New\nFixed message, no data"]
Interface --> Fmt["fmt.Errorf\nDynamic message, can wrap"]
Interface --> Custom["Custom struct\nExtra data, custom methods"]
Custom --> C1["ValidationError\n{Field, Message, Value}"]
Custom --> C2["HTTPError\n{StatusCode, Message}"]
Custom --> C3["DBError\n{Query, Params, Underlying}"]
Custom --> C4["TimeoutError\n{Operation, Duration}"]
C1 --> When1["Need to know\nwhich field failed"]
C2 --> When2["Need to know\nthe HTTP status code"]
C3 --> When3["Need to know\nthe query that failed"]
C4 --> When4["Need to know\nhow long the timeout was"]
style Interface fill:#4f86c6,color:#fff
style Basic fill:#e8f5e9
style Fmt fill:#e3f2fd
style Custom fill:#fff3e0// A custom error type with Unwrap for wrapping compatibility
type DBError struct {
Operation string
Query string
Err error // the underlying error
}
func (e *DBError) Error() string {
return fmt.Sprintf("db error during %s: %v", e.Operation, e.Err)
}
// Unwrap allows errors.Is and errors.As to reach the underlying error
func (e *DBError) Unwrap() error {
return e.Err
}
// Usage
var ErrDBConnection = errors.New("database connection issue")
func queryDB(query string) error {
// Simulate a connection error
return &DBError{
Operation: "SELECT",
Query: query,
Err: ErrDBConnection,
}
}
func main() {
err := queryDB("SELECT * FROM users WHERE id = 1")
// errors.As to access DBError details
var errDB *DBError
if errors.As(err, &errDB) {
fmt.Printf("Operation: %s\n", errDB.Operation)
fmt.Printf("Query: %s\n", errDB.Query)
}
// errors.Is can still find ErrDBConnection
// because DBError implements Unwrap
if errors.Is(err, ErrDBConnection) {
fmt.Println("Database connection issue — try reconnecting")
}
}
Errors with Codes for APIs #
// An error carrying an HTTP status code and an API error code
type AppError struct {
Code string // code for the client: "USER_NOT_FOUND", "INVALID_INPUT"
Message string // a message that can be shown to the user
StatusHTTP int // the matching HTTP status code
Err error // the underlying error (for logging, not sent to the client)
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Constructors for common errors
func ErrUserNotFound(id int) *AppError {
return &AppError{
Code: "USER_NOT_FOUND",
Message: fmt.Sprintf("user with ID %d not found", id),
StatusHTTP: 404,
}
}
func ErrInvalidInputApp(field, message string) *AppError {
return &AppError{
Code: "INVALID_INPUT",
Message: fmt.Sprintf("field %s: %s", field, message),
StatusHTTP: 400,
}
}
// A handler using AppError
func genericHandler(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusHTTP)
json.NewEncoder(w).Encode(map[string]string{
"error": appErr.Code,
"message": appErr.Message,
})
return
}
// An unknown error
log.Printf("unexpected error: %v", err)
http.Error(w, "internal server error", 500)
}
Anti-Patterns to Avoid #
// ✗ ANTI-PATTERN 1: Ignore errors
data, _ := os.ReadFile("config.yaml") // data could be nil!
// If ReadFile fails, data is nil and subsequent usage will panic
// ✓ CORRECT: always check errors
data, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("loadConfig: %w", err)
}
// ✗ ANTI-PATTERN 2: Only log the error but keep going
data, err = os.ReadFile("config.yaml")
if err != nil {
log.Println("error:", err) // logs but doesn't return!
}
// The code below uses data, which might be nil
// ✓ CORRECT: log AND return (or handle properly)
data, err = os.ReadFile("config.yaml")
if err != nil {
log.Printf("failed to read config: %v", err)
return err // or use a default, or exit
}
// ✗ ANTI-PATTERN 3: Wrapping without added value
func findUser2(id int) error {
err := db.Query(id)
if err != nil {
return fmt.Errorf("error: %w", err) // "error:" adds no context at all!
}
return nil
}
// ✓ CORRECT: wrap with meaningful context
func findUser3(id int) error {
err := db.Query(id)
if err != nil {
return fmt.Errorf("findUser id=%d: %w", id, err) // function name + arguments
}
return nil
}
// ✗ ANTI-PATTERN 4: Wrapping an error that already has enough context
func findUser4(id int) (*User, error) {
u, err := findUser3(id)
if err != nil {
// findUser3 already added good context
// wrapping again only adds noise
return nil, fmt.Errorf("findUser4 called with id %d and failed because: %w", id, err)
}
return u, nil
}
// ✓ CORRECT: wrap briefly or return directly if the context is already enough
func findUser5(id int) (*User, error) {
u, err := findUser3(id)
if err != nil {
return nil, fmt.Errorf("findUser5: %w", err) // concise, not redundant
}
return u, nil
}
// ✗ ANTI-PATTERN 5: Use panic for errors that can be handled
func divide(a, b int) int {
if b == 0 {
panic("division by zero") // don't panic for predictable conditions!
}
return a / b
}
// ✓ CORRECT: return an error for conditions that can occur
func divideGood(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("can't divide by zero")
}
return a / b, nil
}
Production Usage Patterns #
Error Registry — Centralized Sentinel Errors #
// errors.go — one file for all sentinel errors in the package
package app
import "errors"
// Authentication errors
var (
ErrTokenExpired = errors.New("token expired")
ErrTokenInvalid = errors.New("invalid token")
ErrSessionNotFound = errors.New("session not found")
)
// Data errors
var (
ErrUserNotFound = errors.New("user not found")
ErrProductNotFound = errors.New("product not found")
ErrOutOfStock = errors.New("out of stock")
ErrDuplicateEmail = errors.New("email already registered")
)
// System errors
var (
ErrDatabase = errors.New("database unavailable")
ErrCache = errors.New("cache unavailable")
ErrExternalSvc = errors.New("external service unavailable")
)
An HTTP Error Handler Middleware #
// Middleware that converts AppError into consistent JSON responses
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Detail any `json:"detail,omitempty"`
}
func errorHandler(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Use panic recovery to catch unexpected panics
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
json.NewEncoder(w).Encode(ErrorResponse{
Code: "INTERNAL_ERROR",
Message: "an internal error occurred",
})
}
}()
next(w, r)
}
}
func sendError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusHTTP)
json.NewEncoder(w).Encode(ErrorResponse{
Code: appErr.Code,
Message: appErr.Message,
})
return
}
// Check common sentinel errors
switch {
case errors.Is(err, ErrUserNotFound):
w.WriteHeader(404)
json.NewEncoder(w).Encode(ErrorResponse{
Code: "NOT_FOUND",
Message: "data not found",
})
case errors.Is(err, ErrTokenInvalid),
errors.Is(err, ErrTokenExpired):
w.WriteHeader(401)
json.NewEncoder(w).Encode(ErrorResponse{
Code: "UNAUTHORIZED",
Message: "authentication required",
})
default:
log.Printf("unhandled error: %v", err)
w.WriteHeader(500)
json.NewEncoder(w).Encode(ErrorResponse{
Code: "INTERNAL_ERROR",
Message: "an internal error occurred",
})
}
}
When to Switch to Alternatives #
Keep using the errors package + fmt.Errorf if:
✓ Standard error handling in all Go code
✓ Defining sentinel errors with errors.New
✓ Wrapping errors with context using fmt.Errorf + %w
✓ Checking error types with errors.Is and errors.As
✓ Combining several errors with errors.Join
Consider panic + recover if:
✗ Conditions that truly can't happen (programmer errors)
✗ Failed initialization at startup where the program should stop anyway
✗ Internal implementations that can't return errors
(e.g. functions called from interfaces without an error return)
Consider external libraries if:
✗ Detailed per-error stack traces → pkg/errors or Go 1.21+ runtime/debug
✗ Errors with automatic structured logging → zap or slog with error fields
✗ More advanced error aggregation → hashicorp/go-multierror
Summary #
- Sentinel errors with
errors.Neware comparable error constants — define them at the package level withErrXxxnames and check them witherrors.Is(not==).fmt.Errorfwith%wis the idiomatic way to add context to an error — use%wrather than%vsoerrors.Isanderrors.Askeep working through the wrapping chain.- Error naming convention:
"functionName arguments: message"— include the function name and relevant arguments so error messages are easy to trace without a stack trace.errors.Istraverses the entire wrapping chain to find a matching error — use it for control flow based on error types (404, 401, etc.).errors.Asextracts a specifically-typed error from the chain — use it when you need to access the fields or methods of a custom error type.errors.Join(Go 1.20+) combines several errors into one — useful for validations collecting all errors at once or parallel operations.- Custom error types with an
Unwrap() errormethod allowerrors.Isanderrors.Asto reach the underlying error — always implement Unwrap if a custom error wraps another error.- Don’t ignore errors —
data, _ := ...is almost always wrong. If you deliberately ignore one, add a comment explaining why.- Don’t use panic for predictable error conditions — panic is for programmer errors; return errors for runtime errors that can occur.