Parse, Don't Validate - in Python
Alexis King has an excellent blog post called "Parse, don't validate" describing a code pattern. I find it improves code quality and is a good convention to use when heavily using coding agents, since it eliminates entire categories of mistakes.
King's original post uses Haskell, though, and I'm a Pythonista. This post adapts her "parse, don't validate" idea for Python. If you are a Haskell person, you can stop reading this post and just read hers.
In a nutshell, her idea is to coerce unstructured data into constrained types at the boundary of your system, so that downstream code never needs to re-validate. Definitions: validate means "check a condition and throw away the evidence", whereas parse means "check the condition and preserve the evidence in a more precise type/data structure". More generally, a parser is any function which takes less-structured input and produces more-structured output.
This comes with many benefits:
- cleans up your code base by obviating redundant validations
- code becomes self-documenting with precise types, since they are more informative than raw Python objects like
dict[str, int] - static code analyzers prevent entire categories of bugs, making refactoring safer
- You can optionally make Python enforce types at runtime
- better auto-complete via IntelliSense
The problem: redundant checking
Here's a function that returns the first element of a list:
from typing import TypeVar
T = TypeVar("T")
def head(lst: list[T]) -> T | None:
if lst:
return lst[0]
return None
The list might be empty, so we return T | None. Fair enough. Now use it:
import os
def get_config_dirs() -> list[str]:
dirs = os.environ["CONFIG_DIRS"].split(",")
if not dirs:
raise ValueError("CONFIG_DIRS cannot be empty") # check 1
return dirs
def main() -> None:
config_dirs = get_config_dirs()
first_dir = head(config_dirs) # check 2 - redundant
if first_dir is not None: # check 3 β redundant
initialize_cache(first_dir)
else:
raise RuntimeError("should never happen")
In this example, we're handling the empty scenario three times because the return type list[str] doesn't carry any proof that the list is non-empty. The knowledge we gained from check 1 was discarded. As far as the type system is concerned, config_dirs could be []. Thus, downstream functions need to be littered with defensive validation, or silence mypy with # type: ignore and pray.
The fix: constrained types
There are a few ways ways to constrain types. One is by construction, another with custom validators.
Constraining by construction
You can use data structures that make it impossible to represent illegal data shapes. Most of the time, Python's built-in data structures give you the invariances you need: set when items must be unique, enum when there's a fixed set of valid values, etc.
In the current case, Python doesn't have a built-in non-empty list type, so let's make one:
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True)
class NonEmpty(Generic[T]):
"""A list guaranteed to have at least one element."""
first: T
rest: list[T]
@staticmethod
def from_list(lst: list[T]) -> "NonEmpty[T] | None":
if not lst:
return None
return NonEmpty(first=lst[0], rest=lst[1:])
def __iter__(self):
yield self.first
yield from self.rest
def __len__(self) -> int:
return 1 + len(self.rest)
It is impossible to construct an empty NonEmpty. Now head is trivial and total:
def head(lst: NonEmpty[T]) -> T:
return lst.first
And the program becomes:
def get_config_dirs() -> NonEmpty[str]:
dirs = os.environ["CONFIG_DIRS"].split(",")
result = NonEmpty.from_list(dirs)
if result is None:
raise ValueError("CONFIG_DIRS cannot be empty")
return result
def main() -> None:
config_dirs = get_config_dirs()
initialize_cache(head(config_dirs)) # no None check needed
We check once, at the boundary (get_config_dirs), and the return type NonEmpty[str] proves the list is non-empty. That proof is carried through the type system β mypy enforces it, and if someone later changes get_config_dirs to return a plain list[str], the call in main fails type-checking immediately.
By the way, this example is overkill. I'm just using for illustrative purposes, but you don't want to be too dogmatic. Most of the time, you don't need custom data structures. Something like dict[UserId, User] is perfectly fine to use if it precisely represents for the meaning of the data.
Constraints beyond data structures
Some invariants can't be easily encoded in the data structure, like: "these DataFrame columns are sorted," "this tensor has shape (batch, seq_len, hidden)," "the output has the same columns as the input." That's where assertions fill in the gap as executable documentation of properties that types can't capture. I wrote more about this in In defense of Python assertions.
You can use NewType wrappers when the type system can't express a constraint directly:
from typing import NewType
Percentage = NewType("Percentage", int)
def parse_percentage(value: int) -> Percentage:
if not (0 <= value <= 100):
raise ValueError(f"percentage must be 0-100, got {value}")
return Percentage(value)
Parse early
Interleaving data validation with processing is a security anti-pattern called shotgun parsing. By the time you uncover invalid data, you may have already caused undesired side-effects like sending email or clobbering database state. Rolling back may be difficult or impossible.
This can be avoided by parsing as close to the inbound boundary as possible. In other words, parse wherever you first ingest uncertain data β env vars, config files, third-party responses, etc. If parsing fails, no side effects have occurred. And all downstream code has more validity guarantees even without validation.
That said, don't be afraid to parse in multiple phases. You shouldn't act on data before it's fully parsed, but you can use partially-parsed data to decide how to parse the rest.
Python specifics
Python is a dynamic language with unenforced type hints, which makes it much easier to write buggy code than better type systems, like Haskell or Typescript. However, combining a few Python libraries (beartype + pydantic + mypy) gives Python real enforcement.
Why not just use Pydantic?
Pydantic is great for validating data constraints on construction:
from pydantic import BaseModel, field_validator
class OrderItem(BaseModel):
product_id: strricardo1
quantity: int
@field_validator("quantity")
@classmethod
def must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("quantity must be positive")
return v
class Order(BaseModel):
customer_id: str
items: list[OrderItem]
@field_validator("items")
@classmethod
def must_be_non_empty(cls, v: list[OrderItem]) -> list[OrderItem]:
if not v:
raise ValueError("order must have at least one item")
return v
# if Order(...) succeeds, everything is valid
Notice however Pydantic is technically validating here, not parsing, because the validations are not exposed to the type checker. For example, although the field_validator ensures that items is a non-empty list, mypy knows none of that. It just thinks it is a list[OrderItem]. The same is true if you use Field(min_length=1): items are still list[OrderItem] to the type checker. So any downstream function that accepts list[OrderItem] is back to defensively checking.
To actually parse, the type has to change:
class Order(BaseModel):
customer_id: str
items: NonEmpty[OrderItem] # the type itself proves non-emptiness
Runtime type validation using beartype
I'm a big fan of beartype, a fast runtime type checker for Python. I like invoking beartype_this_package() in the top-level __init__.py blanket apply beartype on the whole Python package. I find this is great for ensuring type hints on functions are actually correct. Also, it catches lazy tests which aren't exercising code with realistic data.
This would be exhausting to maintain manually, but in the age of agents, it helps keep them from making silly mistakes.
Be suspicious of functions that can return None
If a function's job is "check something and raise," there's probably a version that returns a more precise type instead.
Conclusion
I hope you find the "parse, don't validate" pattern useful for leveling-up your Python code quality. Leave a comment if you found this article useful!