RizTech Academy logo
RizTech Academy
Best Practices: Code Others Can ReadLesson 2 of 535 min

How big is too big

Every rule about size is somebody's guess dressed as a law. "Functions under twenty lines." "Files under four hundred." They are usually right, and the number is never the reason.

The reason is: a function should do one thing, at one level of abstraction. Size is a symptom.

One level of abstraction

def record_expense(date_text, description, amount_text):
    try:
        date = datetime.strptime(date_text, "%Y-%m-%d").date()
    except ValueError:
        raise ValueError(f"bad date: {date_text!r}")

    amount_paise = round(float(amount_text) * 100)
    if amount_paise <= 0:
        raise ValueError("amount must be positive")

    row = f"{date},{description.replace(',', ' ')},{amount_paise}\n"
    with open(DATA_FILE, "a", encoding="utf-8") as f:
        f.write(row)

    for listener in _listeners:
        listener(date, description, amount_paise)

That is not long. It is still hard to read, because four altitudes are stacked: parsing text, checking a business rule, formatting CSV, and notifying listeners. A reader looking for the business rule steps over file handling to find it.

def record_expense(date_text, description, amount_text):
    entry = parse_entry(date_text, description, amount_text)
    store.append(entry)
    _publish(entry)

Same work. Now the function reads as a summary, and each detail is one step down if you want it. The test: can you read the function and understand what it does without reading anything it calls?

What to extract, and what to leave

Extracting is not free — a name to invent, and a jump for the reader. Extract when:

  • The block needs a comment to say what it does. The comment is the function name you have not written yet.
  • It is at a different altitude from its neighbours.
  • It is duplicated, or nearly.
  • You want to test it separately.

Leave it when it is used once, is three lines and obvious; when extracting would need four parameters to carry the context; or when the name would just restate the code — do_the_loop.

If you cannot name the extracted function better than the code it replaces, the extraction earns nothing.

Parameters

Zero is best, one is good, two is fine, three is a smell, four means something is wrong.

Python gives you tools other languages do not:

# hard to read at the call site
add_expense("2026-09-01", "Chai", 2000, True, False, 3)

# keyword arguments fix it immediately
add_expense(
    date="2026-09-01",
    description="Chai",
    amount_paise=2000,
    recurring=True,
)

And you can force it:

def add_expense(date, description, *, amount_paise, recurring=False):
    ...

Everything after the bare * must be passed by keyword. add_expense(d, "Chai", 2000) is now a TypeError rather than a mystery. Use it for any function where the call site would otherwise be a row of unlabelled values.

A boolean parameter is nearly always wrong — save(entry, True) tells the reader nothing. Either two named functions, or keyword-only so the call site reads save(entry, notify=True).

The mutable default

The Python-specific one that catches everybody:

def add_tag(tag, tags=[]):      # WRONG
    tags.append(tag)
    return tags

add_tag("food")     # ['food']
add_tag("travel")   # ['food', 'travel']  <-- the same list, still there

Default arguments are evaluated once, when the function is defined — not per call. A mutable default is shared by every call for the life of the program.

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

Ruff flags this (B006), and it is worth being able to explain rather than just obeying.

Return early

# arrow-shaped
def bill_paise(customer, tiffins):
    if customer is not None:
        if customer.strip():
            subscriber = subscribers.get(customer)
            if subscriber is not None:
                if tiffins > 0:
                    return subscriber.rate_paise * tiffins
                else:
                    raise ValueError("tiffins must be positive")
            else:
                raise KeyError(customer)
        else:
            raise ValueError("customer must not be blank")
    else:
        raise TypeError("customer must not be None")
# flat
def bill_paise(customer, tiffins):
    if customer is None:
        raise TypeError("customer must not be None")
    if not customer.strip():
        raise ValueError("customer must not be blank")
    if tiffins <= 0:
        raise ValueError("tiffins must be positive")

    subscriber = subscribers.get(customer)
    if subscriber is None:
        raise KeyError(customer)

    return subscriber.rate_paise * tiffins

Same behaviour. Guards at the top where a reader absorbs them, the real work at the bottom unindented, and every else gone — each of which was a place to make a mistake.

Classes: one reason to change

A class should have one reason to change — more useful than a line count, because it is about why edits arrive.

The capstone:

Class Changes when…
Expense the fields of an expense change
ExpenseStore the storage format changes
Report the output format changes

Three unrelated reasons. Moving from CSV to SQLite touches ExpenseStore and nothing else, which is the actual payoff and why it exists rather than open() being called from everywhere.

If you can describe a class's job with an "and", it is probably two classes.

And sometimes it should not be a class at all

Java makes you write a class. Python does not, and a class with one method and no state is a function wearing a costume:

class ExpenseFormatter:
    def format(self, expense):
        return f"{expense.date}  {expense.description:<20} {format_paise(expense.amount_paise)}"
def format_expense(expense):
    return f"{expense.date}  {expense.description:<20} {format_paise(expense.amount_paise)}"

The second is the Python answer. Reach for a class when there is state that several functions share, or when you need several interchangeable implementations. A module full of functions is a perfectly good unit of organisation — that is what math and json are.

Modules

A module is the natural unit in Python, and import is your dependency graph.

Split a file when:

  • it has more than one reason to change,
  • you are scrolling to find things,
  • the imports at the top are from unrelated worlds.

Do not split by kind. A models.py, helpers.py and utils.py in every project is a filing cabinet with drawers labelled "paper". Split by feature — storage.py, reporting.py, cli.py — so a change to reporting touches one file.

And utils.py is where code goes to be forgotten. If something is genuinely general, name it for what it does: dates.py, money.py.

Do not gold-plate

The counterweight. A class with one method wrapped in an abstract base class behind a factory function is not well-designed; it is four things to read before you learn what happens.

The right size for a first version is the obvious thing. The second time you touch it, the seams will be visible — and in places you would not have guessed.

Check your work

Why size rules are symptoms: the real rule is one thing at one level of abstraction.

The readability test: can you read the function without reading what it calls?

When to extract: it needs a comment, it is a different altitude, it is duplicated, or you want to test it.

What keyword-only arguments fix: a call site of unlabelled values, and they turn a mistake into a TypeError.

Why a mutable default is shared: defaults are evaluated once at definition, not per call.

What return-early buys: guards at the top, work unindented, no else branches.

The class test: one reason to change, and an "and" means two classes.

When not to write a class: one method and no state — that is a function.

How to split modules: by feature, never by kind, and never into utils.py.

Practice

  1. Find the longest function in your capstone and list the altitudes in it.
  2. Extract one block and name it. If the name restates the code, put it back.
  3. Find a function with a comment inside the body. Turn the comment into a function name.
  4. Write the mutable-default bug, call it three times, and explain the output.
  5. Fix it with None and confirm.
  6. Add * to a function of yours with three or more parameters. Watch the old call sites fail.
  7. Find a boolean parameter and replace it with two functions.
  8. Flatten a nested function with early returns. Count the else branches removed.
  9. Find a class in your code with one method and no state. Make it a function.
  10. If you have a utils.py, list what is in it and work out where each thing actually belongs.

Next: docstrings, type hints, and the few comments worth writing.

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