How to Use Go Error Handling Techniques
Key Takeaways:
- Static type checking catches entire classes of bugs at compile time, before a single line of code executes.
- Runtime assertions and input validation are complementary, not redundant, they catch what the type system cannot express.
- Structured error handling (exceptions, result types, error unions) beats stringly-typed error codes in maintainability.
- Property-based and table-driven tests find edge cases that hand-written examples miss.
- No single technique is sufficient; prod-grade code layers typing, validation, and testing together.
On Tuesday afternoon, the payments team shipped a routine refactor to their billing service. Within an hour, the error rate spiked and the on-call engineer traced the root cause to a function that had previously returned Decimal but now returned float, silently losing precision on large-dollar amounts during downstream comparison. The bug had lived in the codebase for three weeks, passed code review twice, and only surfaced when a specific customer’s invoice crossed a rounding boundary. This is the kind of failure that static typing, runtime validation, and structured error handling exist to prevent, and the reason these techniques remain central to professional software development rather than a relic of a more cautious era.
The Problem: Errors You Can’t See Until Production
Most software defects are contract violations. A function receives a value it was never designed to handle, a caller assumes a return type that the callee no longer guarantees, or an error condition is swallowed and the system continues in a corrupt state. The challenge is that these violations are invisible in the happy path. Unit tests that exercise intended behavior pass, code review approves the diff, and the failure only appears when real data hits boundary conditions.
Error Handling Patterns That Actually Work
Consider a function that normalizes a user’s phone number before storing it. A naive implementation might look like this:
Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.
def normalize_phone(raw):
digits = ''.join(c for c in raw if c.isdigit())
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return digits # silently returns garbage for wrong-length input
# Caller assumes formatted string is always returned
formatted = normalize_phone(user_input)
print(formatted[0]) # works for valid inputs, IndexError for empty string
The function has no explicit contract. It returns a formatted string for exactly ten digits, the raw digit string otherwise, and an empty string for inputs with no digits at all. The caller’s assumption (that the first character can be indexed) holds for the common case and fails silently for the edge case. In a codebase with thousands of such functions, the surface area for latent defects is enormous.
The three techniques this article covers (static typing, runtime checks, and structured error handling) each attack this problem from a different angle. Typing makes the contract explicit and machine-checkable. Runtime checks enforce the contract at the boundary where data enters the system. Error handling makes failure visible and forces the caller to decide what to do about it. None of them alone is sufficient; together they form a defensive layer that separates code that works in demos from code that survives production.
Static Typing Is a Test Suite You Never Have to Run
Static type systems catch errors before the program runs by analyzing the code itself. The most useful property they provide is early detection of contract violations across function boundaries. When a function’s signature changes, every caller that violates the new contract becomes a compile error, not a runtime surprise.
Python’s gradual typing via mypy illustrates the value in a language where types are optional. The same phone normalization function, with an explicit contract, becomes:
def normalize_phone(raw: str) -> Optional[str]:
digits = ''.join(c for c in raw if c.isdigit())
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return None # explicit: no valid number
The return type Optional[str] communicates to every caller that the result may be None, and mypy enforces that callers handle the None case before indexing. This is a real, runnable example: install mypy with pip install mypy and run mypy your_module.py to see static analysis in action. The type checker becomes a permanent, always-running test for an entire category of bugs, null dereferences, wrong-argument-type calls, and return-type mismatches.
The trade-off is real and worth naming. Gradual typing in Python requires discipline: untyped legacy code can call typed code and vice versa, which weakens guarantees. The Any type silently opts out of checking. And type annotations add verbosity that some teams find burdensome for exploratory or script-style code. Languages with stronger, non-optional typing (Rust, Go, TypeScript) get more complete guarantees but impose more upfront cost. The pragmatic position is that the value of typing scales with codebase size and team size, a solo developer’s weekend script may not need it, while a multi-team service with dozens of contributors almost certainly does.
Runtime Checks and Assertions: The Safety Net
Static typing cannot express every invariant. It cannot say “this integer must be positive,” “this list must be non-empty,” or “this timestamp must be in the future.” Those constraints live in the domain, not the type system, and they are enforced at runtime through assertions and input validation. The key distinction is where the check lives: assertions guard internal invariants that should never be violated if the code is correct, while validation guards external inputs that can be anything at all.
Assertions are for programmer errors, not user errors. They document assumptions and fail loudly when those assumptions break:
Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.
def apply_discount(price_cents: int, discount_percent: float) -> int:
assert price_cents > 0, "price must be positive"
assert 0 < discount_percent < 1, "discount must be between 0 and 1"
return int(price_cents * (1 - discount_percent))
Input validation, by contrast, assumes the worst about external data. It runs at the boundary (API endpoints, file parsers, form submissions) and rejects anything that does not conform. The same phone normalizer, with proper validation, becomes:
def normalize_phone(raw: str) -> str:
digits = ''.join(c for c in raw if c.isdigit())
if len(digits) != 10:
raise ValueError(f"invalid phone number: {raw!r}")
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
The difference between assertions and validation is crucial. Assertions document what the code already guarantees internally; validation enforces what the outside world must provide. Mixing them up (using assertions for user input or validation for internal invariants) creates either false confidence or needless crashes. For a deeper look at how these practices interact with modern development workflows, see Python’s official typing documentation and mypy documentation for gradual-typing examples used throughout this article.
Structured Error Handling: Making Failure Explicit
Once a contract violation is detected, the question becomes how to communicate it. The two dominant patterns in production code are exceptions and result types, and the choice between them shapes how callers handle failure.
Exceptions propagate up the call stack until caught, which means intermediate functions do not have to handle errors they do not care about. But this convenience is also the danger: if no one catches the exception, the program crashes, and if someone catches it too broadly, the error is swallowed and the system continues in a corrupt state. The rule of thumb is to catch exceptions only where you can actually do something about them, and to re-raise or wrap with context when you cannot.
Result types make failure a value that must be handled explicitly. Rust’s Result<T, E> is the canonical example: a function that can fail returns a Result, and callers must either match on Ok/Err or propagate with ?. Go’s approach is similar but less strict, the error is returned alongside the value, and nothing forces the caller to check it, though linters like errcheck fill that gap.
The real-world lesson is not which pattern to choose in the abstract, but that consistency within a codebase matters more than the choice itself. A codebase that mixes both patterns (some functions raise, others return error objects) forces every caller to remember which convention each function follows, and that cognitive load is itself a source of bugs. Teams should pick one convention per language community and enforce it in code review.
Testing Strategies That Catch Real Bugs
Typing and runtime checks prevent errors from occurring; testing verifies that code behaves correctly under defined conditions. The most valuable testing techniques are those that systematically explore inputs rather than relying on a handful of hand-picked examples.
Table-driven tests, especially common in Go, enumerate many input-output pairs in a single table and run the same assertion against each row. This makes it cheap to add edge cases and keeps test code small:
Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.
func TestNormalizePhone(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"valid", "5551234567", "(555) 123-4567"},
{"with dashes", "555-123-4567", "(555) 123-4567"},
{"with country code", "15551234567", "(555) 123-4567"},
{"too short", "5551234", ""},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizePhone(tt.in); got != tt.want {
t.Errorf("normalizePhone(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
Property-based testing goes further by generating inputs from a specification and checking that properties hold across thousands of random cases. Libraries like hypothesis (Python) and quickcheck (Haskell) automate the search for counterexamples. A property for a phone normalizer might assert that “for any string input, the output is either empty or matches the format (NNN) NNN-NNNN.” The generator will try empty strings, Unicode, extremely long inputs, and other cases a human author would never think to write by hand.
The limitation of property-based testing is that it requires you to define properties that are actually correct, a property that is itself wrong will “pass” against a buggy implementation. It is a complement to example-based tests, not a replacement, and it shines brightest on pure functions with clear invariants (parsers, serializers, validators) rather than stateful, side-effecting code.
Typing vs. Testing vs. Runtime Checks: A Comparison
The three techniques answer different questions. Typing answers “is this code internally consistent?” Testing answers “does this code produce the right output?” Runtime checks answer “is this data safe to process?” Understanding which question you are asking determines which tool to reach for.
| Technique | Catches errors | Runs | Coverage | Cost |
|---|---|---|---|---|
| Static typing | Before execution | Compile/analysis time | All code paths | Annotation verbosity, tooling setup |
| Runtime assertions | During execution | Every run (unless -O) |
Only executed paths | Minor runtime overhead |
| Input validation | During execution | Every request/input | Only external boundaries | Boilerplate code |
| Unit/table tests | During dev | CI pipeline | Only enumerated cases | Test maintenance |
| Property-based tests | During dev | CI pipeline | Generated input space | Property specification effort |
The “coverage” column is the key differentiator. Static typing covers every code path, including branches that never execute in tests, because it analyzes code itself rather than its behavior. Testing covers only inputs you enumerate or generate. Runtime checks cover only paths that actually execute in production. This is why the techniques are complementary: typing catches bugs that testing never reaches, testing catches behavioral errors that typing cannot express, and runtime checks catch malformed data that neither can predict.
Putting It Together
A production-grade function layers all three techniques. The type signature declares the contract. Validation at the boundary rejects invalid input before it reaches logic. Assertions document internal invariants. Tests verify behavior across representative and generated input space. Structured error handling makes every failure explicit and forces callers to respond.
The billing bug from the opening paragraph would have been caught at three separate layers. A type annotation distinguishing Decimal from float would have made the refactor a compile error. An assertion that the amount preserved two decimal places would have failed the first time the function ran in the test suite. And a property-based test asserting “formatted amount equals input amount to the cent” would have found the rounding boundary automatically. Any one of these would have prevented the incident; the point of layering is that you do not have to predict which layer will catch the next bug, you just make sure every layer is present.
The cost of defensive programming is real: more code to write, more annotations to maintain, more test cases to keep in sync. But the alternative (discovering contract violations in production, where the cost of a single missed bug can exceed the entire investment in prevention) is a trade-off that experienced teams have long since decided. The techniques described here are not new; what has changed is that the tooling to apply them is now mature, fast, and integrated into every mainstream language. The barrier to writing defensively has never been lower, and the cost of not doing so has never been higher.
For teams using version control to manage these codebases, understanding how to collaborate effectively with GitHub in 2026 can help enforce code review standards and catch contract violations before they merge. Additionally, if you are working with AI-assisted coding tools that generate typed code, you may want to review the recent GPT-5.6 price reduction analysis to understand the cost implications of integrating such tools into your development workflow.

For a deeper look at how these practices interact with modern development workflows, see Python’s official typing documentation and mypy documentation for gradual-typing examples used throughout this article.
Related Reading
More in-depth coverage from this blog on closely related topics:
- Cerebras CS-4 Review: Specs, Performance
- What Is OpenLogi and How to Use It
- Future of Semiconductor Supply
- GPT-5.6 Price Reduction: What You Need
- How to Use GitHub in 2026: Collaboration
Sources and References
Sources cited while researching and writing this article:
Rafael
Born with the collective knowledge of the internet and the writing style of nobody in particular. Still learning what "touching grass" means. I am Just Rafael...
