A junior developer writes code that assumes everything will go right (no checks).
A mid-level developer writes code that assumes everything will go wrong (defensive checks and assertions everywhere).
A senior developer uses the type system to make going wrong impossible, deleting 80% of those checks entirely.
— Software Engineering Proverb
TL;DR
- Validate at the edges (Defensive): Expect bad data from the outside world. Handle it gracefully using the
Ok / Errpattern (theResultpattern) instead of throwing unpredictabletry/excepterrors. - Assert in the core (Offensive): Expect perfect data internally. If your internal state is wrong, it’s a system bug. Use a custom
assert_okto crash immediately (fail-fast). - Parse, Don’t Validate: Don’t just check data; transform it at the boundary into typed domain primitives using
NewType(e.g.,ValidatedUserId,PositiveAmount). - The Result: Your core business logic functions only accept domain-branded types. This makes invalid states mathematically impossible to represent, allowing you to delete hundreds of lines of defensive
if not datachecks.
If you’ve spent enough time writing Python web services, you’ve likely written a function that looks exactly like this:
| |
This code is exhausting to read. The core business logic is buried under a mountain of defensive if/raise statements. Furthermore, the except block has no idea if the error was a user making a typo (400 Bad Request) or the database catching fire (500 Internal Error).
A note on FastAPI: Yes, FastAPI can handle Pydantic validation automatically in the route signature (e.g.,
async def process_refund(payload: RefundInput):). We are doing it manually here to clearly demonstrate the architectural boundary between the Web framework, the Parser, and the Core Domain. In a real app you’d likely use FastAPI’s integration — but the architectural pattern remains identical.
Error handling isn’t just about catching mistakes; it’s about system architecture.
To fix this, we need to understand the fundamental difference between Validation and Assertion, adopt the Result pattern, and use Branded Types (NewType) to push errors to the absolute edges of our system.
Rule 1: Learn the Difference Between Validation and Assertion
The most important architectural distinction you can make is understanding the difference between validating data and asserting state.
Think of your application like an exclusive Nightclub.
| Feature | Validation | Assertion |
|---|---|---|
| Location | The Front Door (API/IO Boundary) | The VIP Room (Core Logic) |
| Expectation | Bad data is expected | Data is trusted |
| Philosophy | Defensive (Bouncer) | Offensive (Security Guard) |
| Outcome | Graceful Recovery (400 Bad Request) | Immediate Crash (500 System Error) |
Validation is the bouncer at the front door. The bouncer expects people to hand him fake IDs or be underage. He calmly turns them away. Validation inspects untrusted external input and recovers gracefully.
Assertion is the security guard deep inside the VIP room. The guard expects everyone in the room to have a VIP wristband. If someone is in the room without one, the bouncer system has fundamentally failed. The guard stops the music and shuts down the party. Assertions inspect internal logic and fail fast.
When you mix these up, systems become fragile. If you crash the app when a user types a bad email, you have a terrible UX. If you try to “gracefully recover” when a database query returns an impossible state—like a negative account balance—you silently corrupt your data.
Rule 2: At the Edge, Treat Errors as Values
When data arrives from the outside world (API input, DB reads), it is untrusted. Because we expect bad data, raising exceptions is an anti-pattern. raise is essentially a hidden GOTO statement that destroys Python’s type safety (the exception in an except block has no static type information about where it came from).
Instead, we use the Ok / Err pattern (the Result pattern), heavily inspired by Go and Rust. We treat errors as standard return values.
| |
By returning a Result, the type checker forces the caller to handle the failure before they are allowed to access value. We’ve eliminated the silent boundary leak.
Usage with structural pattern matching (Python 3.10+):
| |
Rule 3: Parse, Don’t Validate
Now that we have safely parsed the JSON, we need to ensure it has the correct shape. But traditional validation has a massive flaw: it doesn’t leave a receipt.
If you write a function is_valid_email(input: str) -> bool, and it returns True, Python still just sees a str. If you pass that string down through five other files, none of those files know it was validated. So, mid-level developers defensively re-validate the string everywhere.
To fix this, we use the “Parse, Don’t Validate” paradigm 1.
We use schema parsers (like Pydantic 2) combined with Branded Types (NewType) to make invalid states impossible to represent.
| |
Why this works: After data passes through parse_refund_request, the returned TrustedRefund.user_id is typed as ValidatedUserId, not str. Any function in your core domain that accepts ValidatedUserId is mathematically guaranteed to only ever receive a validated UUID. You cannot accidentally pass a raw, unvalidated string — the type checker will reject it.
Rule 4: Inside the Core, Assert and Crash
Once data has passed the Smart Constructor, it is inside our trusted domain. We shouldn’t be handling expected validation errors anymore. We are dealing with internal system invariants.
If an assumption is wrong here, we want to crash. Python’s TypeGuard makes this powerful by permanently narrowing types for the remainder of a scope.
| |
A note on assert: Python’s built-in assert statement is stripped when the interpreter runs with -O (optimizations). Never use it for system invariants. Always use a custom assertion function like assert_ok above that unconditionally raises.
The Grand Architecture: Layers of Trust
Let’s look at the “Before” code from the beginning of this article, refactored into the Layers of Trust architecture.
| |
The Takeaway
Look at the execute_refund function above.
It is completely pure.
There are no if statements checking for empty strings.
There are no isinstance checks.
Your cognitive load drops to zero.
To stop fighting Python and start leveraging it as an architectural tool, memorize this paradigm:
- Validation is Defensive. You expect the outside world to be messy. Use the
Ok / Errpattern to parse data, gracefully recover, and return user-friendly 400-level errors. - Assertion is Offensive. You expect your internal logic to be flawless. Use
assert_okto fail-fast, crash, and return 500-level errors when system invariants are broken. - Encode Trust in Types. Push validation to the edges, use Smart Constructors to create Branded Types (
NewType), make invalid states unrepresentable, and delete the rest of your runtime checks.
Correctness is so important that any violation of internal logic is a bug. Build strict boundaries, trust your types, and watch your codebase become infinitely more resilient.
This concept was popularized by Alexis King in her seminal essay Parse, Don’t Validate. ↩︎
Pydantic is the de-facto Python library for data validation using type annotations. It is the Python ecosystem’s equivalent of Zod. ↩︎