The Zen of Python in Code Review
What each line of import this looks like in real code. The same patterns surface again and again in the pull requests I review for Python, Django, and FastAPI developers, not because anyone is careless, but because Python's flexibility makes it easy to get things almost right. Each pattern below maps to one line of the Zen of Python, with before/after code.
1. Beautiful is better than ugly
Code is read far more than it's written. When you make it readable, that is, use clear names, consistent structure, separation of concerns, you're not being precious about aesthetics. You're reducing the cost every future reader pays to understand what it does.
Unrelated concerns like timing, logging, auth checks, clutter the function body when inlined. Decorators and context managers separate the what from the wrapping.
# Ugly: timing logic embedded and repeated everywhere
def process_orders():
start = time.time()
# ... actual logic ...
logger.info(f"process_orders took {time.time() - start:.2f}s")
def sync_inventory():
start = time.time()
# ... actual logic ...
logger.info(f"sync_inventory took {time.time() - start:.2f}s")
# Beautiful: the function body focuses on the core logic, timing is abstracted away
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
logger.info(f"{func.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
@timed
def process_orders():
...
Context managers serve a similar purpose for resource cleanup. Database sessions, file handles, HTTP clients: anything that needs cleanup should use a context manager so it always happens, even when exceptions occur.
# Ugly: cleanup skipped if query raises
session = Session()
result = session.execute(query)
session.close()
# Beautiful: cleanup guaranteed
with Session() as session:
result = session.execute(query)
2. Explicit is better than implicit
Implicit code relies on the reader knowing all the rules, things like Python truthiness, default argument evaluation, magic method side effects. That's cognitive overhead that creates real bugs. Being explicit isn't redundancy; it's respect for the next person reading the code, including future you.
Truthiness checks treat 0, "", and [] the same as None. This causes real bugs.
# Implicit: skips month 0 (January in 0-indexed systems)
if month:
process(month)
# Explicit: only skips None
if month is not None:
process(month)
I've seen this break a system where stock_level=0 was valid. The loop skipped out-of-stock items instead of processing them.
The same principle applies to mutable default arguments. Python evaluates defaults once at function definition time. Every call shares the same object. That's an implicit shared state bug.
# Implicit shared state: items accumulates across calls
def add_item(item, items=[]):
items.append(item)
return items
# Explicit: each call gets its own list
def add_item(item, items: list[str] | None = None):
if items is None:
items = []
items.append(item)
return items
Another example is *args and **kwargs. They allow you to write flexible APIs, but they also widen the interface of your functions, hiding the function's true dependencies. If your function needs user and db, ask for them explicitly (positional arguments or keyword arguments) instead of accepting **kwargs and pulling them out inside the function body.
Force keyword-only arguments with * in the function signature:
# Implicit: accepts any kwargs, hidden dependencies
def process(**kwargs):
user = kwargs["user"]
db = kwargs["db"]
...
# Can be called with unrelated `kwargs` that still pass the "user" and "db" checks
process(user=current_user, db=session, extra_arg=42)
# Explicit: clear dependencies, no surprises
def process(*, user, db):
...
# Must be called like:
process(user=current_user, db=session)
The caller will know exactly what to provide, and the function won't accidentally accept unrelated arguments that it ignores.
3. Simple is better than complex
We often think our code is simple until we see a cleaner way to write it. In Python, simple usually means readable, for example a list comprehension over a five-line loop, pathlib over string joins, an early return over a nested if. Concise isn't the same as terse; the goal is less cognitive load, not fewer lines.
If you need "and" to describe what a function does, it does too much.
# Complex: mixed concerns
def process_and_validate_and_save(data):
# 40 lines of mixed concerns with inline comments
# Simple: single-purpose functions
def validate(data): ...
def transform(data): ...
def save(data): ...
A useful smell: if you're writing inline comments like # validate input and # save to database inside a function, those comments are telling you where to extract. The comment becomes the function name.
The same applies to function signatures. If a function only needs user, don't pass the entire request. Wide interfaces hide dependencies and force tests to mock bigger objects.
# Complex: needs a full request mock to test
def is_chef(request):
return request.user.groups.filter(name="chef").exists()
# Simple: just needs a User object
def is_chef(user):
return user.groups.filter(name="chef").exists()
4. Complex is better than complicated
Complexity isn't the enemy, the wrong kind is. An airplane cockpit has dozens of controls, but each one is where you expect it. That's complex. Complicated is when you can't predict what touching one thing will do to another.
In The Pragmatic Programmer, the helicopter is featured as a classic analogy for a non-orthogonal, or highly coupled, system. Likewise, a non-orthogonal software system requires changes to one module to trigger unexpected changes throughout the code. That's complicated. Think of it this way: in an orthogonal system, a radio and a steering wheel are independent. Changing the volume doesn't change your direction. In a complicated system, they are tangled.
"Simple" code that requires you to hold six things in your head is actually complicated. A proper abstraction adds structure (complexity) but reduces cognitive load.
A good example is the repository pattern. A raw SQL query is simple, but it scatters database logic across the codebase. A repository abstracts that logic into one place. The abstraction adds complexity, but it's a complexity you can understand once and then forget about.
Now onto a practical example. I reviewed a CLI tool that parsed arguments by hand to "keep it simple":
def main():
has_flag = '-u' in sys.argv
has_numeric = any(arg.isdigit() for arg in sys.argv[1:])
has_action = any(arg in ['stats', 'dedup', 'sort'] for arg in sys.argv[1:])
if not has_action and (has_flag or has_numeric or len(sys.argv) == 1):
if len(sys.argv) <= 2 and (len(sys.argv) == 1 or sys.argv[1].isdigit()):
num_words = int(sys.argv[1]) if len(sys.argv) == 2 else 3
# ... more conditions
Three booleans, nested conditionals, index math. It's fragile, hard to read, and doesn't scale. argparse is more complex (a parser object, subcommands, argument definitions) but far less complicated:
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["stats", "dedup", "sort", "generate"])
parser.add_argument("-n", "--num", type=int, default=3)
parser.add_argument("-u", "--unique", action="store_true")
args = parser.parse_args()
match args.action:
case "generate":
run(args.num, unique_letters=args.unique)
case "stats":
manager.show_stats()
You get validation, type conversion, --help, and each action maps to one branch.
Typer takes this further: type hints become CLI argument definitions. It adds a bit more complexity with the @app.command() decorator and the function signatures, but it makes the code self-documenting and handles all the parsing, validation, and help text generation for you.
import typer
app = typer.Typer()
@app.command()
def generate(num: int = 3, unique: bool = False):
run(num, unique_letters=unique)
Typer is hard to beat for simple CLIs.
The same applies to simple data structures: developers avoid NamedTuple or dataclass because "it's just a dict", but then scatter access methods like .get() and handle its default None returns, unnecessary complexity. A good abstraction costs a few lines upfront and saves confusion on every access.
5. Flat is better than nested
Every level of nesting is a new thing the reader has to hold in their head. Three levels deep and you're tracking the function body, the loop, and the condition all at once. Flat code reads top-to-bottom like a story.
When your code starts to make an "arrow shape", readability drops. Three or more levels of nesting is a code smell. Guard clauses flatten it.
# Nested (arrow anti-pattern)
def process(data):
if data:
if data.is_valid():
if data.has_items():
return handle(data)
# Flat
def process(data):
if not data:
return
if not data.is_valid():
return
if not data.has_items():
return
return handle(data)
The happy path lives at the lowest indentation level. Guard clauses also prevent "falling through," where you detect an exit condition but forget to actually exit.
6. Sparse is better than dense
Packing too much onto one line, for example chained calls, nested comprehensions, multiple assignments, forces the reader to slow down and unpack it mentally. Whitespace and line breaks are free. A blank line between logical blocks, a variable to name an intermediate value, a single operation per line: these don't cost you anything except a few extra characters.
Dense code isn't clever. It's expensive for everyone who reads it after you.
# Dense
active_ids = [u.id for u in users if not u.deleted and u.role in ("admin","editor") and u.last_login > cutoff]
# Sparse
active_ids = [
u.id
for u in users
if not u.deleted
and u.role in ("admin", "editor")
and u.last_login > cutoff
]
Same logic. The second version lets your eyes land on each condition separately. You can scan it, spot a bug, review a change. The first makes you hold the whole thing in your head before you can evaluate any part of it.
The same principle applies to function bodies. No blank lines between unrelated operations forces the reader to infer structure that you could have just shown them.
A formatter like Ruff handles the obvious cases. But PEP 8-compliant code can still be dense; the formatter won't break up a logically overloaded line. If a line is doing too much, break it up even if it passes. Extract-method refactoring helps here: naming intermediate steps creates natural spacing and makes the logic easier to scan.
7. Readability counts
Python's syntax was designed to be read, not just executed. But syntax alone doesn't do the work, naming does. A variable called data or result tells you nothing. pending_invoices or validated_user tells you much more. This is the one Zen line that applies everywhere: naming, structure, length, abstraction level.
Raw tuples are positional landmines. result[0] tells you nothing. Swap two fields and the code still "works", just with wrong data.
I reviewed a race analytics app where the student built a list of (date, name) tuples from an API response:
# Unreadable: positional, no type safety
race_info = [(race.get('date'), race.get('raceName', {})) for race in races]
# caller: race_info[0][0] is the date? or the name?
A NamedTuple costs two lines and pays off on every access:
# Readable: attribute access, self-documenting
class Event(NamedTuple):
name: str
date: date
race_info = [
Event(name=race.get("raceName", ""), date=_convert_to_dt(race.get("date")))
for race in races
]
# caller: event.name, event.date, impossible to mix up
Swap two fields in the tuple version and the code still runs, just with wrong data. The NamedTuple version would require you to explicitly rename the arguments, so the mistake becomes visible.
Magic numbers and strings are the same problem. A literal 3 or "gpt-5-nano" buried in a function call is invisible context.
# Magic
time.sleep(3)
response = client.chat(model="gpt-5-nano")
# Readable
API_RATE_LIMIT = 3
DEFAULT_LLM_MODEL = "gpt-5-nano"
time.sleep(API_RATE_LIMIT)
response = client.chat(model=DEFAULT_LLM_MODEL)
8. Special cases aren't special enough to break the rules
Every exception you carve out of a rule adds a mental model the reader has to carry. One-off workarounds compound: today it's a hardcoded ID, next month it's a flag, next quarter it's an undocumented branch only one person knows about. When you feel the pull to handle "just this one case" differently, ask whether the right move is a clean abstraction that covers the general case.
Every special case is tech debt with interest.
I reviewed a SaaS app with three export views, PNG, interactive, and PPTX. Each view had ~50 lines of identical tier-gating logic inlined: check user tier, set watermark, clamp DPI, validate route styles. The developer treated each export format as its own world. But the authorization rules are the same regardless of output format. The rendering is special; the gating is not.
In the same codebase, the feature gate function returned True for unknown feature names:
def check_feature_gate(feature_name: str) -> tuple[bool, str | None]:
if feature_name not in FEATURE_GATES:
return True, None # unknown = allowed
A typo like "exprot_pdf" silently disables your entire paywall. The rule is: if you can't confirm it's allowed, it's not allowed. Unknown features aren't a special case that deserves leniency.
Another common pattern: inconsistent return types. From a race analytics app I reviewed:
# "special case": no race found, so return None, right?
def get_result_by_circuit_id(
session: Session, circuit_id: str
) -> Sequence[RaceResult] | None:
race = session.get(Race, circuit_id)
if race is None:
return # bare return = None
...
return race_results
The caller now has to handle None and an empty sequence as separate cases. But "no race found" and "race found, no results" are the same thing from the caller's perspective: nothing to iterate over. An empty list covers both:
# the rule: callers shouldn't have to guess
def get_result_by_circuit_id(
session: Session, circuit_id: str
) -> list[RaceResult]:
race = session.get(Race, circuit_id)
if race is None:
return []
...
return race_results
The special return never feels wrong in isolation; it only compounds when it becomes a pattern. If you find yourself returning a sentinel value, an out-of-band signal, or a type no caller expects, that's a special case looking for a justification.
9. Although practicality beats purity
The rules exist to serve the code, not the other way around. When constraints change, the right move is sometimes to abandon a principled design entirely. Not patch it, not work around it. The key is doing it deliberately, knowing which rule you're breaking and why.
This example is from my own code, not a student review. When the API behind my search tool went offline, I had a choice: add retries, fallbacks, stale-cache serving, or accept the new reality. I chose the latter.
The original design was correct: async HTTP, a disk-based cache with expiry, a CacheData struct to wrap timestamps and items. When the API stopped existing, all of that became overhead with no payoff.
// Pure: proper async fetch with disk cache
async fn fetch_items(endpoint: String, cache_duration: u64)
-> Result<Vec<Item>, Box<dyn std::error::Error>> {
if let Ok(items) = load_from_cache(cache_duration) {
return Ok(items);
}
let response = client.get(&endpoint).timeout(...).send().await?
.json::<Vec<Item>>().await?;
save_to_cache(&response)?;
Ok(response)
}// Practical: embed the data at compile time
const CONTENT_DATA: &str = include_str!("../data/content.json");
let items: Vec<Item> = serde_json::from_str(CONTENT_DATA)
.expect("Invalid embedded JSON");
main went from async fn main() -> Result<(), Box<dyn std::error::Error>> to fn main(). The async runtime, HTTP client, cache file, timestamp logic: gone. Updates now require a recompile, which is the right trade when there's no live API to call.
The principle: when the constraint that justified the abstraction disappears, remove the abstraction. Don't preserve complexity out of habit.
10. Errors should never pass silently
A silent failure is worse than a crash. A crash tells you where things went wrong. A silent failure lets the program keep running with corrupted state, and you find out three steps later, or you don't find out at all until a customer does. Bare except clauses, pass in error handlers, swallowed exceptions: these are the most common places silence hides.
Most codebases ignore this one.
# Silent failure
try:
data = response.json()
except Exception:
data = {}
You just swallowed a network error, a JSON decode error, and a server 500, and replaced them all with an empty dict. When something breaks in production, you won't know where to start looking.
# Fail fast
response.raise_for_status()
data = response.json()
The stack trace at the point of failure is a gift. It tells you exactly what went wrong and where. Don't throw that away with a blanket except.
Catch the exception you expect. Use logger.exception() to log the full stack trace.
try:
process(data)
except ValueError as e:
logger.exception(f"Value error processing data: {e}")
Narrow your try block to only the lines that can actually raise the exception you're catching. Wide try/except blocks make it impossible to tell which line raised.
A final tip: when you catch an exception, consider whether you can add context and re-raise it. The original stack trace is preserved, but now you have a clearer message about what the code was trying to do when it failed. For example:
try:
data = response.json()
except Exception as e:
raise RuntimeError("Failed to parse API response") from e
The standard library also silences errors by default in places you might not expect. zip() is one of them: when the iterables have different lengths, it silently truncates to the shortest. Since Python 3.10, you can opt into the failing version:
letters = ("a", "b")
numbers = (1, 2, 3)
list(zip(letters, numbers)) # [('a', 1), ('b', 2)] silent data loss
list(zip(letters, numbers, strict=True)) # ValueError: zip() argument 2 is longer
Without strict=True, a bug in the caller (wrong list, off-by-one) disappears into a shorter result. The error passes silently. strict=True is the obvious choice when the lengths should match.
11. Unless explicitly silenced
The previous rule has a deliberate companion. Errors should pass silently when you make that choice deliberately. There are legitimate reasons to suppress an error: a missing optional config file, a known third-party quirk, a no-op case that genuinely needs no action. The difference is intent. When you silence an error explicitly, for example except FileNotFoundError: pass with a comment explaining why, the next reader knows it was a choice, not an accident. The rule isn't "never suppress." It's "never suppress silently."
Silencing is a design decision, not laziness. The difference is intent and visibility.
Accidental silencing - a weather app I reviewed, saved favorites like this:
import json
def save_favorites(favorites: list[str]) -> None:
try:
FAV_PATH.write_text(json.dumps(favorites, indent=2))
except IOError:
pass
The user clicks "Save" and nothing happens. No error, no feedback. Their favorites are gone next session and they'll never know why. "No crash" is not the same as "handled."
Dangerous silencing - a database helper returned None for two very different situations:
def select_by_id(self, model: type[SQLModel], id: int) -> SQLModel | None:
try:
return session.get(model, id)
except OperationalError:
logger.error(...)
return None
except NoResultFound:
logger.warning(...)
return None
The caller can't distinguish "record doesn't exist" (normal) from "database crashed" (emergency). Both return None. The fix: return None for "not found," raise for "something broke." Your callers need to know when to retry, alert ops, or show a friendly message.
Intentional silencing - this is what "explicitly silenced" looks like:
condition_info = CONDITION_MAP.get(condition_code, {})
condition_text = condition_info.get("day", "Unknown")
The empty dict default is a deliberate choice: a missing weather condition code isn't an error, it means "show Unknown." The absence is expected (external API data is incomplete), the behavior is visible (user sees "Unknown"), and the default is explicit.
The same principle applies to linter suppressions. A # type: ignore or # noqa without a comment is accidental, future you won't know if it's still needed. A # type: ignore[arg-type] # API returns str, types say int is explicit.
12. In the face of ambiguity, refuse the temptation to guess
When the input could be a string or a list, when a field might be missing or might be None, when an API response has undocumented edge cases, the tempting move is to write something that "probably works." That guess becomes load-bearing code nobody touches. Ambiguous code that appears to work is a time bomb. Explicit validation that raises immediately is a feature.
When a tool fails, the temptation is to add a flag, skip a check, or suppress the warning. Resist it. The workaround masks the root cause and breaks again later.
# Guessing: just skip it
[tool.isort]
skip = ["problematic_module.py"]
# Refusing to guess: find the real issue
$ isort -c -v problematic_module.py
# Read the output, trace the config, find the real issue
# Fix: known_local_folder was misconfigured
Run the tool with verbose flags. Read the error. A proper fix takes 20 minutes. A workaround takes 5 minutes now and 2 hours later when it breaks something else.
This applies to AI coding tools too: when an LLM-generated fix doesn't work, don't keep prompting. Read the error, understand why it failed, then fix it yourself or give the AI better context.
13. There should be one-- and preferably only one --obvious way to do it
Python gives you lists, tuples, generators, and comprehensions. It gives you os.path, pathlib, and str.split("/"). When you pick one and stick with it across a codebase, reading becomes faster because there's less to decode. When every developer picks differently, the cognitive overhead compounds. Consistency isn't a style war, it's a gift to the reader.
The standard library has purpose-built tools for common patterns. Use them instead of forcing a general-purpose type to do a job it wasn't designed for.
# Multiple ways, none obvious: .get() on every access
points_by_driver: dict[int, int] = {}
for result in results:
points_by_driver[result.driver_id] = (
points_by_driver.get(result.driver_id, 0) + result.points
)
# One obvious way: use Counter for counting things
from collections import Counter
points_by_driver = Counter()
for result in results:
points_by_driver[result.driver_id] += result.points
Same principle for money: float loses precision silently. Decimal is the one obvious way.
# Bug: silent rounding errors
price = 19.99
tax = price * 0.07 # 1.3993000000000002
# One obvious way
from decimal import Decimal
price = Decimal("19.99")
tax = price * Decimal("0.07") # 1.3993 = exact
When the same validation logic appears in three views, that's three ways to do one thing. Extract it.
14. Although that way may not be obvious at first unless you're Dutch
I'm Dutch, and Guido's joke still lands. But the point underneath is real: idiomatic Python often isn't obvious to people coming from other languages. A C developer sees a for loop; a Pythonista reaches for enumerate. A Java developer writes a class for everything; a Pythonista uses a function or a dataclass. The obvious way in Python is learned, not innate. That's what code review and deliberate practice are for.
deque is the obvious way to do a queue, but most people reach for list.pop(0) first because they don't know about it yet.
# Obvious to you: O(n) per pop(0), 600x slower on 100k items
queue = list(range(100_000))
while queue:
item = queue.pop(0)
# Obvious once you know: O(1) per popleft()
from collections import deque
queue = deque(range(100_000))
while queue:
item = queue.popleft()
Draining 100,000 items: 21.6ms with list.pop(0), 0.034ms with deque.popleft(), a 600x difference. Task queues, BFS, event buffers: if it's FIFO, reach for deque. The standard library is full of these "obvious ways": Counter, NamedTuple, itertools, functools, operator. The Pythonic way is obvious, once you've seen it.
15. Now is better than never
Shipping imperfect code that works beats the perfect design you never finish. The refactor you've been meaning to do, the test coverage you'll "add later," the tech debt you'll address "next sprint". These all compound. In practice: write the test before it's too late, open the PR before it's perfect, raise the concern before it becomes a crisis.
The hardest part of testing is the setup. Once you have dependency overrides and fixtures, each new test is 5 lines. Invest now.
# The investment (once)
@pytest.fixture
def db():
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture
def client(db):
app.dependency_overrides[get_session] = lambda: db
yield TestClient(app)
app.dependency_overrides.clear()
# The payoff (every test after)
def test_create_item(client):
response = client.post("/items/", json={"name": "test"})
assert response.status_code == 201
Put these fixtures in conftest.py. Pytest discovers them automatically. I've seen developers go from "testing is painful" to "testing is trivial" in one week, the moment the fixture infrastructure clicks.
16. Although never is often better than right now
"Now is better than never" doesn't mean ship recklessly. A half-finished migration, an untested integration, a performance fix done under pressure, these things are more dangerous deployed than delayed. The tension between 15 and 16 is deliberate: good judgment means knowing which situation you're in. "We can always fix it later" and "we'll wait until it's perfect" are both ways to avoid making that call.
If your module reads files, calls APIs, or accesses environment variables at import time, pytest collection will crash when those resources aren't available.
# Right now: breaks pytest collection
PRIVATE_KEY = Path("privatekey.pem").read_text()
ENGINE = create_engine(os.environ["DATABASE_URL"])
# Never (at import time): defer to startup
def load_config():
return Settings(
private_key=Path("privatekey.pem").read_text(),
database_url=os.environ["DATABASE_URL"],
)
Keep module scope clean. Load config in a startup function, inject from there. The module scope bites people when the test runner imports all modules to discover tests, and import-time side effects cause collection to fail.
17. If the implementation is hard to explain, it's a bad idea
If you're struggling to describe what your function does in one sentence, that's a design signal. Long explanations usually mean mixed responsibilities; the function is doing too many things, or it's tangled up with things it shouldn't know about. This is one of the best heuristics for code review: ask the author to explain it. If the explanation is complicated, the code probably is too.
If you need a paragraph to explain what a function does, the function is too clever.
I reviewed this "default-setting" logic in a task manager API:
if not getattr(task_in, "priority", None):
task_in.priority = TaskPriority.medium
Three things compound here. getattr is unnecessary. task_in is a Pydantic model, so task_in.priority works directly. The schema already defines TaskPriority.medium as the default, so this service-layer override is redundant. And if not getattr(...) treats any falsy value as "missing". If priority is set to a value that happens to be falsy, the code silently overwrites the user's explicit choice. It reads as "set a default if missing" but behaves as "overwrite valid values that look falsy."
The fix: delete these lines entirely. Trust the schema defaults.
class TaskCreate(BaseModel):
priority: TaskPriority = TaskPriority.medium # already handles it
Another signal: type annotations that don't match the code.
The obvious miss: returning None without saying so:
def get_config(key: str) -> str:
return CONFIG.get(key) # dict.get() returns str | None, not str
The caller trusts the annotation, calls .upper(), and gets an AttributeError at runtime. The fix is one character: -> str | None.
The subtle miss: tuple[T] and tuple[T, ...] are not the same type:
def get_ids(users: list[User]) -> tuple[int]: # means exactly one int
return tuple(u.id for u in users) # should be tuple[int, ...]
tuple[int] is a fixed-length tuple containing a single integer. tuple[int, ...] is a variable-length tuple of integers. The type checker accepts both without complaint because neither is obviously wrong, but only one matches what the function actually returns.
18. If the implementation is easy to explain, it may be a good idea
"Easy to explain" is necessary but not sufficient. Plenty of bad decisions are easy to justify in the moment; the quick fix, the hardcoded value, the dependency copy-pasted from Stack Overflow. The "may" in this line matters. Ease of explanation is a green flag, not a guarantee. Pair it with: does it handle failure? Is it testable? Will it still make sense in six months?
Manually slicing timestamp strings with hardcoded indices is "stringly typed" programming. It works until someone changes the format, then it breaks silently.
# Hard to explain: what is [:4] and [5:7]?
month = f"{entry.timestamp[:4]}-{entry.timestamp[5:7]}"
# Easy to explain: parse to datetime, format when needed
month = entry.created_at.strftime("%Y-%m")
Parse into a real datetime at the boundary. Use .strftime() when you need a string.
The same principle applies to LLM output. Parsing raw text with regex is hard to explain because the failure modes are hard to reason about: what if the model rephrases, what if the match is case-sensitive, what does the caller do with None?
# Hard to explain: what happens when the regex doesn't match?
def extract_sentiment(text: str) -> str:
response = llm.complete(f"Analyse sentiment: {text}")
match = re.search(r'\b(positive|negative|neutral)\b', response, re.IGNORECASE)
return match.group(1).lower() if match else "unknown"
# Easy to explain: structured output, Pydantic validates the contract
class SentimentResult(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
confidence: float
def extract_sentiment(text: str) -> SentimentResult:
return llm.complete(f"Analyse sentiment: {text}", response_model=SentimentResult)
"Parse LLM output into a typed model" fits in one sentence. The regex version doesn't.
19. Namespaces are one honking great idea -- let's do more of those!
Namespaces are how Python keeps things from colliding. Packages, modules, classes, functions. Each creates a scope. The discipline of putting things where they belong, not at the top level of a module just because it's convenient, is what keeps large codebases navigable. from payments.stripe import create_charge tells a story. from utils import create_charge does not.
Three tips in one, all about scope boundaries.
Don't shadow built-in names. Using object, type, list, or id as variable names shadows the built-in. It works until the one time you need the original.
# Shadows builtins
object = f"{year}/{month}.zip"
type = "incremental"
id = row["user_id"]
# Namespaced
object_path = f"{year}/{month}.zip"
sync_type = "incremental"
user_id = row["user_id"]
Don't mutate data from outside your scope. When you append to a list that was passed in, you modify the caller's data. This is a silent side effect.
# Mutates caller's list
def add_defaults(items):
items.append("default")
return items
# Respects the boundary
def add_defaults(items):
return [*items, "default"]
Inject dependencies: don't hardcode module-level state. Module-level mutable objects are singletons. Every request and every test shares them.
# Global state: shared by everything
engine = create_engine("sqlite:///app.db")
# Injected: testable, swappable
def get_engine():
return create_engine(settings.database_url)
Centralize config in one place (pydantic_settings.BaseSettings), validate once at load time, inject everywhere else.
Keep going
Want this kind of review on your own code? That's what my coaching is built around: real projects, weekly pull requests, and feedback that explains the why.
For more Python tips, browse my collection on GitHub. If you use Claude Code, you can also pull my tips directly into it via MCP.