Writing Clean Python: Names, Functions, and Docstrings
Code that future-you can read without solving a puzzle
In this lesson
Code is read far more often than it is written, and the person reading it most is you in three months with no memory of writing it. Everything in this lesson is aimed at that reader.
This is not about being tidy for its own sake. Unclear code hides bugs, and every rule here exists because it makes a real category of mistake easier to see.
Explain it like I’m 5
Clean code is code you can understand at reading speed, without stopping to work out what it does.
Names do most of the work
A good name removes the need for a comment. It should answer what is in here or what does this do, in the words of the problem rather than the words of the code.
- Variables are nouns:
unpaid_invoices, notdataorx. - Functions are verbs:
send_reminder(), notreminder(). - Booleans read as questions:
is_overdue,has_permission. - Collections are plural:
customersholds many,customerholds one.
Single letters are fine in exactly two places: a throwaway loop counter, and standard math notation. Everywhere else they force the reader to hold a translation table in their head.
# Before: correct, and unreadable.
def p(d, r):
t = 0
for i in d:
if i[2] > r:
t += i[1] * i[2]
return t
# After: same logic, no comments needed.
def total_value_above(orders, minimum_quantity):
total = 0
for name, price, quantity in orders:
if quantity > minimum_quantity:
total += price * quantity
return total
orders = [("widget", 2.50, 10), ("gizmo", 9.99, 2), ("doodad", 1.20, 40)]
print(total_value_above(orders, 5))
73.0
One function, one job
A function that fetches data, transforms it, and prints a report is three functions wearing a coat. Split it and each piece becomes testable, reusable, and possible to name.
The most useful test: if the honest name needs an “and” in it, split it. load_and_clean_and_save is telling you exactly where the seams are.
Length is a symptom rather than a rule. There is no magic number, but a function that does not fit on one screen usually has a smaller function hiding inside it, and the piece worth extracting is normally the innermost block, the one indented three levels deep.
ROWS = ["ada,36", "sam,41", "bad line", "rae,29"]
# One job each. Each of these three can be tested on its own.
def parse_row(row):
"""'ada,36' -> ('ada', 36), or None if the row is malformed."""
parts = row.split(",")
if len(parts) != 2 or not parts[1].strip().isdigit():
return None
return parts[0].strip(), int(parts[1])
def parse_all(rows):
"""Every row that parsed, skipping the ones that did not."""
return [parsed for parsed in map(parse_row, rows) if parsed is not None]
def format_report(people):
"""A sorted, human-readable summary."""
lines = [f" {name:6s} {age}" for name, age in sorted(people)]
return f"{len(people)} people\n" + "\n".join(lines)
print(format_report(parse_all(ROWS)))
3 people ada 36 rae 29 sam 41
Refactor a function that is doing three things at once. Pull the price calculation out into line_total(item) and the formatting out into format_line(item), then let receipt(items) just join the pieces together. The output must not change.
ITEMS = [
{"name": "coffee", "price": 2.50, "qty": 2},
{"name": "bagel", "price": 1.80, "qty": 3},
{"name": "juice", "price": 3.00, "qty": 1},
]
def line_total(item):
return 0.0 # TODO: price times quantity
def format_line(item):
return "" # TODO: "coffee x2 5.00"
def receipt(items):
return "" # TODO: every line, then "TOTAL" and the grand total
print(receipt(ITEMS))
line_total is one expression: item["price"] * item["qty"]. For the layout, f"{name:10s}" pads a name to ten characters and f"{value:5.2f}" right-aligns money in five. Build receipt from a list of formatted lines plus a final total line, and join with "\n".
ITEMS = [
{"name": "coffee", "price": 2.50, "qty": 2},
{"name": "bagel", "price": 1.80, "qty": 3},
{"name": "juice", "price": 3.00, "qty": 1},
]
def line_total(item):
return item["price"] * item["qty"]
def format_line(item):
return f'{item["name"]:10s} x{item["qty"]} {line_total(item):5.2f}'
def receipt(items):
lines = [format_line(item) for item in items]
grand_total = sum(line_total(item) for item in items)
lines.append(f'{"TOTAL":10s} {grand_total:5.2f}')
return "\n".join(lines)
print(receipt(ITEMS))
coffee x2 5.00
bagel x3 5.40
juice x1 3.00
TOTAL 13.40
Comments explain why; docstrings explain what
A comment that restates the code is worse than no comment, because it is another thing to keep in step and it will eventually lie. Comments earn their place when they record something the code cannot say: a reason, a constraint, a decision.
A docstring is different. It is the triple-quoted string on the first line of a function, and it is not a comment: Python keeps it, help() prints it, and your editor shows it as you type the call.
For a small function, one line is plenty: say what it returns, not how. Save the longer form for when the arguments genuinely need explaining.
import time
def retry_delay(attempt):
"""Seconds to wait before retry number `attempt` (1-based).
Doubles each time and stops at 30s, so a long outage does not turn
into an hour-long sleep.
"""
# The API rate-limits at 10 requests/minute, so never retry
# faster than 6 seconds however early the attempt is.
return min(30, max(6, 2 ** attempt))
for attempt in range(1, 7):
print(f"attempt {attempt}: wait {retry_delay(attempt)}s")
print(retry_delay.__doc__.splitlines()[0])
attempt 1: wait 6s attempt 2: wait 6s attempt 3: wait 8s attempt 4: wait 16s attempt 5: wait 30s attempt 6: wait 30s Seconds to wait before retry number `attempt` (1-based).
PEP 8, and letting a tool do it
PEP 8 is Python's official style guide. The parts that matter day to day are few:
- Four spaces per indent, never tabs.
lower_snake_casefor variables and functions,CapWordsfor classes,UPPER_SNAKEfor constants.- Spaces around operators and after commas:
total = price * 2, nottotal=price*2. - Two blank lines between top-level functions, one between methods.
- Imports at the top, one per line, standard library first.
Then stop thinking about it and install a formatter. Black or Ruff reformats a whole file on save, which ends every discussion about spacing permanently. A linter (Ruff again, or Flake8) goes further and flags unused imports, shadowed names, and undefined variables — real bugs, found for free.
$ pip install ruff
$ ruff format report.py
1 file reformatted
$ ruff check --output-format=concise report.py
report.py:2:8: F401 [*] `os` imported but unused
report.py:14:9: F841 Local variable `rows` is assigned to but never used
Found 2 errors.
[*] 1 fixable with the `--fix` option.
Common mistake: Comments that repeat the code
Adding a comment feels responsible, and describing the line is the easiest comment to write.
# add 1 to count above count += 1 is noise that will eventually contradict the code. Comment the reason, not the mechanism.
Common mistake: One-letter names outside a short loop
They are quick to type and the meaning is obvious while you are writing it.
It is not obvious a month later. Name the thing after what it holds; editors autocomplete, so long names cost nothing.
Common mistake: Clever one-liners nobody can read
Compressing four lines into one feels like skill.
A nested comprehension with two conditions and a ternary is a puzzle. If it takes a minute to read, write the loop.
Common mistake: Refactoring everything at once
Once you start noticing problems, they all look fixable in one pass.
Change one thing, run the tests, commit. Otherwise a behavior change hides among fifty cosmetic ones and review becomes impossible.
Common mistake: Treating style rules as a moral position
Style is visible and easy to have opinions about.
Install a formatter, accept its output, and spend the energy on naming and structure instead. Those are the parts a tool cannot fix.
What should a good variable name reveal?
unpaid_invoices tells the reader what it is. invoice_list only repeats what the brackets already show.
When is a comment genuinely useful?
Why the number is 6, why the order matters, why the obvious approach was rejected: none of that is visible in the code.
How do you know a function should be split?
Length is only a hint. Doing two things is the actual problem, and the name is where it shows.
What is the difference between a docstring and a comment?
Python keeps docstrings as data, which is why editors and help() can show them. Comments are discarded entirely.
Mini exercise (medium)
Take a working but unreadable function and clean it up without changing what it does. Rename everything to say what it means, extract the eligibility rule into a well-named helper, and add a one-line docstring to each. The output must stay identical. That is what makes it a refactor rather than a rewrite.
Try it yourself. Complete the snippet and hit Run; it executes in your browser, no setup required.
CUSTOMERS = [("ada", 12, 3), ("sam", 4, 5), ("rae", 9, 1), ("kit", 20, 2)]
# The original. It works. Nobody can read it.
#
# def f(c, n):
# r = []
# for x in c:
# if x[1] >= n and x[2] > 1:
# r.append(x[0])
# return r
#
# Each customer is (name, orders, years_subscribed).
def is_eligible(customer, minimum_orders):
return False # TODO: enough orders AND more than a year subscribed
def eligible_names(customers, minimum_orders):
return [] # TODO: the names of everyone who qualifies
print(eligible_names(CUSTOMERS, 5))
c is a list of customers, n is a minimum order count, and x[1] and x[2] are the order count and the years subscribed. The condition x[1] >= n and x[2] > 1 is the eligibility rule and belongs in its own function.
CUSTOMERS = [("ada", 12, 3), ("sam", 4, 5), ("rae", 9, 1), ("kit", 20, 2)]
def is_eligible(customer, minimum_orders):
"""True when a customer has enough orders and over a year of subscription."""
name, orders, years_subscribed = customer
return orders >= minimum_orders and years_subscribed > 1
def eligible_names(customers, minimum_orders):
"""The names of every customer who qualifies for the loyalty discount."""
return [customer[0] for customer in customers
if is_eligible(customer, minimum_orders)]
print(eligible_names(CUSTOMERS, 5))
['ada', 'kit']
assert eligible_names(CUSTOMERS, 5) == ["ada", "kit"], "ada and kit have 5+ orders and more than a year"
assert eligible_names(CUSTOMERS, 100) == [], "an impossible minimum should qualify nobody"
assert is_eligible(("zed", 5, 2), 5) is True, "exactly the minimum counts as enough (>=)"
assert is_eligible(("zed", 5, 1), 5) is False, "one year is not MORE than one year"
assert is_eligible(("zed", 4, 9), 5) is False, "too few orders fails however long they have subscribed"
assert is_eligible.__doc__, "give is_eligible a one-line docstring saying what it returns"
assert eligible_names.__doc__, "give eligible_names a one-line docstring too"
print("✓ Looks good!")