Every Python project hits this moment. You need a function that only accepts valid input. Easy right? Just add some checks at the top.
That's what I thought anyway.
Building an expense tracker. Function takes a list of transactions, each with amount and category. Categories had to be from a fixed set: food, transport, entertainment, utilities. Amounts had to be positive numbers.
Basic validation. Should take 10 minutes.
Took 4 hours.
First, I tried type checks:
def add_expenses(transactions):
if not isinstance(transactions, list):
raise TypeError("Transactions must be a list")
for t in transactions:
if not isinstance(t, dict):
raise TypeError("Each transaction must be a dict")
if 'amount' not in t or 'category' not in t:
raise ValueError("Missing required fields")
if not isinstance(t['amount'], (int, float)):
raise TypeError("Amount must be number")
return process(transactions)
Looked solid. Types are right, required fields exist.
Ran the tests. All green.
Shipped it.
User submitted {"amount": -50, "category": "food"}. Negative amount. Type check passed. Negative numbers are valid int and float after all.
My validation said "this is fine" while the data was completely wrong.
Okay, value validation time:
VALID_CATEGORIES = {'food', 'transport', 'entertainment', 'utilities'}
def add_expenses(transactions):
if not isinstance(transactions, list):
raise TypeError("Transactions must be a list")
for t in transactions:
if not isinstance(t, dict):
raise TypeError("Each transaction must be a dict")
amount = t.get('amount')
category = t.get('category')
if amount is None or category is None:
raise ValueError("Missing required fields")
if not isinstance(amount, (int, float)):
raise TypeError("Amount must be number")
if amount <= 0:
raise ValueError("Amount must be positive")
if category not in VALID_CATEGORIES:
raise ValueError(f"Category must be one of {VALID_CATEGORIES}")
return process(transactions)
Better. Now checks that amounts are actually positive. Now checks that categories are in the valid set.
Tests passed again.
User submitted {"amount": 0, "category": "food"}. Zero dollars. Is zero a valid expense? Depends on your business logic, but in this case, no. Zero means the transaction wasn't recorded properly. Our validation said "this is fine" again.
Wait, I wrote if amount <= 0. That's <=, not <. So zero would pass. Fixing that.
Except I also forgot that isinstance(50.0, int) returns False. Fifty dollars as a float. Fifty cents as 0.50. Both valid amounts. But my check said floats that aren't integers are fine, while actual integers that are zero are caught.
Third time's the charm right? Pydantic:
from pydantic import BaseModel, field_validator
class Transaction(BaseModel):
amount: float
category: str
@field_validator('amount')
@classmethod
def amount_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Amount must be greater than zero')
return v
@field_validator('category')
@classmethod
def category_must_be_valid(cls, v):
if v not in VALID_CATEGORIES:
raise ValueError(f'Category must be one of {VALID_CATEGORIES}')
return v
class TransactionList(BaseModel):
transactions: list[Transaction]
def add_expenses(data):
validated = TransactionList(transactions=data)
return process([t.model_dump() for t in validated.transactions])
This time the validation actually works at the value level. Not just checking types but checking that the types have the right values. Amount 0 or -50 or "fifty" (yes someone tried it) all fail before even reaching my code.
Tests passed. User tested it. Negative amounts rejected. Zero rejected. Invalid categories rejected. String amounts rejected.
Worked.
Honestly I should have just used Pydantic from the start. Spent 3 hours writing validation that accomplished less than what a library does for free.
Top comments (0)