Writing code that works is only part of the job — this chapter teaches you to write code that is readable, testable, and genuinely easy to improve
By this point in the book, you can write Python programs that use variables, conditionals, loops, functions, data structures, files, exceptions, and classes. You have the vocabulary of a programmer. But writing code that works is only part of the job.
The other part is writing code that is easy to understand, easy to test, easy to fix, and easy to improve. A program can produce correct output and still be nearly impossible to read. It can work today and break the moment someone changes one line. It can have functions that do five unrelated things, variable names that mean nothing, and logic that only the original author could decode.
Professional programmers spend a large portion of their time not writing new code, but improving existing code. They write tests to catch mistakes early. They refactor programs to make the design clearer. They review each other's work. They delete duplication. They rename things. They simplify conditions. They break long functions apart. All of this is called working on code quality, and it matters as much as correctness.
This chapter teaches you how to evaluate the quality of a program, how to write simple tests, and how to improve code through careful refactoring. These skills will serve you in every Python project you work on, including the capstone project in Chapter 30.
Imagine two programs that both calculate the average of a list of scores. Both produce identical output. One has variable names like x, temp2, and stuff, with three copies of the same calculation buried in different places and no tests. The other has named functions like calculate_average and count_passing, a brief docstring on each, and a test file that runs in under a second. If a bug appears six months later, which program would you rather fix? That is the question this chapter answers.
By the end of this chapter, you should be able to:
assert statements and organize them in a test file.assert inside functions to verify assumptions about data.When programmers talk about code quality, they are not talking about whether the code runs. They are talking about whether the code is good to work with.
Consider two programs that both calculate the average of a list of numbers. Both produce the same output. But one is twenty lines of tangled logic with variable names like x, temp2, and stuff, with no comments and three copies of the same calculation spread through the code. The other is eight lines of clean functions with names like calculate_average and count_items, a brief comment explaining the purpose, and no repeated logic.
If you had to fix a bug in one of those programs, you would want the second one. If you had to add a feature, you would want the second one. If someone else had to maintain the code for the next two years, they would want the second one.
Code quality is about the human side of programming. Code is read far more often than it is written. Every program you write is a message to a future reader — and that future reader is often yourself, six months from now, with no memory of what you were thinking.
Simple code is usually more readable. Readable code is easier to test. Testable code is easier to maintain. Well-maintained code tends to stay correct longer. Improving any one dimension tends to pull the others upward.
The single most powerful thing you can do to improve code quality is to use good names. Python allows almost any name for a variable, function, or class. That freedom is dangerous. A variable named x tells you nothing. A variable named student_average_score tells you exactly what it holds.
x = 85
y = 0
for i in items:
y += i
passing_grade = 85
total_score = 0
for score in scores:
total_score += score
Variables should describe what they store, not how they are used.
Functions should describe what they do, ideally starting with a verb.
def data(lst):
...
def process(x):
...
def calculate_average(scores):
...
def find_highest_score(scores):
...
Boolean variables should read naturally as true or false.
# Awkward status = True # Natural — reads like a true/false question is_logged_in = True has_errors = False account_is_active = True
Classes should be nouns in CapWords style. For example: StudentRecord, ShoppingCart, ContactBook.
Misleading names are worse than no names at all. A variable called total that actually stores an average will cause real confusion. A function called print_report that also saves a file and sends an email is lying about what it does. Names should match behavior exactly.
# This function is lying — it does far more than it claims def print_report(data): print(data) save_to_file(data) # surprise! send_email(data) # another surprise # Better: give each responsibility its own honest function def print_report(data): print(data) def save_report(data): save_to_file(data) def send_report(data): send_email(data)
Python code reads best when lines stay under 80–90 characters. Long lines force readers to scroll sideways and make it harder to see the structure of an expression. Use blank lines to separate logical sections within a function. Use two blank lines between function definitions. Spacing is not decoration — it shows the reader where one idea ends and another begins.
Comments explain the why, not the what. Good code mostly explains itself. Comments are for the parts that do not.
total = total + score
# add score to total
if score >= 60: # district policy
grade = "Pass"
If a comment is just restating the code in English, delete it. It adds noise without adding understanding.
A docstring is a string literal placed at the start of a function, class, or module. It explains what the function does, what it accepts, and what it returns. Docstrings are accessible through Python's built-in help() system.
def calculate_average(numbers):
"""
Return the arithmetic mean of a list of numbers.
Args:
numbers: A list of numeric values. Must not be empty.
Returns:
The average as a float.
Raises:
ValueError: If the list is empty.
"""
if not numbers:
raise ValueError("Cannot average an empty list.")
return sum(numbers) / len(numbers)
The one-line docstring form is fine for simple functions:
def is_passing(score):
"""Return True if score is 60 or higher."""
return score >= 60
Do not leave blocks of dead code in a file permanently. If code is no longer needed, delete it. If you might need it later, version control tools like Git can retrieve it. Commented-out code is confusing: readers cannot tell whether it was intentionally removed, forgotten, or still important.
A unit test is a small, automated check that verifies a specific piece of code produces the expected output for a given input. You have been testing programs throughout this book, but mostly by running them and reading the output yourself. That is called manual testing, and it is valuable — but it has two weaknesses. First, you have to remember to repeat it every time you change something. Second, humans miss things.
Automated unit tests solve both problems. You write them once, and you can run them any time to confirm everything still works.
The assert statement checks whether a condition is true. If it is, nothing happens. If it is false, Python raises an AssertionError.
assert calculate_average([10, 20, 30]) == 20.0 assert calculate_average([100]) == 100.0 assert calculate_average([0, 0, 0]) == 0.0 print("All tests passed.") # A failing assertion stops the program immediately: assert calculate_average([10, 20]) == 15.5 # actual: 15.0 → AssertionError
def test_empty_list_raises():
try:
calculate_average([])
print("FAIL: No exception was raised.")
except ValueError:
print("PASS: ValueError raised correctly.")
test_empty_list_raises()
As programs grow, collect all tests in one file separate from the main program.
| File | Contents |
|---|---|
grades.py | Main functions: calculate_average, is_passing, letter_grade |
test_grades.py | Test functions for each function in grades.py |
from grades import calculate_average, is_passing, letter_grade
def test_calculate_average():
assert calculate_average([10, 20, 30]) == 20.0
assert calculate_average([100]) == 100.0
assert calculate_average([0, 0, 0]) == 0.0
print("test_calculate_average passed.")
def test_is_passing():
assert is_passing(60) == True
assert is_passing(59) == False
assert is_passing(100) == True
assert is_passing(0) == False
print("test_is_passing passed.")
def test_letter_grade():
assert letter_grade(95) == "A"
assert letter_grade(85) == "B"
assert letter_grade(75) == "C"
assert letter_grade(65) == "D"
assert letter_grade(55) == "F"
print("test_letter_grade passed.")
# Run all tests
test_calculate_average()
test_is_passing()
test_letter_grade()
The assert statement is useful beyond test files. You can use assertions inside functions to verify that your assumptions about the data are correct when the program runs.
def calculate_average(numbers):
"""Return the arithmetic mean of a list of numbers."""
assert isinstance(numbers, list), "Input must be a list."
assert len(numbers) > 0, "List must not be empty."
return sum(numbers) / len(numbers)
If someone passes in a string or an empty list, Python immediately raises a clear AssertionError with a message rather than failing silently somewhere deeper in the program.
Assertions are for catching programming errors, not for handling user input. If your program accepts data from a user and that data might be invalid, use if statements and raise to give a proper error. Assertions can be disabled with a Python runtime flag. User-facing validation should never be disabled.
def process_age(age):
assert age >= 0, "Age cannot be negative."
def process_age(age):
if age < 0:
raise ValueError(
f"Age cannot be negative. Got: {age}"
)
Refactoring means improving the internal structure of code without changing its external behavior. The program does exactly the same thing before and after — it just does it in a cleaner, more organized way. You refactor when code is hard to read, functions are too long, the same logic appears in more than one place, names are unclear, or a function does too many unrelated things.
Step 1. Make sure the code has tests, so you can confirm nothing breaks.
Step 2. Make one small improvement.
Step 3. Run the tests to confirm behavior is unchanged.
Step 4. Repeat.
Refactoring in large steps without tests is risky. Small steps with tests are safe.
def run_report(filename):
scores = []
with open(filename) as f:
for line in f:
parts = line.strip().split(",")
scores.append(int(parts[1]))
total = 0
for score in scores:
total += score
average = total / len(scores)
passing = 0
for score in scores:
if score >= 60:
passing += 1
print(f"Average score: {average:.1f}")
print(f"Students passing: {passing}")
print(f"Students in file: {len(scores)}")
def load_scores(filename):
"""Read a CSV file and return a list of integer scores."""
scores = []
with open(filename) as f:
for line in f:
parts = line.strip().split(",")
scores.append(int(parts[1]))
return scores
def calculate_average(scores):
"""Return the mean of a list of scores."""
if not scores:
raise ValueError("Score list is empty.")
return sum(scores) / len(scores)
def count_passing(scores, threshold=60):
"""Return the number of scores at or above the threshold."""
return sum(1 for score in scores if score >= threshold)
def print_report(scores):
"""Print a summary report for a list of scores."""
average = calculate_average(scores)
passing = count_passing(scores)
print(f"Average score: {average:.1f}")
print(f"Students passing: {passing}")
print(f"Students in file: {len(scores)}")
def run_report(filename):
"""Load data and print the full report."""
scores = load_scores(filename)
print_report(scores)
The behavior of the program is identical. But now each function does exactly one thing, each can be tested independently, and changes to the file format touch only load_scores while changes to the passing threshold touch only count_passing. The structure now matches the problem.
Duplicated code is one of the most common quality problems in beginner programs. When the same logic appears in two or more places, changing it requires finding and updating every copy. Missing even one copy creates a bug. The rule programmers use is called DRY: Don't Repeat Yourself.
def process_final_exam(score):
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
elif score >= 60:
grade = "D"
else:
grade = "F"
return grade
def process_midterm(score): # identical grading logic
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
...
return grade
def letter_grade(score):
"""Return the letter grade for a numeric score."""
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
def process_final_exam(score):
return letter_grade(score)
def process_midterm(score):
return letter_grade(score)
Now the grading logic exists in exactly one place. If the thresholds ever change, you change them once.
# Repeated condition in two places if temperature > 30 and humidity > 70: print("Heat advisory issued.") log_entry("Heat advisory") if temperature > 30 and humidity > 70: update_status("Alert") # Better: name the condition once heat_advisory = temperature > 30 and humidity > 70 if heat_advisory: print("Heat advisory issued.") log_entry("Heat advisory") update_status("Alert")
# Hard to read as one long condition if age >= 18 and income >= 30000 and credit_score >= 650 and not has_existing_loan: approve_loan() # Easier to read — each piece has a meaningful name old_enough = age >= 18 sufficient_income = income >= 30000 good_credit = credit_score >= 650 no_existing_debt = not has_existing_loan if old_enough and sufficient_income and good_credit and no_existing_debt: approve_loan()
def process_order(order):
if order is not None:
if order["status"] == "pending":
if order["total"] > 0:
fulfill_order(order)
def process_order(order):
if order is None:
return
if order["status"] != "pending":
return
if order["total"] <= 0:
return
fulfill_order(order)
Early returns remove nesting and make the main path of the function clearer. Readers reach fulfill_order knowing all conditions have been verified.
# Use _ when the loop variable is unused for _ in range(3): print("Retry") # Iterate directly — avoid manual index when you don't need it for name in names: # prefer this print(name) # Use enumerate when you need both index and value for i, name in enumerate(names): print(f"{i + 1}. {name}") # Never modify a list while iterating — build a new one instead inventory = [item for item in inventory if item["quantity"] > 0]
As programs grow beyond a single file, organization matters. A well-organized project is easier to navigate, easier to test, and easier to hand to someone else.
For simple programs, one file is fine: calculator.py, quiz.py, notes.py. When the program has several distinct components, separate them into modules.
gradebook/
gradebook.py ← main program, menus, user interaction
grades.py ← grade calculation functions
reports.py ← report generation functions
file_io.py ← file reading and writing
test_grades.py ← tests for grades.py
test_reports.py ← tests for reports.py
data/
students.csv ← sample data
README.md ← description and instructions
| File | Purpose |
|---|---|
main.py or program name | Entry point, menus, user interaction |
| Descriptive module name | Classes, data definitions, or business logic |
file_io.py | File reading, writing, CSV handling |
test_*.py | Tests for the corresponding module |
README.md | Description, setup instructions, usage |
A README.md file belongs at the top of every project. It should describe what the program does, how to run it, what files are included, any setup steps required, and known limitations. A short README is always better than no README.
# Student Gradebook A command-line program for managing student scores and generating reports. ## How to Run python gradebook.py ## Files - gradebook.py — main program - grades.py — grade calculation functions - data/students.csv — sample student data ## Requirements Python 3.8 or later. No external packages required.
Code review is the practice of having another person — or yourself, with fresh eyes — read your code before it is finalized. In professional software development, almost no code is released without review. The purpose is not to criticize the programmer; it is to catch problems the original author could not see because they were too close to the work.
Self-review works better than you might expect, especially if you wait at least a few hours before reviewing. When you return to code with fresh eyes, you will notice things you missed when writing it.
Programs that work today can break tomorrow when the input format changes, a library is updated, a new Python version handles something differently, the requirements change, or a bug is found in a corner case that was never tested. Maintainable code handles these changes gracefully. A program that has never been touched again after it was written is not typical. Real programs are updated, extended, handed to new people, and adapted to new situations. Writing code as if someone will have to read and change it later — because someone will — is one of the most valuable habits a programmer can develop.
Here is a working program that calculates student grades. It produces correct output but has several quality problems. Study the original, then trace every improvement made in the refactored version.
def f(d):
r = []
for x in d:
n = x[0]
s = (x[1] + x[2] + x[3]) / 3
if s >= 90:
g = 'A'
elif s >= 80:
g = 'B'
elif s >= 70:
g = 'C'
elif s >= 60:
g = 'D'
else:
g = 'F'
r.append((n, s, g))
return r
data = [
("Alice", 88, 92, 85),
("Bob", 55, 60, 48),
("Carol", 95, 97, 91),
("David", 70, 74, 68),
]
results = f(data)
for item in results:
print(item[0], round(item[1], 1), item[2])
f, d, r, x, n, s, g are all single-letter or meaningless names. None describe what they hold.item[0], item[1] instead of named access, making it impossible to understand what each position means.def letter_grade(average):
"""Return the letter grade for a numeric average."""
if average >= 90:
return "A"
elif average >= 80:
return "B"
elif average >= 70:
return "C"
elif average >= 60:
return "D"
else:
return "F"
def calculate_student_result(name, score1, score2, score3):
"""
Calculate the average and letter grade for a student.
Returns a dictionary with keys: name, average, grade.
"""
average = (score1 + score2 + score3) / 3
grade = letter_grade(average)
return {"name": name, "average": average, "grade": grade}
def process_class(student_data):
"""Process a list of student records and return results."""
results = []
for name, score1, score2, score3 in student_data:
result = calculate_student_result(name, score1, score2, score3)
results.append(result)
return results
def print_results(results):
"""Print a formatted summary of student results."""
for result in results:
print(f"{result['name']:10} {result['average']:.1f} {result['grade']}")
# --- Tests ---
def test_letter_grade():
assert letter_grade(95) == "A"
assert letter_grade(85) == "B"
assert letter_grade(90) == "A" # boundary: exactly 90
assert letter_grade(60) == "D" # boundary: exactly 60
assert letter_grade(55) == "F"
print("test_letter_grade passed.")
def test_calculate_student_result():
result = calculate_student_result("Alice", 88, 92, 85)
assert result["name"] == "Alice"
assert abs(result["average"] - 88.333) < 0.01
assert result["grade"] == "B"
print("test_calculate_student_result passed.")
# --- Main program ---
student_data = [
("Alice", 88, 92, 85),
("Bob", 55, 60, 48),
("Carol", 95, 97, 91),
("David", 70, 74, 68),
]
test_letter_grade()
test_calculate_student_result()
results = process_class(student_data)
print_results(results)
The refactored program is not faster or more featureful. It is the same program expressed in a way that is vastly easier to read, test, and change. That is what code quality means.
help(). Comments that restate the code and blocks of dead commented-out code should be deleted.assert statements and organizing them in a separate test file makes testing fast and repeatable. Good tests cover normal cases, boundary values, and error conditions.raise ValueError cannot. Use raise wherever users can provide invalid input.enumerate for index-and-value pairs, and building new lists rather than modifying lists in place.assert that verifies a condition is true; raises AssertionError if it is false. Used to verify assumptions about data during development.help().return statement placed at the start of a function to handle special cases and reduce nesting, making the main logic path easier to follow.Read each code example carefully. Identify problems and reason through the expected behavior before writing anything.
def p(l): t = 0 / for x in l: t = t + x / a = t / len(l) / print(a)
assert calculate_average([10, 20, 30])assert calculate_average == 20.0assert calculate_average([10, 20, 30]) = 20.0
letter_grade is implemented correctly? What happens if the function erroneously returns "B" for a score of exactly 90? Which assert line fails, and what does the error message show?
approve_loan — one deeply nested, one using early returns. Trace both with inputs (20, 35000, 700) and (16, 35000, 700). Do they produce the same result? At which line does each version make its decision?assert is_passing(59) == True. The function is_passing returns score >= 60. The test always fails. Is the bug in is_passing or in the test? Explain your reasoning.def calculate_discount(price, percent): discount = price * percent; return price - discount. Write a test that catches this bug. Then fix the function.go with parameters a, b, c, d; (b) a condition written as if x == True; (c) four levels of nesting where early returns would flatten the logic.clamp(value, minimum, maximum) that returns value if it is between minimum and maximum, returns minimum if too low, and returns maximum if too high. Write at least five assert tests covering normal cases, boundary values, and at least one case where clamping occurs in each direction.print_invoice(items) and print_receipt(items) — that contain nearly identical logic: iterate over items, print each line total, sum the total, and print a header and footer with different titles. Extract the shared logic into a helper function and rewrite both functions to use it without changing the output.budget.py module containing three functions: is_over_budget(spent, budget), remaining_budget(spent, budget), and budget_percentage_used(spent, budget). Your tests should cover normal cases, boundary cases, and at least one case that should raise a ValueError.check_eligibility(a, b, c, d) where a = is a citizen, b = age, c = has valid ID, d = outstanding violations. The function returns True only when all four conditions are satisfied.i or _.raise ValueError instead. Explain the difference.process_data(d). Without reading the body, what can you tell from the name alone? What makes this signature a quality problem, and what would be a better version?assert statement do? What happens when an assertion fails?if/elif/else blocks in two different functions. How does the DRY principle tell you to fix this, and what happens if you do not?Chapter 30 is the capstone of this textbook. You will plan, build, test, document, and present a complete Python project that combines the major skills of all seven parts of the book — values and variables, control flow, functions, data structures, files, exception handling, and where appropriate, classes. The project will be tested with the techniques from this chapter, refactored for quality, and documented with a README and docstrings. Chapter 29 has given you the habits of a careful programmer. Chapter 30 gives you the opportunity to practice all of them on a project that is entirely your own.