Docstrings, type hints and comments that say why
Most comments are a smell. A few are the most valuable lines in a file. Knowing which you are writing is the whole skill — and Python gives you a third tool, type hints, that removes the need for a lot of what people write comments about.
The rule
Code says what. Comments say why.
A comment restating the code is noise that will go stale and start lying. A comment explaining a decision the code cannot express is the only place that information exists anywhere.
i += 1 # increment i
expenses.append(entry) # add the entry to the list
Two comments, zero information, and they cost a reader attention.
# Stored as paise, never rupees. 0.1 + 0.2 is 0.30000000000000004 in any
# language with floats, and a tracker that is out by a paisa per row is a
# tracker nobody trusts.
amount_paise: int
That one is worth more than the line it describes. Nothing in amount_paise: int
says why it is not a float, and without the comment the next person
"simplifies" it.
The comments worth writing
Why this and not the obvious alternative.
# dict, not a list of pairs: this is looked up per row in the report loop, and
# a linear scan there made a 5,000-row report take four seconds.
self._by_category: dict[str, list[Expense]] = {}
A warning about consequences.
# Written to a temp file and renamed, so an interrupted save cannot leave a
# half-written CSV where the real one was.
Something non-obvious about the domain.
# A recurring expense is counted once per month even if the date falls on the
# 31st and the month has 30 days — the shop bills monthly, not per occurrence.
A deliberate limitation, so the next person does not think it is a bug.
# Only the last 12 months are kept. Older rows are dropped on save, and nobody
# has asked for history yet.
The comments to delete
Restatements. Anything that would change with a rename.
Commented-out code. Git remembers. A dead block raises a question — is this coming back? does it still work? — and answers nothing.
Change logs in the file. git log and git blame do it properly and never
drift.
# Modified by Rahul 12/03 - added category
# Modified by Priya 18/03 - fixed bug
Divider art. If a file needs signposts to navigate, it is too big.
TODO with no name and no date. A TODO from 2019 with no author is not a
task, it is litter. Do it, or write a ticket and reference it.
Type hints do a comment's job, checkably
This is the Python-specific part, and it is the reason Python needs fewer comments than it used to.
def expenses_in(month: str) -> list[Expense]:
...
That signature answers what "month" is and what comes back, in a form a tool can check and that cannot go stale the way a docstring can — because Ruff and mypy will complain when it stops being true.
def total_paise(expenses: list[Expense]) -> int: ...
def find(entry_id: str) -> Expense | None: ...
def save(entries: list[Expense], path: Path) -> None: ...
Expense | None is the important one. It says, in the signature, that this can
return nothing — which is the question every caller has, and the alternative is
every caller writing a defensive check they may not need.
Hints are optional and are not enforced at runtime. They are documentation that a type checker reads. Use them on anything public; skip them on a three-line local helper if they add more noise than meaning.
Docstrings
A docstring documents a contract for somebody who will never read the body.
It is also help() and the tooltip in every editor.
def bill_paise(customer: str, tiffins: int) -> int:
"""The bill for one customer, in paise.
The customer's name must match exactly as recorded on deliveries;
matching is case-sensitive.
Args:
customer: the subscriber's name.
tiffins: how many were delivered. Must be positive.
Returns:
The amount in paise. Never negative.
Raises:
KeyError: if no subscriber is registered with that name.
ValueError: if tiffins is not positive.
"""
What makes that useful is everything the signature cannot say: the match is exact and case-sensitive, the unit is paise, an unknown name raises rather than returning zero.
Compare:
def bill_paise(customer: str, tiffins: int) -> int:
"""Bills the paise.
Args:
customer: the customer.
tiffins: the tiffins.
Returns:
the paise.
"""
That is the signature retyped. It satisfies a "has docstring" check and helps nobody — which is exactly what happens when a team mandates docstring coverage as a number.
The conventions
PEP 257 gives the rules:
- Triple double quotes, always, even for one line.
- One-line docstring: a sentence in the imperative — "Return the total", not "Returns the total" or "This function returns the total".
- Multi-line: a one-line summary, a blank line, then the detail.
- The closing
"""on its own line for multi-line docstrings.
Three styles for the detail — Google (above), NumPy, and reStructuredText. Pick one and use it everywhere. Google style is the most readable as plain text, which is how most people meet it.
What to document
- Every public module, class and function. Anything somebody imports.
- Not every private helper.
def _normalise(text)with a good name and a type hint needs nothing. - Not a restatement of the name.
def save(): """Save."""is noise.
The __init__ docstring convention is worth knowing: document the class in the
class docstring, and use __init__'s for the parameters — or put everything in
the class docstring, which most projects now do.
Self-documenting code is not an excuse
"Good code needs no comments" is half true. Good code removes the need for comments that explain what. It does nothing about why.
No amount of renaming communicates "we tried the obvious dict comprehension here and it used 2GB on a large file". That fact exists nowhere except in a comment, in somebody's memory, or in an incident report nobody will find.
The test: if the next person would be tempted to simplify this and reintroduce a bug, write the comment.
Check your work
The rule: code says what, comments say why.
Why a restating comment is worse than none: it costs attention and goes stale, and a stale comment is a lie.
What type hints replace: the comments that used to say what a parameter is and what comes back — checkably, so they cannot rot silently.
Why Expense | None matters: it answers the question every caller has.
What a docstring is for: a contract for somebody who will never read the
body, surfaced by help() and every editor.
What makes one useful: everything the signature cannot say — units, constraints, what raises, whether matching is exact.
PEP 257 essentials: triple double quotes, imperative one-liner, summary then blank line then detail.
What not to document: private helpers with good names, and anything that restates the name.
The limit of self-documenting code: renaming cannot express "the obvious version used 2GB".
Practice
- Find a comment in your capstone restating its code. Delete it and check nothing was lost.
- Find code that looks odd and is deliberate. Write the why-comment.
- Add type hints to every public function in one module. Run
mypyor Ruff and see what it finds. - Find a function that can return
Noneand say so in its hint. - Write a docstring for one public function including a constraint the signature cannot express.
- Write a deliberately useless docstring that retypes the signature. Compare.
- Run
help()on one of your modules and read what a user would see. - Search your project for commented-out code and delete it. Note the reluctance.
- Find a
TODOwith no name or date and decide its fate. - Write the comment you wish had been in the last confusing code you read.
Next: asking forgiveness, and failing fast.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship