“Clean code” conversations often spiral into style wars. For Python services that will be edited by many hands, the goal is simpler: make correct changes easy and dangerous changes visible. The practices below focus on maintainability in real repos, not textbook purity.
Start with readability constraints Python already gives you
PEP 8 is a floor, not a ceiling. Type hints, docstrings where behavior is non-obvious, and consistent project layout (src package, tests beside or under tests/) reduce onboarding time. Prefer explicit over clever: a junior maintainer should predict where configuration, I/O, and domain logic live.
Names that encode intent
Functions should read like verbs (calculate_invoice_total), booleans like questions (is_subscription_active), and modules like nouns (billing_rules). Avoid abbreviations that only locals understand. When a name needs a comment, rename first.
Bad:
def proc(d):
...
Better:
def normalize_address(raw: dict[str, str]) -> Address:
...
Functions should do one thing at one level of abstraction
Mixing SQL, business rules, and HTTP response shaping in one function guarantees regressions. A useful rule: if you need section comments inside a function, split it.
Pattern:
def handle_checkout(request: CheckoutRequest) -> CheckoutResponse:
order = build_order(request.cart, request.customer)
charge = payment_gateway.charge(order.total, request.payment_method)
persist_order(order, charge.id)
return CheckoutResponse(order_id=order.id)
Each helper stays testable without booting a web framework.
Errors are part of the API
Bare except: hides failures. Catch specific exceptions, add context, and let unknown errors propagate to your observability stack.
try:
payload = client.fetch(id)
except TimeoutError as exc:
raise InventoryUnavailable(id) from exc
Use custom exception classes for domain failures callers can handle (InsufficientStock), not string matching on messages.
Data structures before patterns
Python’s dataclasses, enums, and TypedDict reduce primitive obsession. Reach for design patterns when you feel pain, not preemptively. A simple Protocol often replaces a factory hierarchy.
Tests that protect behavior, not lines
Prioritize tests around:
- Pricing and permission rules
- Serialization boundaries (API ↔ domain)
- Regressions you have already fixed once
Use pytest fixtures for common setup; parametrize edge cases (0 items, expired coupon). Snapshot entire HTTP responses only when schemas are stable—otherwise assert fields that matter.
Dependency injection without frameworks
Pass collaborators as constructor arguments or function parameters:
class InvoiceService:
def __init__(self, repo: InvoiceRepository, clock: Clock):
self._repo = repo
self._clock = clock
Tests supply fakes; production wires real implementations in one composition root.
Logging and observability
Log structured fields (order_id, user_id) at boundaries. Debug logs inside tight loops belong behind sampling. Correlate requests with a trace id propagated from your HTTP middleware.
Performance without sacrificing clarity
Measure before micro-optimizing. If hotspots appear:
- Push heavy work to background tasks
- Use
__slots__or arrays only when profiling proves need - Prefer vectorized libraries in data paths, keep orchestration in plain Python
Readability losses need a benchmark receipt.
Documentation that earns its keep
Module-level docstrings explain invariants (“amounts are minor units”). README sections cover how to run migrations and seeds. ADRs (Architecture Decision Records) capture why you chose event sourcing, Celery, or a specific database—future you will forget.
Code review checklist (team-friendly)
| Question | Pass criteria |
|---|---|
| Can I run tests locally in one command? | Documented |
| Are types accurate on public functions? | mypy/pyright clean |
| Does this change need a migration note? | Included |
| Are errors user-safe and operator-informative? | Both paths tested |
FAQ
Are list comprehensions unclean?
Use them when they fit on one line mentally. Nested comprehensions with side effects are a smell—use loops.
How long should files be?
No magic number. Split when concepts diverge or imports become circular.
Black vs manual formatting?
Pick an autoformatter and stop debating commas in review.
When to refactor?
After making behavior safe with tests, not before speculative abstraction.
Closing habit
Before opening a pull request, read the diff as if you are on call at 2 a.m. If you cannot trace failure modes from the diff alone, add logging, tests, or rename until you can. Clean Python code is the code your team can change quickly without fear—that is the metric that survives dogma debates.
Modules, packages, and import hygiene
Keep domain rules out of views.py or route handlers. A practical layout for FastAPI or Django services:
app/
domain/ # pure Python, no framework imports
services/ # orchestration
adapters/ # DB, HTTP clients
api/ # request/response mapping
Circular imports signal missing boundaries—extract shared types to a small domain/types module. Avoid from package import *; explicit imports make refactors grep-friendly.
Typing and static analysis as guardrails
Gradual typing catches None-related bugs before runtime. Enable pyright or mypy in CI on packages that handle money, permissions, or PII. Use NewType for identifiers (UserId, OrderId) so you cannot pass the wrong id accidentally. When types lie (dynamic JSON blobs), wrap them in parsing functions that validate once at the boundary.
Working with legacy code
When you cannot rewrite, use the “sprout” method: write new behavior in tested functions, call them from old paths, and delete duplication when coverage allows. Boy scout rule—leave functions shorter than you found them—but avoid drive-by refactors unrelated to the ticket.
Collaboration habits that keep code clean
Short design notes in pull requests (“why not a dict here?”) age better than oral tradition. Pair on tricky refactors so knowledge transfers. When onboarding, ask newcomers to fix one small readability issue in their first week—fresh eyes spot confusing names veterans no longer notice.
Configuration and secrets
Keep secrets out of source control and out of function defaults. Use environment-specific settings objects validated at startup so misconfiguration fails fast during deploy rather than mid-request. Feature flags belong near integration boundaries, not sprinkled inside domain calculations.
Async code and readability
Async Python rewards explicit boundaries: avoid hidden IO inside property getters, document which functions perform network calls, and time out external dependencies. For Celery or RQ workers, keep task bodies thin—call into domain services that are also used by HTTP handlers so behavior cannot diverge.
Docstrings versus comments
Docstrings should describe contracts (“raises InsufficientStock when quantity exceeds available”). Inline comments explain non-obvious business rules (“promo stacking disallowed per finance policy 2024-03”). Delete comments that restate code; they rot faster than the code itself.
Formatting and review etiquette
Automate formatting so review focuses on behavior. Ask reviewers to flag naming and missing tests before micro-optimizations. When you disagree on style, defer to the linter config the repo already adopted—debates belong in a team agreement doc, not in every pull request thread.
When performance work touches readable code, leave a one-line comment linking to the benchmark or ticket. Future maintainers should know the optimization was measured, not habitual.
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…