Index Ai Academy
Phase 7 · Software engineering · Writing good code

Readable code and refactoring

Write code people can understand, and improve it safely.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Names that explain
  2. Pure logic, side effects at the edges
  3. Refactoring: change the shape, keep the behavior
  4. Guard clauses flatten nesting
  5. Judgment calls
  6. 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, not list1. unit_price, not num.
  • 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 > 90 hides a business rule. INACTIVE_AFTER_DAYS = 90 states it once, and changing it means changing one line.
  • Length should match scope. i is 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.

Quick check
What does this print?
def is_adult(age):
    return age >= 18
print(is_adult(18))
  1. False
  2. 18
  3. True
  4. None
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.

Quick check
Which name is best for a value holding whether a customer opted in to email?
  1. email
  2. x2
  3. flag
  4. is_subscribed
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))
It runs right here in your browser, with nothing to install.

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.

Quick check
Which of these functions is pure?
  1. def log_sale(amount): print(amount)
  2. def today(): return datetime.date.today()
  3. def tax(amount): return round(amount * 0.2, 2)
  4. def save(amount): open('sales.txt', 'a').write(str(amount))
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:

smellusual fix
a function longer than a screenextract smaller functions
deep nesting (if inside if inside for)guard clauses, extract functions
the same few lines copied in three placesextract one function (wait for the third copy)
a function with six or more parametersgroup them into a dataclass or dict
a True/False parameter that switches behaviortwo functions with clear names
unexplained numbers like 0.0725named 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.

Try it now

Each smell is a hint to look closer, not a rule to obey blindly.

Worked example

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])
In the lesson you can change this and run it.

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.

Quick check
Which change is a refactoring?
  1. Splitting a long function into three named ones, with the tests still passing
  2. Fixing a bug in the totals
  3. Adding a new export format
  4. Changing the output to show cents
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

Create a free account

Try a quiz question

What is refactoring?

  1. Changing code's structure without changing what it does
  2. Adding new features quickly, without tests
  3. Rewriting a program in another, faster language
  4. Fixing the bugs that users have reported

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson