Readable code and refactoring
Write code people can understand, and improve it safely.
Create a free account Try part of it here
Why it matters
Code is read far more often than it's written: by teammates, by reviewers, by you in six months, and now by AI agents that edit it. Code that reads clearly has fewer bugs and is cheaper to change. Clean code also helps AI assistants: clear names and small functions give a model the context it needs to make correct edits.
What you'll be able to do
- Name variables and functions so the code explains itself
- Split long functions into small ones that each do one thing
- Separate pure logic from side effects such as printing, files and network calls
- Refactor in small, safe steps with tests watching
What it covers
- Names that explain
- Pure logic, side effects at the edges
- Refactoring: change the shape, keep the behavior
- Guard clauses flatten nesting
- Judgment calls
- The next reader
Names that explain
Think of a kitchen where every jar is labeled, and one where none are. You can cook in both, and you'll make more mistakes in the second. Compare these two lines:
d = [c for c in cs if c[3] > 90] inactive = [c for c in customers if c["days_since_order"] > 90]
Same logic. The second needs no comment. Good names are the cheapest way to make code clear:
- Say what it holds, not its type.
emails, notlist1.unit_price, notnum. - Functions are verbs.
send_receipt,parse_order,total_spend. - True or false values read as questions.
is_active,has_email,can_refund. - Name the magic numbers.
if days > 90hides a business rule.INACTIVE_AFTER_DAYS = 90states it once, and changing it means changing one line. - Length should match scope.
iis fine in a three-line loop. A value used across a file needs a full name.
Small functions that do one thing
A function should do one job, at one level of detail. If you need "and" to describe it ("loads the file and cleans the rows and emails a report"), it's probably three functions. Small functions are easier to name, to test and to reuse.
def is_adult(age): return age >= 18 print(is_adult(18))
Show the answer
True
18 >= 18 is True, because >= includes the edge. A short function with a clear name reads like a sentence wherever it's called.
Show the answer
is_subscribed
A true/false value reads best as a question. email sounds like an address, and flag and x2 say nothing.
Pure logic, side effects at the edges
A pocket calculator is the model here. The same keys give the same answer, and pressing them changes nothing else in the world. A pure function works like that: it returns a result that depends only on its inputs, and changes nothing outside itself. round(19.99 * 0.85, 2) is pure. A side effect is anything else: printing, writing a file, calling an API, changing a global variable, reading the clock.
Programs need side effects, or they'd do nothing useful. But mixing them into your logic makes the logic hard to test and hard to reuse. Think of a restaurant, where cooks cook and waiters deal with the guests. A good shape is a functional core of pure functions, wrapped in a thin shell that does the input and output:
# Core: pure and easy to test. def overdue_invoices(invoices, today): return [inv for inv in invoices if inv["due"] < today and not inv["paid"]] def reminder_text(invoice): return f"Invoice {invoice['id']} for {invoice['amount']} was due on {invoice['due']}." # Shell: does the I/O (here, print stands in for sending email). invoices = [ {"id": "A1", "amount": 120, "due": "2026-09-01", "paid": False}, {"id": "A2", "amount": 80, "due": "2026-12-01", "paid": False}, {"id": "A3", "amount": 45, "due": "2026-08-15", "paid": True}, ] for inv in overdue_invoices(invoices, today="2026-10-30"): print(reminder_text(inv))
Notice today is passed in rather than read from the clock. That one choice makes the function testable on any date.
Comments say why, not what
i += 1 # add one to i <- says what the code already says retries += 1 # the payment API often fails once on cold start, so we allow one retry
The first comment is noise. The second records something the code can't: the reason. If code needs a comment to explain what it does, try renaming or splitting it first.
In plain words: let names say what the code does, and comments say why it does it that way.
Show the answer
def tax(amount): return round(amount * 0.2, 2)
tax depends only on its input and changes nothing. The others print, read the clock or write a file.
Refactoring: change the shape, keep the behavior
Tidying a garage doesn't change what you own. It only makes things easier to find. Refactoring means improving code's structure without changing what it does. The rule that makes it safe: small steps, tests green after each one. Rename one thing, run the tests. Pull out one function, run the tests. If a test fails, the last small step is the culprit.
Common refactorings:
- Rename a variable or function to say what it is.
- Extract a function from a block that does one identifiable job.
- Replace a magic number with a named constant.
- Use guard clauses: handle the special cases first and return early, so the main logic isn't buried under layers of
if.
Code smells
A faint smell of gas doesn't prove a leak, but you'd go and check. A code smell isn't a bug either. It's a sign that a bug, or a painful change, is likely:
| smell | usual fix |
|---|---|
| a function longer than a screen | extract smaller functions |
deep nesting (if inside if inside for) | guard clauses, extract functions |
| the same few lines copied in three places | extract one function (wait for the third copy) |
| a function with six or more parameters | group them into a dataclass or dict |
| a True/False parameter that switches behavior | two functions with clear names |
unexplained numbers like 0.0725 | named constants |
Don't remove duplication too early. Two similar blocks may drift apart for good reasons. Many engineers follow the rule of three: extract on the third copy, when the pattern is clear.
Formatters and linters handle the mechanical part, the way a spell-checker does. Ruff and Black are the common Python tools. A formatter fixes spacing and layout so nobody argues about it. A linter flags likely mistakes, like unused imports or variables that are never defined.
Each smell is a hint to look closer, not a rule to obey blindly.
Guard clauses flatten nesting
Both functions decide whether an order can be refunded. The second handles each special case first and returns.
def can_refund_nested(order, days_since): if order["paid"]: if not order["refunded"]: if days_since <= 30: return True else: return False else: return False else: return False REFUND_WINDOW_DAYS = 30 def can_refund(order, days_since): if not order["paid"]: return False if order["refunded"]: return False return days_since <= REFUND_WINDOW_DAYS cases = [({"paid": True, "refunded": False}, 10), ({"paid": True, "refunded": True}, 3), ({"paid": False, "refunded": False}, 1), ({"paid": True, "refunded": False}, 45)] print([can_refund_nested(o, d) for o, d in cases]) print([can_refund(o, d) for o, d in cases])
Both print the same results, so the refactoring kept the behavior. The second version reads top to bottom as a list of rules, and the 30-day rule now has a name. Checking that old and new code agree on many cases, as this does, is the core habit of safe refactoring.
Show the answer
Splitting a long function into three named ones, with the tests still passing
Refactoring changes the shape of the code, not what it does. The unchanged tests prove the behavior stayed the same.
Judgment calls
Short names don't save time. You type a name once and read it dozens of times. n is fine in a three-line loop, but customers_without_orders is better for anything you'll meet again later. And if you feel a comment is needed to explain what a block does, try renaming or splitting it first.
Don't refactor and add features together. You wouldn't rewire a house while you paint it. If you change the shape of the code and its behavior in one go, then a failing test can't tell you which part broke it. Refactor first with the tests green, and commit. Then add the feature. It feels slower, and it's usually faster.
Not all duplication is bad. Two blocks that look alike today may need to change for different reasons tomorrow. Merge them too early, and you end up with one function full of flags and special cases, which is worse than the copies. A useful rule is to wait until you have three real examples before you pull out a shared function. Duplication is cheaper than the wrong abstraction.
The next reader
The person reading your code next may well be you, months from now, with none of today's context. Leave them what you'd want to find.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What is refactoring?