How to Write Clean Python Code: A Practical Guide
Concrete, non-obvious rules for writing clean, readable Python — naming, structure, and the habits that actually matter
“Clean code” gets treated as a matter of taste, but most of it is mechanical — a handful of concrete habits that make code easier to read six months later. This isn’t a PEP 8 summary; it’s the stuff style guides don’t tell you.
Follow PEP 8, but don’t stop there
PEP 8 covers formatting — line length, whitespace, naming conventions. Run Ruff or Black and this is solved automatically; it’s not worth arguing about by hand. Clean code is a different, harder problem: it’s about structure and intent, not whitespace.
Name things for what they do, not what they are
data, temp, result, obj — these tell a reader nothing. A function called get_active_users is self-documenting; a function called process is not, no matter how good the docstring is. If you can’t name a variable clearly, that’s often a sign the function is doing too much and should be split.
Functions should do one thing
The classic rule, but the practical test is: can you name the function without using “and”? validate_and_save_user is two functions pretending to be one. Splitting it makes each half independently testable and reusable — and it’s usually the difference between a 200-line function nobody wants to touch and two 20-line functions anyone can review in a minute.
Prefer early returns over nested conditionals
# Harder to follow
def process(user):
if user is not None:
if user.is_active:
if user.has_permission:
return do_thing(user)
return None
# Easier to follow
def process(user):
if user is None:
return None
if not user.is_active:
return None
if not user.has_permission:
return None
return do_thing(user)
Each guard clause eliminates a case and moves on — no need to hold four levels of nesting in your head to understand the happy path.
Use type hints, even in scripts
Type hints aren’t just for large codebases. They make function signatures self-documenting and let tools like mypy catch real bugs before runtime — a wrong argument type, a None that wasn’t handled. On any function more complex than a one-liner, hints pay for themselves the first time someone else (or future you) has to call it without re-reading the implementation.
Avoid deep inheritance hierarchies
Python makes multiple inheritance easy, which is exactly why it’s easy to misuse. Prefer composition — pass in the behaviour you need as an object or function, rather than inheriting from three base classes to get it. If you find yourself checking isinstance() to work around inheritance, that’s usually the signal to switch to composition.
Replace magic numbers with names
# Why 3?
if retries > 3:
give_up()
# Clear
MAX_RETRIES = 3
if retries > MAX_RETRIES:
give_up()
A bare number or string tells the reader nothing about why it matters, and if it appears in two places you have to find and change both. A named constant, or an Enum for a fixed set of options, documents the intent and gives you one place to edit.
Comment the why, not the what
# increment i repeats the code. A good comment explains something the code cannot: a workaround, a constraint, a non-obvious reason. If you feel the need to explain what a block does, that is usually a sign it should be a well-named function instead.
Catch the exception you expect, not every exception
# Hides real bugs
try:
save(user)
except Exception:
pass
# Handles the failure you actually planned for
try:
save(user)
except DuplicateUserError:
return existing_user(user.email)
Catching everything and carrying on turns a loud, fixable bug into silent wrong behaviour. Name the specific exception you know how to handle and let everything else surface.
Use dataclasses for structured data, not loose dicts
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
A dictionary or tuple with an implied shape makes every reader guess the field names. A dataclass gives the shape a name, makes typos fail loudly, and works with your editor’s autocomplete. If the data comes from outside your program and needs validating, see dataclasses vs Pydantic vs attrs.
Write the test before you trust the function
Not full TDD dogma — just: if a function has a non-obvious edge case (empty input, None, a boundary value), write a pytest case for it before moving on. This catches more real bugs than code review does, because it forces you to actually think through the edge case rather than eyeball the code.
Further reading
For a book-length treatment, Clean Code in Python applies these ideas specifically to Python idioms rather than the generic (often Java-flavoured) advice most “clean code” content is written for. Effective Python is the other book worth reading once these habits are second nature — it goes further into Pythonic idioms specifically.
Frequently Asked Questions
What’s the difference between PEP 8 and clean code?
PEP 8 covers formatting — line length, whitespace, naming conventions — and tools like Ruff or Black solve it automatically. Clean code is a harder, different problem about structure and intent, not whitespace, and isn’t something a formatter can fix for you.
What’s the single most useful clean-code habit in Python?
Naming things for what they do, not what they are. A function called get_active_users is self-documenting; one called process or data tells a reader nothing, no matter how good the docstring is.
How long should a Python function be?
PEP 8 sets no hard limit. A useful test is whether you can name the function without using the word and, and read it without scrolling. If not, it is probably doing more than one job.
Should you use type hints even in small scripts?
For a genuinely throwaway script, skip them. For anything that will be read or called more than once — public APIs, shared utility functions — type hints pay off fast, making signatures self-documenting and letting mypy catch real bugs before runtime.