Comments, Docstrings, and the Style That Makes Python Readable
1 · The lesson
readPython's tagline is "readability counts". The community even has an official style guide — PEP 8 — that almost every Python codebase follows. Learning the conventions early makes your code feel "Pythonic" instead of "Python-shaped".
This is the shortest lesson in the path, but skipping it is one of the most common reasons beginner code looks beginner.
1. Comments — the WHY, Not the WHAT
A comment starts with #. Everything after it on that line is ignored by Python.
# This is a comment x = 5 # this is an inline comment # Block comments can span # multiple lines like this
The rookie habit is to comment what the code does:
# BAD — the code already tells us this x = x + 1 # increment x by 1
The pro habit is to comment why the code does what it does:
# GOOD — explains intent that isn't visible from the code x = x + 1 # skip the header row before reading data
If a comment only restates the code, delete it. Names should do the work.
2. Docstrings — Comments That Tools Can Read
A docstring is a string literal placed as the first statement of a module, function, class, or method. Python keeps it accessible at runtime as obj.__doc__. Tools like help(), IDEs, and documentation generators all read them.
def calculate_tip(amount, percent=15): """Calculate the tip on a bill amount. Args: amount: The bill amount in dollars. percent: Tip percentage (default 15). Returns: The tip as a float. """ return amount * percent / 100 # The docstring is now discoverable: print(calculate_tip.__doc__) help(calculate_tip) # opens the full doc page
Use triple double-quotes """...""" for docstrings (PEP 257 convention). Single-line docstrings sit on one line:
def square(x): """Return x squared.""" return x * x
3. PEP 8 — The Style Rules You'll Actually Use
PEP 8 is 30 pages long; here's the 90% you need:
Indentation: 4 spaces, never tabs
def greet(name): if name: # 4 spaces in print(f"Hi, {name}") # 4 more (8 total)
Lines under ~80 characters
If a line is getting long, break it:
# OK — fits comfortably total = item_price * quantity + tax + shipping # Too long — break it total = ( item_price * quantity + tax + shipping_for(destination) )
setup added so this can run · defines shipping, tax, shipping_for, destination, item_price, quantity
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) shipping = _AutoMock('shipping') tax = _AutoMock('tax') def shipping_for(*_a, **_kw): print('-> shipping_for() called') return _AutoMock('shipping_for()') destination = _AutoMock('destination') item_price = _AutoMock('item_price') quantity = _AutoMock('quantity')
Naming conventions
my_variable = 1 # snake_case for variables and functions def calculate_total(): # snake_case for functions too pass class UserAccount: # PascalCase for classes pass MAX_RETRIES = 5 # SCREAMING_SNAKE for constants _private_thing = "x" # leading underscore = "internal, don't touch"
Spaces around operators and after commas
# GOOD x = 1 + 2 fn(a, b, c) # UGLY x=1+2 fn(a,b,c)
setup added so this can run · defines fn, a, b, c
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def fn(*_a, **_kw): print('-> fn() called') return _AutoMock('fn()') a = _AutoMock('a') b = _AutoMock('b') c = _AutoMock('c')
Blank lines for breathing room
- 2 blank lines between top-level functions/classes
- 1 blank line between methods inside a class
- 1 blank line inside functions to separate logical chunks
4. Imports — Where and How
PEP 8 has a strong convention for imports: at the top of the file, in three groups separated by a blank line.
# 1. Standard library import os import sys from pathlib import Path # 2. Third-party packages import requests import pandas as pd # 3. Your own modules from myapp.config import settings from myapp.utils import helpers
One import per line for import x; from x import a, b, c is fine on one line.
# GOOD import os import sys # BAD (works, but not Pythonic) import os, sys
5. The Tools That Enforce All This
You don't have to remember PEP 8 — there are tools that do it for you.
| Tool | What it does |
|---|---|
| black | Auto-formats your code. Opinionated, fast, no config. The current default. |
| ruff | Linter (and now formatter) — flags violations and offers fixes. Extremely fast. |
| flake8 | Older linter, still common. |
| isort | Sorts your imports into the three PEP 8 groups. |
| mypy | Static type checker (relevant later, when you get to type hints). |
Most editors run these on save. Set up black and ruff once and you'll never think about formatting again.
6. The Zen of Python
Python has a built-in poem about its philosophy. Type import this in any Python REPL to see it:
import thisA few greatest hits:
- Beautiful is better than ugly.
- Explicit is better than implicit.
- Simple is better than complex.
- Readability counts.
- There should be one — and preferably only one — obvious way to do it.
- If the implementation is hard to explain, it's a bad idea.
These aren't just slogans — they actually shape how Pythonistas review each other's code.
7. Before-and-After: Real-Code Cleanup
Before:
def f(x,y): if x>0 and y>0: z=x*y # multiply return z else: return 0
After:
def area_of_rectangle(width, height): """Return the area, or 0 if either side is non-positive.""" if width > 0 and height > 0: return width * height return 0
What changed:
- Function name says what it does
- 4-space indent (the original was 1-space inconsistent)
- Docstring explains intent
- Spaces around operators and after commas
- Redundant
# multiplycomment removed - Redundant
elseremoved (early return reads cleaner) - Temporary
zremoved — just return the expression
That's the cumulative effect of style.
8. Mistakes You'll Hit
1. Mixing tabs and spaces
Python 3 forbids it outright — your script won't even run. Configure your editor to insert spaces when you press Tab.
2. Naming variables in CamelCaseuserName = "alice" will work but flag a style warning. Use user_name. CamelCase is for class names only.
3. Skipping docstrings on public functions
If someone else (including future-you) might call this function, give it a one-line docstring at minimum.
🎯 Your Turn — Fix the Comments, Not the Code
The function below works correctly. Every comment in it is useless — each one
describes what the line does, which the line already says. What a reader
actually needs to know is why the rules are what they are.
Rewrite it: delete the noise comments, add a docstring, give the variables real
names, and leave exactly one comment explaining the non-obvious rule.
def calc(x, y): # multiply x by y t = x * y # if t is more than 500 then discount if t > 500: # multiply by 0.9 t = t * 0.9 # add 18 percent t = t * 1.18 # return t return t
The non-obvious part: orders over ₹500 get a 10% discount, and GST of 18% is
applied after the discount — not before. That ordering is a tax rule, not a
programming decision, and it is the one thing a future reader cannot infer.
Skeleton:
def order_total(unit_price, quantity): # TODO 1: write a docstring saying what it returns # TODO 2: name the running amount something meaningful # TODO 3: keep ONE comment — the one explaining discount-before-GST ... print(round(order_total(100, 3), 2)) # 354.0 -> below threshold, GST only print(round(order_total(100, 8), 2)) # 849.6 -> discount, then GST
Hint 1 — A comment that repeats the code has negative value
# multiply by 0.9 tells you nothing that t * 0.9 did
not. Worse, if the rate changes to 0.85 and someone forgets the comment, it now
lies. The only comments worth keeping are the ones the code cannot express.
Hint 2 — Names carry more than comments do
t forces the reader to hold "t is the running total" in their head.
total does not. Renaming is usually a better fix than commenting —
a good name is a comment that can never go stale.
Show full solution
GST_RATE = 0.18 BULK_DISCOUNT_RATE = 0.10 BULK_DISCOUNT_THRESHOLD = 500 def order_total(unit_price, quantity): """Return the payable total for an order, including GST. Orders above the bulk threshold receive a discount before tax. """ total = unit_price * quantity if total > BULK_DISCOUNT_THRESHOLD: total = total * (1 - BULK_DISCOUNT_RATE) # GST applies to the discounted amount, not the list price — taxing the # pre-discount total would overcharge the customer. return total * (1 + GST_RATE) print(round(order_total(100, 3), 2)) # 354.0 print(round(order_total(100, 8), 2)) # 849.6
Six comments became one. The rates moved into named constants, so the numbers
explain themselves and there is a single place to change them. The one surviving
comment answers a question the code genuinely cannot: why this order.
Recap
- Comments explain why — never just restate what.
- Docstrings (triple-quoted, first statement) are machine-readable.
- PEP 8 is the style guide everyone follows. Highlights: 4-space indent,
snake_casefor variables and functions,PascalCasefor classes,SCREAMING_SNAKEfor constants. - Tools like
black,ruff, andisortenforce style automatically. import thisfor the philosophy.
This lesson is a discipline lesson. Other lessons teach you what Python can do; this one teaches you how to do it the way everyone else does. That matters when reading other people's code — and when other people read yours.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.