Writing Clean Python: Names, Functions, and Docstrings

Code that future-you can read without solving a puzzle

Advanced 14 min

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, not data or x.
  • Functions are verbs: send_reminder(), not reminder().
  • Booleans read as questions: is_overdue, has_permission.
  • Collections are plural: customers holds many, customer holds 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.

Example
# 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))
Output
73.0
Nothing changed but the names — and the second version explains itself.

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.

Example
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)))
Output
3 people
  ada    36
  rae    29
  sam    41
Parse one row, parse many rows, format the result.

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))

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.

Example
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])
Output
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).
The docstring says what; the comment says why the 6 is there.

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_case for variables and functions, CapWords for classes, UPPER_SNAKE for constants.
  • Spaces around operators and after commas: total = price * 2, not total=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.

Example
$ 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.
One command formats it; another finds two real problems.

Common mistake: Comments that repeat the code

Why it happens:

Adding a comment feels responsible, and describing the line is the easiest comment to write.

How to fix it:

# 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

Why it happens:

They are quick to type and the meaning is obvious while you are writing it.

How to fix 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

Why it happens:

Compressing four lines into one feels like skill.

How to fix it:

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

Why it happens:

Once you start noticing problems, they all look fixable in one pass.

How to fix it:

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

Why it happens:

Style is visible and easy to have opinions about.

How to fix it:

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?

When is a comment genuinely useful?

How do you know a function should be split?

What is the difference between a docstring and a comment?

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))

What to learn next

You practiced the things a tool cannot do for you: names that say what a value is, functions whose honest name needs no “and”, comments that record a reason rather than repeat the code, and docstrings that stay available at runtime. Then you handed the rest to ruff, which ends the spacing discussion permanently and finds real bugs while it is there.

Your project is now readable, tested and checked. The last thing standing between it and other people is installation. Packaging turns the script into a command anyone can pip install and run by name.