Blog

Code Readability and Maintainability: Practical Rules With Examples

Readable code is code that the next person can understand without asking you. Maintainable code is code that person can change without breaking something unrelated. Both matter because code is read far more often than it is written, and every feature you add later has to be fitted around what is already there.

This post gives you the rules that pay off most, each with a before and after you can run, plus the places where the usual advice goes too far.

Why it matters in practice

A line of code is written once, then read by every teammate who debugs it, reviews a change near it, or adds a feature next to it. If a function takes ten minutes to understand instead of two, that cost is paid again on every visit. Unreadable code also hides bugs: you cannot spot a wrong condition in logic you cannot follow.

It matters for interviews too. When you write a solution on a whiteboard or in a shared editor, the interviewer is reading it live. Clear names and a simple structure make your reasoning visible, and they make it easier for you to catch your own mistakes.

1. Name things for what they mean

A name should say what a value is or what a function does, so the reader does not have to look at the definition. Compare these two versions of the same function (output: 30 for both, with the same order data):

// Before
function calc(o) {
  let t = 0;
  for (let i = 0; i < o.length; i++) {
    if (o[i].q > 0) {
      if (o[i].t === 'b') {
        t += o[i].p * o[i].q * 0.9;
      } else {
        t += o[i].p * o[i].q;
      }
    }
  }
  return t;
}

// After
const BULK_DISCOUNT = 0.9;

function lineTotal(item) {
  const subtotal = item.price * item.quantity;
  return item.type === 'bulk' ? subtotal * BULK_DISCOUNT : subtotal;
}

function orderTotal(items) {
  return items
    .filter(item => item.quantity > 0)
    .reduce((total, item) => total + lineTotal(item), 0);
}

Three things changed. The single-letter names became words. The magic number 0.9 became a named constant, so a pricing change is a one-line edit. And the nested if became a function with one job. Nothing about the behaviour changed.

Short names are fine when the scope is tiny and the meaning is obvious: i in a three-line loop, or left and right in a two-pointer algorithm. The rule is that the distance between a name’s definition and its use should match how descriptive it is.

2. Keep functions focused, but do not shred them

A function should do one thing at one level of detail. If you need the words “and” or “then” to describe it, it probably does two. A typical case is a handler that validates input, formats it, saves it and sends an email:

function validateUser({ name, email, age }) {
  if (!name || !email || !age) throw new Error('Invalid user data');
}

function normalizeUser({ name, email, age }) {
  return { name: name.trim(), email: email.trim().toLowerCase(), age };
}

function registerUser(input) {
  validateUser(input);
  const user = normalizeUser(input);
  database.saveUser(user);
  sendWelcomeEmail(user.email, user.name);
  return user;
}

Here registerUser reads like an outline: validate, normalise, save, notify. A bad input throws Invalid user data before anything is saved, which is easy to verify because validation sits in one place. (The database and sendWelcomeEmail are whatever your project provides.)

The counter-risk is splitting every three lines into its own function. If the reader has to jump through six files to follow one operation, you have traded one problem for another. Extract a function when it gives a piece of logic a name, makes it testable, or removes duplication, not just to make functions short.

3. Flatten nesting with early returns

Each level of indentation is one more condition the reader has to hold in their head. Handle the exceptional cases first and exit, so the main path stays at the left margin:

def first_active_admin(users):
    for user in users:
        if not user["active"]:
            continue
        if user["role"] != "admin":
            continue
        return user
    return None

print(first_active_admin([
    {"active": True, "role": "user"},
    {"active": False, "role": "admin"},
    {"active": True, "role": "admin", "name": "Sam"},
]))
# {'active': True, 'role': 'admin', 'name': 'Sam'}

The alternative is if user["active"]: if user["role"] == "admin": return user, which does the same job with an extra level for every condition you add later.

4. Comments explain why, not what

A comment that restates the code is noise, and it goes stale when the code changes. Compare:

// Calculate the total price including tax
let t = p + (p * r);

// Better: no comment needed
let totalPrice = basePrice + basePrice * taxRate;

Keep comments for things the code cannot say: why a workaround exists, what constraint forced an odd choice, a link to the bug report, the invariant a tricky loop depends on. “Retry once: the payment API sometimes returns 503 right after a deploy” is useful. “Increment i” is not.

5. Remove duplication, but only real duplication

When the same logic appears in several places, a fix has to be made in all of them, and someone will miss one. Pull out the shared part. Even a constant helps. A circle-area function and a circumference function that each hard-code 3.14159 are a common example, and the standard library already has a better value.

console.log(Math.PI * 2 * 2);  // 12.566370614359172
console.log(3.14159 * 2 * 2);  // 12.56636

The two results already disagree in the fourth decimal place, which is exactly what copied constants cause over time.

The opposite mistake is merging code that only looks alike. Two blocks that happen to share lines today but belong to different business rules will diverge, and a shared function with a flag parameter for each case ends up harder to read than the two copies. Wait until you see the same thing three times, and ask whether they would change for the same reason.

6. Follow the formatting and conventions of the project

Consistency matters more than which style you choose. Use the language’s standard: PEP 8 for Python, the project’s Prettier or ESLint config for JavaScript, gofmt for Go. Let a formatter such as Black (Python) or Prettier (JavaScript) enforce it automatically so reviews are about logic, not spacing. A linter (ESLint, Pylint, Ruff) catches unused variables and other small mistakes before a person has to.

7. Write tests that document behaviour

Tests are what let you change code without fear, which is the core of maintainability. A good test names the behaviour it protects:

function add(a, b) { return a + b; }

function assertEqual(actual, expected, label) {
  console.log(actual === expected ? 'ok   ' : 'FAIL ', label);
}

assertEqual(add(2, 3), 5, 'adds two positive numbers');
assertEqual(add(-1, 1), 0, 'negative and positive cancel out');
assertEqual(add(0, 0), 0, 'zero plus zero');

In a real project you would use a test runner such as Jest, Vitest or pytest instead of hand-written assertions. Note that console.assert, often used in tutorials, only prints a message on failure and does not stop the program, so a test script can look fine while checking nothing.

8. Design for change, not for every possible change

Principles like SOLID and patterns like dependency injection exist to keep modules independent: a change in how you send email should not touch the code that validates a user. They are useful when you feel that pain. Applying them in advance to a 200-line script produces layers of abstraction that nobody needs. A good test: if you cannot name two realistic changes the abstraction makes easier, leave it out.

9. Refactor in small steps, with tests passing

Clean code usually comes from a series of small cleanups, not a rewrite. Make the change, run the tests, commit. Rename a variable, extract a function, delete dead code. Do it in the same area you are already working in, since that is where the next reader is most likely to land. Avoid mixing a refactor with a behaviour change in the same commit: if something breaks, you will not know which one caused it. Our post on when and how to refactor goes deeper.

A quick checklist before you submit a pull request

  • Could someone new to this file explain what each function does from its name alone?
  • Are there magic numbers or strings that should be named constants?
  • Is any function longer than a screen, or doing two jobs?
  • Does every comment explain something the code cannot?
  • Is there a test for the behaviour you just changed?
  • Did the formatter and linter pass?

Practising it

Readability is a habit you build while writing ordinary code. Start with names: AlgoCademy’s Variable Naming Rules lesson covers the basics in Python, and Why Functions explains when to split code out. Then take something you wrote a month ago and see how long it takes you to understand it. That delay is a fair measure of how readable it was.

A lesson in AlgoCademy: the step to write, a picture of what it does, the line written in the editor, and the check: Step 2 passed

Before you go

Start thinking like a software engineer.

Learn to code and solve interview problems, one small, checked step at a time.

Free, no card needed. Or look around first

By continuing you agree to the Terms of Service and Privacy Policy.