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.
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%wlayers 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. Avar buf bytes.Bufferis ready to use without initialisation; async.Mutex’s zero value is an unlocked mutex. Design your own types so their zero value is valid, and callers need less ceremony. deferfor 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 (
appendto 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. gofmtends all style debates. Formatting is not a matter of taste in Go;gofmt(andgoimports) 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
- Ignoring returned errors. Writing
f, _ := os.Open(path)to silence the error, then dereferencing anilfand crashing. The error is returned for a reason — handle it or return it. (Linters likeerrcheckcatch this.) - Flattening errors with
%vinstead of%w.fmt.Errorf("...: %v", err)turns the error into a plain string, soerrors.Is/Ascan no longer see the original. Use%wto wrap,%vonly when you deliberately want to obscure the cause. - 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. - 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.
- 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. - Forgetting
deferfor cleanup. Opening a file, DB rows, or a lock and closing it manually on only the happy path, leaking it on error paths.deferthe cleanup right after you acquire the resource.
Quick Recap
- Errors are values, not exceptions: a function returns
erroras 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
%wto add context as errors travel up (fmt.Errorf("doing X: %w", err)); inspect witherrors.Is(sentinels) anderrors.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
deferfor cleanup, know your slices and maps, and letgofmtend 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.