Exceptions #
Go doesn’t have try-catch. Not because of a limitation, but because of a very deliberate design decision: errors are ordinary values, not a special control-flow mechanism. In Java or Python, exceptions can arise from any function and “jump” across many layers of code before being caught — this makes error flow hard to predict and understand. In Go, errors are returned explicitly as return values, must be handled by the immediate caller, and the flow can always be read from top to bottom. The result is more verbose, but also more transparent — no surprising “exception surprises.”
Go’s error handling flow, which relies on explicit return values (error values), is very different from the try-catch mechanism in other languages. The standard error handling flow vs the panic-recover flow can be visualized in the following diagram:
flowchart TD
subgraph StandardFlow["Standard Error Handling Flow (Idiomatic)"]
FuncA["Function A Calls Function B"] --> CallB["Function B Returns (Data, error)"]
CallB --> CheckErr{"Check: error != nil?"}
CheckErr -->|"Yes"| HandleErr["Handle Error (Log / Return to Caller)"]
CheckErr -->|"No"| Success["Continue Happy Path"]
end
subgraph PanicFlow["Panic & Recover Flow (Exceptional)"]
Exception["There Is a Fatal Error / Bug"] --> Panic["Trigger panic()"]
Panic --> ExecDefer["Execute defer Functions"]
ExecDefer --> CallRecover{"Is there a recover()?"}
CallRecover -->|"Yes"| Recovered["Handle Panic (Program Doesn't Die)"]
CallRecover -->|"No"| Crash["Program Crashes / Exits"]
endThe error Interface — The Foundation of All Errors in Go
#
Every error in Go implements this very simple interface:
type error interface {
Error() string
}
Just one method. That means any type with an Error() string method is a valid error in Go. This makes Go’s error system highly extensible — you can create errors that carry any data, as long as they can describe themselves as a string.
Ways to Create Errors #
errors.New — Simple Errors
#
For errors that only need a static message:
import "errors"
err := errors.New("file not found")
fmt.Println(err) // file not found
fmt.Println(err.Error()) // file not found — calls the Error() method
Sentinel Errors — Comparable Errors #
Sentinel errors are package-level variables representing specific error conditions. They let callers check the error kind with errors.Is:
package database
import "errors"
// Sentinel errors — names always start with Err
var (
ErrNotFound = errors.New("record not found")
ErrDuplicate = errors.New("record already exists")
ErrUnauthorized = errors.New("no access")
ErrConnFailed = errors.New("database connection failed")
)
func FindUser(id int) (*User, error) {
user := db.Query(id)
if user == nil {
return nil, ErrNotFound // return the sentinel error
}
return user, nil
}
// In the caller — can check the error kind precisely
user, err := database.FindUser(42)
if errors.Is(err, database.ErrNotFound) {
// handle the "not found" case specifically
return nil, fmt.Errorf("user 42 does not exist in the system")
}
if err != nil {
// some other unexpected error
return nil, fmt.Errorf("failed to get user: %w", err)
}
fmt.Errorf — Errors with Context
#
fmt.Errorf creates errors with a formatted message. Use %w (not %v) to wrap the original error so it remains traceable:
import "fmt"
// %v — only includes the error string, the chain is BROKEN
err1 := fmt.Errorf("failed to read file: %v", originalErr)
// %w — wraps the original error, the chain is PRESERVED
err2 := fmt.Errorf("failed to read file: %w", originalErr)
// With %w, originalErr can still be found with errors.Is
errors.Is(err2, originalErr) // true
errors.Is(err1, originalErr) // false — the chain is broken
Custom Error Types — Errors That Carry Data #
When an error needs to carry more information than just a string, create a custom error type by implementing the error interface:
// Error for input validation — carries which field is problematic
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q (value: %v): %s",
e.Field, e.Value, e.Message)
}
// Error for HTTP — carries a status code
type HTTPError struct {
StatusCode int
Status string
Body []byte
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d %s", e.StatusCode, e.Status)
}
// Error for database operations — carries the failed query
type DBError struct {
Op string // operation: "insert", "select", "update"
Table string
Err error // the original error from the driver
}
func (e *DBError) Error() string {
return fmt.Sprintf("db %s on table %q: %v", e.Op, e.Table, e.Err)
}
func (e *DBError) Unwrap() error {
return e.Err // required for errors.Is/As to work with the chain
}
Using Custom Error Types #
func validateAge(age int) error {
if age < 0 {
return &ValidationError{
Field: "age",
Value: age,
Message: "must not be negative",
}
}
if age > 150 {
return &ValidationError{
Field: "age",
Value: age,
Message: "exceeds the maximum limit of 150",
}
}
return nil
}
func main() {
err := validateAge(-5)
if err != nil {
// Extract detailed information with errors.As
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field %q failed: %s\n", valErr.Field, valErr.Message)
}
}
}
Error Wrapping — Building Error Chains #
Error wrapping lets you add context to an error while preserving the original. The result is an error chain — a series of connected errors:
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
// Wrap: add "where" context, preserve "what"
return nil, fmt.Errorf("readConfig(%q): %w", path, err)
}
return data, nil
}
func parseConfig(path string) (*Config, error) {
data, err := readConfig(path)
if err != nil {
return nil, fmt.Errorf("parseConfig: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parseConfig: unmarshal: %w", err)
}
return &cfg, nil
}
func startServer(configPath string) error {
cfg, err := parseConfig(configPath)
if err != nil {
return fmt.Errorf("startServer: %w", err)
}
// ...
return nil
}
If os.ReadFile fails, the error chain will look like:
startServer: parseConfig: readConfig("/etc/app/config.json"): open /etc/app/config.json: no such file or directory
Each layer adds context — very useful for debugging in production.
errors.Is — Traversing the Chain
#
errors.Is checks whether a specific error exists within the chain, traversing all the way to the end:
var ErrPermission = errors.New("access denied")
func readSecretFile() error {
return fmt.Errorf("readSecretFile: %w",
fmt.Errorf("operating system: %w", ErrPermission))
}
func main() {
err := readSecretFile()
// errors.Is traverses the entire chain
fmt.Println(errors.Is(err, ErrPermission)) // true — even though wrapped twice
// Direct comparison doesn't find it because it's wrapped
fmt.Println(err == ErrPermission) // false
}
Custom Is() Implementations
#
For custom error types that need matching logic more complex than pointer equality:
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}
// Custom Is() implementation — matches on Resource only
func (e *NotFoundError) Is(target error) bool {
t, ok := target.(*NotFoundError)
if !ok {
return false
}
// Match if the Resource is the same (ignore the ID)
return e.Resource == t.Resource || t.Resource == ""
}
func main() {
err := &NotFoundError{Resource: "User", ID: 42}
target := &NotFoundError{Resource: "User"} // empty ID = wildcard
fmt.Println(errors.Is(err, target)) // true — same Resource
}
errors.As — Extracting a Specific Error Type
#
errors.As traverses the chain and extracts an error of a specific type into a target variable:
func processRequest(userID int) error {
user, err := db.FindUser(userID)
if err != nil {
return fmt.Errorf("processRequest: %w",
&DBError{Op: "select", Table: "users", Err: err})
}
_ = user
return nil
}
func main() {
err := processRequest(999)
// errors.As — extract *DBError from any chain
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Printf("Failed operation: %s on table %s\n",
dbErr.Op, dbErr.Table)
}
// errors.As also traverses through wrapping
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("Validation failed:", valErr.Message)
} else {
fmt.Println("Not a validation error")
}
}
errors.Is vs errors.As
#
errors.Is(err, target):
→ Checks VALUE EQUALITY
→ target is usually a sentinel error (var ErrXxx = errors.New(...))
→ Answers: "is this error ErrNotFound?"
errors.As(err, &target):
→ Checks TYPE MATCHING and extracts the value
→ target is a pointer to a concrete error type (*MyError)
→ Answers: "is there a *ValidationError in this chain, and if so, give it to me"
Idiomatic Error Handling Patterns #
Annotate at Boundary — Add Context at Layer Boundaries #
// ANTI-PATTERN: returning a raw error without context
func getUserPosts(userID int) ([]*Post, error) {
posts, err := db.Query("SELECT * FROM posts WHERE user_id = ?", userID)
if err != nil {
return nil, err // ✗ "sql: no rows" — unclear context
}
return posts, nil
}
// CORRECT: wrap with context at every boundary
func getUserPosts(userID int) ([]*Post, error) {
posts, err := db.Query("SELECT * FROM posts WHERE user_id = ?", userID)
if err != nil {
return nil, fmt.Errorf("getUserPosts(userID=%d): %w", userID, err)
// ✓ "getUserPosts(userID=42): sql: no rows" — clear!
}
return posts, nil
}
Handle Once — Handle the Error a Single Time #
// ANTI-PATTERN: log AND return — the error is handled twice
func doWork() error {
if err := step1(); err != nil {
log.Println("step1 error:", err) // log here
return err // AND return — double handling!
}
return nil
}
func main() {
if err := doWork(); err != nil {
log.Println("doWork error:", err) // log again here — duplication!
}
}
// CORRECT: pick one — log OR return, not both
func doWork() error {
if err := step1(); err != nil {
return fmt.Errorf("doWork.step1: %w", err) // only wrap and return
}
return nil
}
func main() {
if err := doWork(); err != nil {
log.Println("Error:", err) // handle ONCE at the highest level
}
}
Don’t Ignore Errors #
// ANTI-PATTERN: ignoring errors
file, _ := os.Create("output.txt") // ✗ if it fails, file is nil
file.Write([]byte("data")) // panic: nil pointer dereference
// CORRECT: always handle
file, err := os.Create("output.txt")
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
panic and recover
#
panic stops normal execution, runs all the defers in the stack, then crashes. recover catches a panic so the program doesn’t crash entirely — it’s only valid when called inside a defer.
When panic Is Legitimate
#
// 1. Programming bugs that should never happen
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("mustPositive: n must be > 0, got %d", n))
}
return n
}
// 2. Failed program initialization — no point continuing
func mustCompile(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(fmt.Sprintf("mustCompile: invalid pattern %q: %v", pattern, err))
}
return re
}
// Usage at the package level — the panic happens before main()
var emailRegex = mustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
recover to Prevent Crashes
#
// HTTP middleware that catches panics from handlers
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// Catch the panic, log it, return 500
log.Printf("PANIC: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Safe wrapper — convert a panic into an error
func safeCall(fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
switch v := r.(type) {
case error:
err = fmt.Errorf("panic: %w", v)
default:
err = fmt.Errorf("panic: %v", v)
}
}
}()
return fn()
}
When to use panic vs error:
Use ERROR (return value) for:
✓ Anticipatable conditions: missing file, invalid input
✓ I/O failures: network timeout, DB errors
✓ Business failures: insufficient balance, product out of stock
Use PANIC for:
✓ Programming bugs: index out of range, nil dereference
✓ Failed initialization: wrong regex pattern, missing required config
✓ Conditions that can't happen if the code is correct
DON'T use panic as a replacement for ordinary error handling
Multi-Errors — Combining Several Errors #
Since Go 1.20, errors.Join lets you combine several errors into one:
import "errors"
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name must not be empty"))
}
if len(u.Name) > 100 {
errs = append(errs, errors.New("name too long (max 100)"))
}
if u.Email == "" {
errs = append(errs, errors.New("email must not be empty"))
}
if !isValidEmail(u.Email) {
errs = append(errs, fmt.Errorf("email %q is not valid", u.Email))
}
if u.Age < 0 || u.Age > 150 {
errs = append(errs, fmt.Errorf("age %d outside the valid range", u.Age))
}
return errors.Join(errs...) // nil if errs is empty
}
func main() {
err := validateUser(User{Name: "", Email: "not-an-email", Age: -1})
if err != nil {
fmt.Println("Validation failed:")
// errors.Join produces an error that can be unwrapped one by one
for _, e := range errors.Unwrap(err).(interface{ Unwrap() []error }).Unwrap() {
fmt.Println(" -", e)
}
}
}
Complete Example Program #
The following program simulates a payment service with realistic layered error handling:
package main
import (
"errors"
"fmt"
"time"
)
// ── Sentinel Errors ───────────────────────────────────────────
var (
ErrInsufficientFunds = errors.New("insufficient balance")
ErrAccountFrozen = errors.New("account frozen")
ErrLimitExceeded = errors.New("transaction limit exceeded")
ErrAccountNotFound = errors.New("account not found")
)
// ── Custom Error Types ────────────────────────────────────────
type TransactionError struct {
Code string
AccountID string
Amount float64
Reason string
Err error
}
func (e *TransactionError) Error() string {
return fmt.Sprintf("[%s] transaction failed for account %s (Rp%.0f): %s",
e.Code, e.AccountID, e.Amount, e.Reason)
}
func (e *TransactionError) Unwrap() error { return e.Err }
type AuditError struct {
TransactionID string
Err error
}
func (e *AuditError) Error() string {
return fmt.Sprintf("audit failed for transaction %s: %v", e.TransactionID, e.Err)
}
func (e *AuditError) Unwrap() error { return e.Err }
// ── Domain Models ─────────────────────────────────────────────
type Account struct {
ID string
Name string
Balance float64
DailyLimit float64
DailyUsed float64
Frozen bool
}
type Transaction struct {
ID string
From string
To string
Amount float64
Timestamp time.Time
Status string
}
// ── Repositories (simulated) ──────────────────────────────────
type AccountRepo struct {
accounts map[string]*Account
}
func NewAccountRepo() *AccountRepo {
return &AccountRepo{
accounts: map[string]*Account{
"ACC001": {ID: "ACC001", Name: "Budi", Balance: 5_000_000, DailyLimit: 10_000_000, Frozen: false},
"ACC002": {ID: "ACC002", Name: "Sari", Balance: 2_000_000, DailyLimit: 5_000_000, Frozen: false},
"ACC003": {ID: "ACC003", Name: "Ahmad", Balance: 1_000_000, DailyLimit: 3_000_000, Frozen: true},
},
}
}
func (r *AccountRepo) FindByID(id string) (*Account, error) {
acc, ok := r.accounts[id]
if !ok {
return nil, fmt.Errorf("AccountRepo.FindByID(%q): %w", id, ErrAccountNotFound)
}
return acc, nil
}
func (r *AccountRepo) UpdateBalance(id string, newBalance float64) error {
acc, err := r.FindByID(id)
if err != nil {
return fmt.Errorf("AccountRepo.UpdateBalance: %w", err)
}
acc.Balance = newBalance
return nil
}
// ── Audit Service ─────────────────────────────────────────────
type AuditService struct {
logs []string
}
func (a *AuditService) Log(tx Transaction) error {
// Simulate: fails if the amount is very large (fake bug)
if tx.Amount > 100_000_000 {
return fmt.Errorf("AuditService.Log: value too large to audit")
}
a.logs = append(a.logs, fmt.Sprintf("[%s] %s → %s: Rp%.0f (%s)",
tx.Timestamp.Format("15:04:05"),
tx.From, tx.To, tx.Amount, tx.Status))
return nil
}
// ── Payment Service ───────────────────────────────────────────
type PaymentService struct {
repo *AccountRepo
audit *AuditService
}
func NewPaymentService(repo *AccountRepo, audit *AuditService) *PaymentService {
return &PaymentService{repo: repo, audit: audit}
}
func (s *PaymentService) validateTransfer(from *Account, amount float64) error {
if from.Frozen {
return &TransactionError{
Code: "ACCOUNT_FROZEN",
AccountID: from.ID,
Amount: amount,
Reason: "sender account is frozen",
Err: ErrAccountFrozen,
}
}
if from.Balance < amount {
return &TransactionError{
Code: "INSUFFICIENT_FUNDS",
AccountID: from.ID,
Amount: amount,
Reason: fmt.Sprintf("balance Rp%.0f < Rp%.0f", from.Balance, amount),
Err: ErrInsufficientFunds,
}
}
if from.DailyUsed+amount > from.DailyLimit {
remaining := from.DailyLimit - from.DailyUsed
return &TransactionError{
Code: "LIMIT_EXCEEDED",
AccountID: from.ID,
Amount: amount,
Reason: fmt.Sprintf("daily limit remaining Rp%.0f", remaining),
Err: ErrLimitExceeded,
}
}
return nil
}
func (s *PaymentService) Transfer(fromID, toID string, amount float64) (*Transaction, error) {
// Get the sender account
from, err := s.repo.FindByID(fromID)
if err != nil {
return nil, fmt.Errorf("Transfer: %w", err)
}
// Get the receiver account
to, err := s.repo.FindByID(toID)
if err != nil {
return nil, fmt.Errorf("Transfer: %w", err)
}
// Validate
if err := s.validateTransfer(from, amount); err != nil {
return nil, fmt.Errorf("Transfer: %w", err)
}
// Process the transfer
tx := &Transaction{
ID: fmt.Sprintf("TRX-%d", time.Now().UnixNano()),
From: fromID,
To: toID,
Amount: amount,
Timestamp: time.Now(),
Status: "SUCCESS",
}
from.Balance -= amount
from.DailyUsed += amount
to.Balance += amount
if err := s.repo.UpdateBalance(fromID, from.Balance); err != nil {
return nil, fmt.Errorf("Transfer: update sender: %w", err)
}
if err := s.repo.UpdateBalance(toID, to.Balance); err != nil {
return nil, fmt.Errorf("Transfer: update receiver: %w", err)
}
// Audit log — an error here doesn't cancel the transfer
if err := s.audit.Log(*tx); err != nil {
// Log the audit error but don't fail the transaction
fmt.Printf("⚠ Audit warning: %v\n", &AuditError{
TransactionID: tx.ID,
Err: err,
})
}
return tx, nil
}
// ── Helper for error analysis ─────────────────────────────────
func describeError(err error) string {
if err == nil {
return "no error"
}
var txErr *TransactionError
if errors.As(err, &txErr) {
desc := fmt.Sprintf("TransactionError[%s]: %s", txErr.Code, txErr.Reason)
// Identify the sentinel error inside
switch {
case errors.Is(err, ErrInsufficientFunds):
desc += " (INSUFFICIENT_FUNDS)"
case errors.Is(err, ErrAccountFrozen):
desc += " (ACCOUNT_FROZEN)"
case errors.Is(err, ErrLimitExceeded):
desc += " (LIMIT_EXCEEDED)"
}
return desc
}
if errors.Is(err, ErrAccountNotFound) {
return "Account not found"
}
return fmt.Sprintf("Generic error: %v", err)
}
func main() {
repo := NewAccountRepo()
audit := &AuditService{}
svc := NewPaymentService(repo, audit)
testCases := []struct {
name string
fromID string
toID string
amount float64
}{
{"Normal transfer", "ACC001", "ACC002", 1_000_000},
{"Insufficient balance", "ACC002", "ACC001", 5_000_000},
{"Frozen account", "ACC003", "ACC001", 500_000},
{"Account not found", "ACC999", "ACC001", 100_000},
{"Transfer to missing account", "ACC001", "ACC999", 100_000},
}
fmt.Println("=== Payment Transaction Simulation ===\n")
for _, tc := range testCases {
fmt.Printf("▶ %s (Rp%.0f)\n", tc.name, tc.amount)
tx, err := svc.Transfer(tc.fromID, tc.toID, tc.amount)
if err != nil {
fmt.Printf(" ✗ Failed: %v\n", err)
fmt.Printf(" ℹ Analysis: %s\n", describeError(err))
} else {
fmt.Printf(" ✓ Success: ID=%s\n", tx.ID)
}
fmt.Println()
}
fmt.Println("=== Audit Log ===")
for _, log := range audit.logs {
fmt.Println(" ", log)
}
fmt.Println("\n=== Final Balances ===")
for _, id := range []string{"ACC001", "ACC002", "ACC003"} {
acc, _ := repo.FindByID(id)
fmt.Printf(" %s (%s): Rp%.0f\n", acc.ID, acc.Name, acc.Balance)
}
}
Summary #
- Errors are values — returned as the last return value, not an exception mechanism; the flow is always readable linearly.
- The
errorinterface only needsError() string— any type with this method is a valid error.- Sentinel errors (
var ErrXxx = errors.New(...)) for conditions that need precise comparison witherrors.Is.fmt.Errorfwith%wwraps an error with context while preserving the chain;%vbreaks the chain.- Custom error types when an error needs to carry extra data — implement
Unwrap()so the chain stays traceable.errors.Ischecks value equality traversing the whole chain;errors.Asextracts a specific type from the chain.- Annotate at boundary — add context as errors cross layers; handle once — handle the error in a single place.
panicfor programming bugs and initialization failures;recoverinsidedeferto prevent crashes in middleware.errors.Join(Go 1.20+) combines several errors into one — useful for validation that collects all errors.- Don’t ignore errors with
_unless you’re truly certain — always consider what happens if the operation fails.