arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 02Go for Backend Engineers — Concurrency, Simplicity and Real Numbers6 min read

Go Fundamentals, Fast

October 29, 2026

TL;DR

The biggest mental shift for newcomers is error handling: Go has NO exceptions for normal failures. A function that can fail returns an error as its last value, and you check it right there: f, err := os.Open(p); if err != nil { return ... }. Yes, 'if err != nil' is everywhere — and that is the feature: every failure path is visible on the page, right next to the call, instead of an invisible jump to a handler three layers up. WRAP errors with %w to add context as they travel up (fmt.Errorf("charge user %d: %w", id, err)); INSPECT them with errors.Is (sentinels like sql.ErrNoRows) and errors.As (typed errors). panic/recover exists but is for BUGS (nil dereference, impossible states), not expected failures like a missing file or a bad request. Other fundamentals that matter: the error is part of the SIGNATURE so callers can't forget it; zero values are usable; defer runs cleanup; gofmt ends all style debates. Explicit beats surprising.

You can learn Go’s syntax in an afternoon — it is a small language (Lesson 1). What actually trips up newcomers is not syntax but a handful of idioms that are different enough to feel wrong at first, chief among them how Go handles errors. This lesson is the fast path through the fundamentals that matter, spending most of its time on the one that matters most.

Errors Are Values, Not Exceptions

In Java, Python, C#, JavaScript, you signal failure by throwing an exception, and somewhere up the call stack a catch block handles it — or doesn’t, and the program crashes. Go does not work this way at all. In Go, an error is an ordinary value that a function returns, conventionally as its last return value, and you check it right where the call happens.

Errors are values, not exceptions. The Go way, return and check: f, err := os.Open(path); if err != nil { return fmt.Errorf open path percent-w err }; defer f.Close(); then use f. Every call that can fail returns an error; you check it right there, or pass it up. Not the Go way, exceptions: try doThing catch e — Go has NO try/catch for normal errors; control flow stays linear, no invisible jump to a handler three layers up; panic exists but it is for BUGS like nil dereference or out-of-range, not for expected failures like a missing file. Why verbose error handling is a feature: you get code where every failure path is visible on the page, right next to the call. Wrap with percent-w to add context as the error travels up. Inspect with errors.Is for sentinels and errors.As for typed errors. The error is part of the function signature so callers cannot forget it exists, the way a thrown exception can silently blow past three layers that never expected it.

The canonical shape is everywhere in Go:

f, err := os.Open(path)
if err != nil {
    return fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
// ... use f ...

Every function that can fail hands back an error alongside its result. nil means success; anything else is a failure you must deal with — handle it, or return it up to your caller. There is no try, no catch, no invisible jump. The control flow stays linear: you can read top to bottom and see exactly what happens on every failure path.

Newcomers almost universally recoil at this. ”if err != nil is on every third line — it’s so noisy!” And it is verbose. But sit with it through one production incident and the value becomes obvious: every failure path is visible, right next to the call that can fail. When the database call errors, the handling is right there, not in some catch block three files away that you have to go hunting for. You never get the exception-language surprise where a failure thrown deep in a library sails silently past five layers that never expected it and blows up somewhere unrelated. In Go, the error is part of the function’s signature — func Get(id int) (Order, error) — so a caller cannot forget it exists. The compiler and the idiom force you to confront failure at every step. Explicit beats surprising.

Wrapping and Inspecting Errors

Returning errors up the stack is only useful if they carry context. A bare "connection refused" five layers up tells you nothing about what was being attempted. So you wrap as the error travels upward, using fmt.Errorf with the %w verb:

if err != nil {
    return fmt.Errorf("charge user %d: %w", userID, err)
}

Each layer adds a phrase, and you end up with a readable trail: charge user 42: call stripe: connection refused. The %w is important — it wraps the original error rather than flattening it to a string, so the original is still inspectable at the top.

And you inspect it with two standard functions:

  • errors.Is(err, target) — “is this error (or anything it wraps) this specific sentinel?” Used with package-level sentinel errors: if errors.Is(err, sql.ErrNoRows) { /* not found */ }. It unwraps through all your %w layers to find a match.
  • errors.As(err, &target) — “is this error (or anything it wraps) of this type?” Used with custom error types that carry fields: var pe *net.OpError; if errors.As(err, &pe) { /* inspect pe.Op, pe.Addr */ }.

Together, wrapping (%w) on the way up and Is/As at the top give you rich, debuggable errors without exceptions — context where it is added, inspection where it is needed.

panic and recover: For Bugs, Not Failures

Go does have something exception-shaped: panic (unwind the stack) and recover (catch a panic). But the idiom is strict and worth burning in: panic is for programmer bugs, not for expected failures.

  • Expected failures — a file that doesn’t exist, a malformed request, a user not found, a network blip — are normal and return an error. These are not exceptional; they are Tuesday. Handle them with the value pattern above.
  • Bugs and impossible states — a nil pointer dereference, an index out of range, an invariant that should never be violated — panic. These mean the program is in a state the programmer thought could not happen.

In a server, you typically recover at the top of each request (a middleware, Lesson 8) so that one request hitting a bug returns a 500 and logs a stack trace, instead of crashing the whole process and taking down every other in-flight request. But you do not use panic/recover as a general try/catch for control flow — reaching for it to avoid writing if err != nil is a classic non-Go smell that other Go engineers will flag immediately.

A Few More Fundamentals That Matter

The rest of the fundamentals are quick, but these four save real pain:

  • Zero values are usable. Every type has a sensible zero: 0, "", nil, false. A var buf bytes.Buffer is ready to use without initialisation; a sync.Mutex’s zero value is an unlocked mutex. Design your own types so their zero value is valid, and callers need less ceremony.
  • defer for cleanup. defer f.Close() schedules the close to run when the function returns, whatever path it returns by. It keeps cleanup next to acquisition and makes it impossible to forget on an error path. Deferred calls run in LIFO order.
  • Slices and maps are the workhorses. A slice is a growable view over an array (append to grow, and know that it may reallocate); a map is the built-in hash table (v, ok := m[key] to test presence). You will use these constantly. Maps are not safe for concurrent writes — remember that for Lesson 7.
  • gofmt ends all style debates. Formatting is not a matter of taste in Go; gofmt (and goimports) canonicalise it. Run it on save. Unused imports and unused local variables are compile errors, not warnings — strict, but it keeps the codebase clean by force.

Put together, these fundamentals produce Go’s characteristic texture: explicit, linear, a little verbose, and very hard to misread. The error handling in particular is the thing to make peace with early, because the next time you debug a production failure and every error path is sitting right there on the page with a wrapped context trail, you will stop missing exceptions.

Pitfalls to Avoid

  1. Ignoring returned errors. Writing f, _ := os.Open(path) to silence the error, then dereferencing a nil f and crashing. The error is returned for a reason — handle it or return it. (Linters like errcheck catch this.)
  2. Flattening errors with %v instead of %w. fmt.Errorf("...: %v", err) turns the error into a plain string, so errors.Is/As can no longer see the original. Use %w to wrap, %v only when you deliberately want to obscure the cause.
  3. Using panic/recover as try/catch. Panicking on a missing file or a bad input, then recovering, to avoid if err != nil. That is fighting the language — return errors for expected failures; reserve panic for bugs.
  4. Not recovering in a server. The opposite mistake: no top-level recover middleware, so one request hitting a nil-pointer bug panics and crashes the whole process, killing every other in-flight request. Recover per request, log, return 500.
  5. Assuming a type needs a constructor. Writing initialisation ceremony for types whose zero value is already usable (bytes.Buffer, sync.Mutex). Lean on zero values; design your own types to have useful ones.
  6. Forgetting defer for cleanup. Opening a file, DB rows, or a lock and closing it manually on only the happy path, leaking it on error paths. defer the cleanup right after you acquire the resource.

Quick Recap

  • Errors are values, not exceptions: a function returns error as its last value and you check it right there (if err != nil). The verbosity is the feature — every failure path is visible on the page, and the error is part of the signature so callers cannot forget it.
  • Wrap with %w to add context as errors travel up (fmt.Errorf("doing X: %w", err)); inspect with errors.Is (sentinels) and errors.As (typed errors).
  • panic/recover is for bugs (nil deref, impossible states), not expected failures. In servers, recover per request so one bug doesn’t crash the process.
  • Lean on zero values, use defer for cleanup, know your slices and maps, and let gofmt end style debates. Unused imports/vars are compile errors.

Next lesson: the two ideas that shape every Go codebase’s structure — implicit interfaces and composition over inheritance.