Lesson 5 · Python Beginner Course
Python Debugging for Beginners: Read Errors, Fix Bugs, Write Tests (Beginner Python Course, Part 5)
In this lesson you'll be able to: Read a traceback line by line and jump straight to the bug Recognise the most common Python errors and fix each one Swap…
- CoursePython Beginner Course
- Lesson5 of 10
- Video59 min
- FormatJupyter notebook · 80 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbDebug Like a Pro: Errors, Testing and Clean Code#
In this lesson you'll be able to:
- Read a traceback line by line and jump straight to the bug
- Recognise the most common Python errors and fix each one
- Swap scattered print() calls for the logging module
- Check your code with assert, plain test functions and pytest
- Write cleaner code with type hints, PEP 8 names, docstrings and refactoring
- Capstone: debug and test a buggy gym membership fee calculator
Warm-Up#
In lesson 3 you caught errors with try and except. Today you learn to read them, prevent them and test for them.
def split_bill(total, people):
return round(total / people, 2)
print(split_bill(48.60, 3))
print(split_bill(100, 4))
try:
split_bill(48.60, 0)
except ZeroDivisionError as problem:
print("Caught:", type(problem).__name__, "->", problem)
from pathlib import Path
folder = Path("lesson_files")
folder.mkdir(exist_ok=True)
print("Working folder ready:", folder)
Reading a Traceback#
A traceback is a trail of breadcrumbs. Start at the bottom: the last line says what went wrong, the lines above say where.
menu = {"latte": 3.20, "flat white": 3.40, "tea": 2.10}
def item_price(name):
return menu[name]
def order_total(items):
total = 0
for item in items:
total += item_price(item)
return total
print(f"${order_total(['latte', 'tea']):.2f}")
Read it from the bottom up#
- Last line: the error type and its message
- Just above: the exact line that crashed
- Further up: the chain of calls that led there
print(order_total(["latte", "mocha", "tea"]))
def order_total(items):
total = 0
for item in items:
if item in menu:
total += item_price(item)
else:
print(f" Skipping '{item}': not on the menu")
return round(total, 2)
print(order_total(["latte", "mocha", "tea"]))
Quick check#
The order_total traceback showed three places in the code. Which one shows where the crash actually happened?
print("The LAST one, inside item_price. The ones above just show who called whom.")
Meet the Error Family#
Each error type is a different warning light on a car dashboard. Know what each light means and you know where to look.
eval()runs a string as code. It's handy for this demo, but avoid it in real programs.
examples = ["playlist_lenght", "'Track ' + 7", "int('seven')",
"['rock', 'jazz'][5]", "{'tea': 2.1}['coffee']",
"'hello'.push('!')", "10 / 0"]
for code in examples:
try:
eval(code)
except Exception as error:
print(f"{code:<24} -> {type(error).__name__}: {error}")
Common mistake: a typo in a variable name (NameError)#
playlist_length = 42
print(f"Playlist runs for {playlist_lenght} minutes")
playlist_length = 42
print(f"Playlist runs for {playlist_length} minutes")
Common mistake: gluing text to a number (TypeError)#
age = 29
print("You are " + age + " years old")
age = 29
print("You are " + str(age) + " years old")
print(f"You are {age} years old")
ValueError: right type, wrong value#
int() happily takes a string, but only one that looks like a whole number.
for text in ["12", " 12 ", "12.5", "twelve"]:
try:
print(f"{text!r:<10} -> {int(text)}")
except ValueError as error:
print(f"{text!r:<10} -> ValueError: {error}")
while True:
answer = input("How many laps did you swim? ")
try:
laps = int(answer)
break
except ValueError:
print(f" '{answer}' isn't a whole number, try digits like 12.")
print(f"Great work: {laps} laps is {laps * 25} metres!")
IndexError and KeyError: asking for something that isn't there#
genres = ["rock", "jazz", "folk"]
stock = {"tea": 12, "latte": 30}
print("Last genre:", genres[-1], "| valid positions 0 to", len(genres) - 1)
print("Coffee in stock:", stock.get("coffee", 0))
Common mistake: calling a method that doesn't exist (AttributeError)#
shopping = ["milk", "bread"]
shopping.push("eggs")
shopping = ["milk", "bread"]
shopping.append("eggs")
print(shopping)
methods = [m for m in dir(shopping) if not m.startswith("_")]
print("List methods:", methods)
Common mistake: averaging an empty list (ZeroDivisionError)#
def average(scores):
return sum(scores) / len(scores)
print(average([7, 9, 8]))
print(average([]))
def average(scores):
if not scores:
return 0.0
return sum(scores) / len(scores)
print(average([7, 9, 8]))
print(average([]))
Common mistake: wonky indentation (IndentationError)#
Python checks the layout before it runs anything. compile() lets us test a code snippet safely.
snippet = "def greet():\nprint('Hello!')"
compile(snippet, "greet.py", "exec")
snippet = "def greet():\n print('Hello!')"
exec(compile(snippet, "greet.py", "exec"))
greet()
quiz = [("int('ten')", "ValueError"),
("[1, 2][9]", "IndexError"),
("len(5)", "TypeError")]
score = 0
for code, answer in quiz:
guess = input(f"Which error does {code} raise? ").strip()
if guess.lower() == answer.lower():
score += 1
print(" Correct!")
else:
print(f" Not quite, it's {answer}.")
print(f"Score: {score}/{len(quiz)}")
Your turn#
- Predict the error for
"abc"[3],None + 1and{}.append(1), then check with theevalloop. - Make
average()raise aValueErrorwith a helpful message instead of returning 0.0.
Print Debugging#
When code gives the wrong answer but no error, switch the lights on: print the values and watch them change.
def weekly_summary(minutes):
total = 0
active_days = 0
for m in minutes:
total = m
if m > 0:
active_days += 1
return total, round(total / active_days, 1)
workouts = [30, 0, 45, 20, 0, 60, 15]
print(weekly_summary(workouts))
total = 0
for day, m in enumerate(workouts, start=1):
total = m
print(f" day {day}: m={m:>2} total={total}")
def weekly_summary(minutes):
total = 0
active_days = 0
for m in minutes:
total += m
if m > 0:
active_days += 1
return total, round(total / active_days, 1)
print(weekly_summary(workouts))
Pro tip:
print(f"{name=}")prints the variable's name and value together, perfect for quick debugging.
total, avg = weekly_summary(workouts)
print(f"{total=}")
print(f"{avg=}")
typed = "42\n"
print("Really is: ", repr(typed))
print(typed == "42", typed.strip() == "42")
The logging Module#
print()is a torch you switch off by hand, line by line.loggingis a dimmer switch for the whole program. Five levels, quietest to loudest: DEBUG, INFO, WARNING, ERROR, CRITICAL.
import logging
import sys
logging.basicConfig(level=logging.DEBUG, format="%(levelname)-8s %(name)s: %(message)s",
stream=sys.stdout, force=True)
log = logging.getLogger("cafe")
log.debug("Grinding beans")
log.info("Shop is open")
log.warning("Oat milk running low")
log.error("Card machine offline")
log.critical("Coffee machine on fire")
log.setLevel(logging.WARNING)
log.debug("You won't see this")
log.info("Or this")
log.warning("But you will see this")
def process_orders(orders):
total = 0.0
for item in orders:
log.debug(f"Looking up {item}")
if item not in menu:
log.warning(f"Unknown item '{item}' skipped")
continue
total += menu[item]
log.info(f"Processed {len(orders)} orders, total {total:.2f}")
return round(total, 2)
log.setLevel(logging.INFO)
print("Total:", process_orders(["latte", "tea", "cake"]))
log.setLevel(logging.DEBUG)
print("Total:", process_orders(["tea", "flat white"]))
file_handler = logging.FileHandler("lesson_files/cafe.log", mode="w")
file_handler.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
log.addHandler(file_handler)
log.setLevel(logging.INFO)
process_orders(["latte", "muffin"])
file_handler.close()
log.removeHandler(file_handler)
print("Inside cafe.log:")
print(Path("lesson_files/cafe.log").read_text())
Quick check#
You're done debugging. How do you hide all the DEBUG and INFO messages without deleting a single line?
log.setLevel(logging.WARNING)
print(process_orders(["latte", "latte"]), "(no log lines: only warnings and worse are shown now)")
assert: Sanity Checks#
assertis a smoke alarm: silent while all is well, loud the moment something is wrong.
assert 2 + 2 == 4
assert "latte" in menu
print("No alarms. Everything is as expected.")
def apply_discount(price, percent):
assert 0 <= percent <= 100, f"percent must be 0-100, got {percent}"
return round(price * (1 - percent / 100), 2)
print(apply_discount(40, 25))
print(apply_discount(40, 150))
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0-100, got {percent}")
return round(price * (1 - percent / 100), 2)
try:
apply_discount(40, 150)
except ValueError as error:
print("Refused:", error)
Use assert for your own assumptions while developing. Use raise ValueError for bad input from users, because assert can be switched off with python -O.
assert apply_discount(40, 25) == 30.0
assert apply_discount(19.99, 0) == 19.99
print("All discount checks passed")
Testing with Plain Functions#
A test is a tiny robot that runs your code and checks the answer, every single time, without getting bored. Pattern: arrange the inputs, act by calling the function, assert the result.
def dog_food_grams(weight_kg, active=False):
grams = weight_kg * 20
if active:
grams *= 1.2
return round(grams)
print(dog_food_grams(5), dog_food_grams(10, active=True))
def test_small_dog():
result = dog_food_grams(5)
assert result == 100, f"expected 100, got {result}"
def test_active_dog():
result = dog_food_grams(10, active=True)
assert result == 240, f"expected 240, got {result}"
def test_puppy_weight_zero():
assert dog_food_grams(0) == 0
def run_tests(tests):
passed = 0
for test in tests:
try:
test()
print(f"PASS {test.__name__}")
passed += 1
except AssertionError as error:
print(f"FAIL {test.__name__}: {error}")
print(f"{passed}/{len(tests)} tests passed")
run_tests([test_small_dog, test_active_dog, test_puppy_weight_zero])
def test_negative_weight_refused():
try:
dog_food_grams(-3)
except ValueError:
return
raise AssertionError("negative weight should raise ValueError")
run_tests([test_small_dog, test_negative_weight_refused])
def dog_food_grams(weight_kg, active=False):
if weight_kg < 0:
raise ValueError("weight can't be negative")
grams = weight_kg * 20
if active:
grams *= 1.2
return round(grams)
all_tests = [test_small_dog, test_active_dog, test_puppy_weight_zero, test_negative_weight_refused]
run_tests(all_tests)
Your turn#
- Write
test_big_lazy_dogfor a 30 kg dog that isn't active, and add it toall_tests. - Write a test that checks
dog_food_grams(2.5)returns a whole number (isinstance(..., int)).
Mini-Project: Bug Hunt in the Plant Shop#
The plant shop's basket total gives odd answers. Write the expected answers first, then hunt. Rules: members get 10% off, then the voucher comes off, and the total never goes below zero.
def basket_total(prices, voucher=0, member=False):
total = sum(prices[1:])
if member:
total = total * 0.9
total - voucher
return round(total, 2)
print(basket_total([12.0, 8.5, 4.5]))
basket_cases = [
(([12.0, 8.5, 4.5],), 25.0),
(([12.0, 8.5, 4.5], 5), 20.0),
(([20.0, 10.0], 0, True), 27.0),
(([6.0], 10), 0.0),
]
def check_basket(function):
for args, expected in basket_cases:
got = function(*args)
status = "PASS" if got == expected else "FAIL"
print(f"{status} {str(args):<28} expected {expected:>5} got {got}")
check_basket(basket_total)
prices = [12.0, 8.5, 4.5]
print(f"{prices[1:]=}")
total = 25.0
total - 5
print(f"{total=} (the subtraction was thrown away)")
def basket_total(prices, voucher=0, member=False):
total = sum(prices)
if member:
total = total * 0.9
total = total - voucher
return round(max(total, 0), 2)
check_basket(basket_total)
pytest: The Professional Test Runner#
pytest finds every function whose name starts with
test_and runs them all. You just write plain asserts. Real projects keep code in one file and its tests intest_<name>.py.
Path("lesson_files/tips.py").write_text("""
def tip_amount(bill, percent=10):
return round(bill * percent / 100, 2)
def split_with_tip(bill, people, percent=10):
total = bill + tip_amount(bill, percent)
return round(total / people, 2)
""")
print("tips.py saved")
Path("lesson_files/test_tips.py").write_text("""
from tips import tip_amount, split_with_tip
def test_default_tip():
assert tip_amount(50) == 5.0
def test_custom_tip():
assert tip_amount(80, percent=15) == 12.0
def test_split_between_four():
assert split_with_tip(100, 4) == 27.5
""")
print("Test file written")
import subprocess
def run_pytest(target):
result = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "--tb=short", "-p", "no:cacheprovider", "--color=no", target],
capture_output=True, text=True)
print(result.stdout.strip())
run_pytest("lesson_files/test_tips.py")
with open("lesson_files/test_tips.py", "a") as f:
f.write("""
def test_split_between_three():
assert split_with_tip(100, 3) == 36.6
""")
run_pytest("lesson_files/test_tips.py")
text = Path("lesson_files/test_tips.py").read_text()
text = text.replace("== 36.6", "== 36.67")
Path("lesson_files/test_tips.py").write_text(text)
run_pytest("lesson_files/test_tips.py")
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
import pytest
print(0.1 + 0.2 == pytest.approx(0.3))
Path("lesson_files/test_tips_extra.py").write_text("""
import pytest
from tips import tip_amount, split_with_tip
@pytest.mark.parametrize("bill, percent, expected", [
(20, 10, 2.0),
(45, 20, 9.0),
])
def test_tip_table(bill, percent, expected):
assert tip_amount(bill, percent) == pytest.approx(expected)
def test_zero_people_crashes():
with pytest.raises(ZeroDivisionError):
split_with_tip(50, 0)
""")
run_pytest("lesson_files/test_tips_extra.py")
@pytest.mark.parametrize runs one test with many inputs. pytest.raises checks that an error happens.
In a terminal you'd simply type: python -m pytest
Type Hints#
Type hints are labels on storage boxes. Python doesn't check them, but every reader (and your editor) knows what belongs inside.
def bmi(weight_kg: float, height_m: float) -> float:
return round(weight_kg / height_m ** 2, 1)
print(bmi(70, 1.75))
print(bmi.__annotations__)
def double(x: int) -> int:
return x * 2
print(double(21))
print(double("ha"))
def total_calories(meals: dict[str, int]) -> int:
return sum(meals.values())
print(total_calories({"porridge": 350, "salad": 420, "curry": 680}))
members = {"amara": "premium", "jonas": "basic"}
def find_plan(name: str) -> str | None:
return members.get(name.lower())
print(find_plan("Amara"))
print(find_plan("Zed"))
Quick check#
double("ha") ran without complaint. So what are type hints actually for?
print("1. Humans: you see what goes in and what comes out at a glance.")
print("2. Editors: autocomplete, and warnings before you even run the code.")
print("3. Checkers like mypy can scan a whole project for type mistakes.")
Clean Code: PEP 8, Names and Docstrings#
Code is read far more often than it's written. Write for the tired person reading it at midnight (often future you). PEP 8 is Python's style guide:
snake_casefor variables and functions,PascalCasefor classes,UPPER_CASEfor constants- 4 spaces per indent, spaces around
=and operators, lines under about 79 characters
def CalcP(p,q,D=0):return p*q-D
x=CalcP(3.5,4,2)
print(x)
def calculate_price(unit_price, quantity, discount=0):
return unit_price * quantity - discount
order_price = calculate_price(3.5, 4, discount=2)
print(order_price)
INTENSITY_RATES = {"easy": 4, "moderate": 7, "hard": 11}
def calories_burned(minutes: int, intensity: str = "moderate") -> int:
"""Estimate the calories burned in a workout.
minutes: length of the workout
intensity: "easy", "moderate" or "hard"
"""
return round(minutes * INTENSITY_RATES[intensity])
print(calories_burned(30), calories_burned(45, "hard"))
help(calories_burned)
Pro tip: tools like
blackandrufftidy your style automatically:pip install ruff, thenruff check .in a terminal.
Your turn#
- Rename
def GetAvg(L):and its variables in PEP 8 style, then give it a one-line docstring. - Add type hints to
dog_food_gramsand check them withdog_food_grams.__annotations__.
Refactoring a Messy Function#
Refactoring is tidying the kitchen while every dish tastes exactly the same. Tests prove the dishes didn't change.
def report(d):
t = 0
f = 0
for x in d:
t = t + x[1]
if x[2] == "food":
f = f + x[1]
if t > 500:
s = "OVER BUDGET"
else:
s = "ok"
return "Total: " + str(round(t, 2)) + " | Food: " + str(round(f, 2)) + " | " + s
trip = [("hotel", 320.0, "stay"), ("pizza", 18.5, "food"),
("museum", 22.0, "fun"), ("tapas", 41.75, "food"),
("train", 64.0, "travel"), ("gelato", 7.2, "food")]
print(report(trip))
test_trips = [trip, trip + [("flight", 120.0, "travel")], []]
snapshots = [report(t) for t in test_trips]
for line in snapshots:
print(line)
BUDGET_LIMIT = 500
def total_spent(expenses, category=None):
"""Add up expenses, optionally only one category."""
return sum(amount for name, amount, kind in expenses
if category is None or kind == category)
def budget_status(total):
"""Say whether a total is within the budget."""
return "OVER BUDGET" if total > BUDGET_LIMIT else "ok"
def trip_report(expenses: list[tuple[str, float, str]]) -> str:
"""One-line summary of a trip's spending."""
total = total_spent(expenses)
food = total_spent(expenses, "food")
return f"Total: {round(total, 2)} | Food: {round(food, 2)} | {budget_status(total)}"
print(trip_report(trip))
for expenses, expected in zip(test_trips, snapshots):
assert trip_report(expenses) == expected, trip_report(expenses)
print("Refactor is safe: all", len(snapshots), "outputs identical")
Capstone: The Gym Membership Fee Calculator#
Members say their bills are wrong. Our job: reproduce the bugs, find them, fix them, then lock the fix in with tests.
- Monthly price: basic $25, standard $35, premium $50
- Students get 20% off; members aged 65+ get 15% off (only the bigger discount applies)
- $30 joining fee, waived on contracts of 12 months or more
PRICES = {"basic": 25, "standard": 35, "premium": 50}
def membership_fee(plan, months, age, student):
monthly = PRICES[plan]
if student:
monthly = monthly - 20
if age > 65:
monthly = monthly * 0.15
total = monthly * months
if months > 12:
total = total + 30
return total
print("Basic, 1 month, age 30:", membership_fee("basic", 1, 30, False))
membership_fee("Premium", 6, 40, False)
typed_plan = " Premium "
clean_plan = typed_plan.strip().lower()
print(repr(typed_plan), "->", repr(clean_plan))
print(membership_fee(clean_plan, 6, 40, False))
Step 1: work out the right answers by hand#
fee_cases = [
(("basic", 1, 30, False), 55.0), # 25 + 30 joining
(("basic", 12, 30, False), 300.0), # 25 x 12, fee waived
(("standard", 6, 21, True), 198.0), # 28 x 6 + 30
(("premium", 12, 70, False), 510.0), # 42.50 x 12
(("premium", 3, 66, True), 150.0), # student 20% wins: 40 x 3 + 30
(("basic", 12, 65, False), 255.0), # 65 counts as 65+
(("Premium ", 12, 40, False), 600.0), # messy typing still works
]
print(len(fee_cases), "hand-checked cases")
def check_fees(fee_function):
passed = 0
for args, expected in fee_cases:
try:
got = round(fee_function(*args), 2)
except Exception as error:
got = type(error).__name__
ok = got == expected
passed += ok
print(f"{'PASS' if ok else 'FAIL'} {str(args):<32} want {expected:>6} got {got}")
print(f"{passed}/{len(fee_cases)} passed")
check_fees(membership_fee)
Step 2: shine a light on the failures#
plan, months, age, student = "standard", 6, 21, True
monthly = PRICES[plan]
monthly = monthly - 20
print(f"after student discount {monthly=} (want 28.0: 20% off, not $20 off)")
monthly = PRICES["premium"]
print(f"{70 > 65=} {65 > 65=} (65 should count)")
print(f"{monthly * 0.15=} (that's the discount, not the price)")
print(f"{monthly * 0.85=} (the price after 15% off)")
Step 3: fix it in a module, clean and commented#
Path("lesson_files/gym_fees.py").write_text("""
PRICES = {"basic": 25, "standard": 35, "premium": 50}
STUDENT_DISCOUNT = 0.20
SENIOR_DISCOUNT = 0.15
SENIOR_AGE = 65
JOINING_FEE = 30
FEE_FREE_MONTHS = 12
def membership_fee(plan: str, months: int, age: int, student: bool) -> float:
plan = plan.strip().lower()
if plan not in PRICES:
raise ValueError(f"Unknown plan '{plan}'. Choose from: {', '.join(PRICES)}")
discount = 0.0
if student:
discount = STUDENT_DISCOUNT
if age >= SENIOR_AGE:
discount = max(discount, SENIOR_DISCOUNT)
monthly = PRICES[plan] * (1 - discount)
total = monthly * months
if months < FEE_FREE_MONTHS:
total += JOINING_FEE
return round(total, 2)
""")
print("gym_fees.py saved:", len(Path("lesson_files/gym_fees.py").read_text().splitlines()), "lines")
sys.path.insert(0, "lesson_files")
import gym_fees
check_fees(gym_fees.membership_fee)
try:
gym_fees.membership_fee("platinum", 6, 30, False)
except ValueError as error:
print("Refused:", error)
Step 4: lock it in with pytest#
Path("lesson_files/test_gym_fees.py").write_text("""
import pytest
from gym_fees import membership_fee
@pytest.mark.parametrize("plan, months, age, student, expected", [
("basic", 1, 30, False, 55.0),
("basic", 12, 30, False, 300.0),
("standard", 6, 21, True, 198.0),
("premium", 12, 70, False, 510.0),
("premium", 3, 66, True, 150.0),
("basic", 12, 65, False, 255.0),
("Premium ", 12, 40, False, 600.0),
])
def test_fees(plan, months, age, student, expected):
assert membership_fee(plan, months, age, student) == pytest.approx(expected)
def test_unknown_plan_is_refused():
with pytest.raises(ValueError):
membership_fee("platinum", 6, 30, False)
""")
run_pytest("lesson_files/test_gym_fees.py")
print(f"{'Plan':<10}{'1 month':>11}{'6 months':>11}{'12 months':>11}")
for plan in gym_fees.PRICES:
fees = [f"${gym_fees.membership_fee(plan, m, 30, False):.2f}" for m in (1, 6, 12)]
print(f"{plan:<10}" + "".join(f"{fee:>11}" for fee in fees))
plan = input("Which plan (basic/standard/premium)? ")
age = int(input("Your age? "))
student = input("Student? (y/n) ").strip().lower() == "y"
months = int(input("How many months? "))
fee = gym_fees.membership_fee(plan, months, age, student)
print("-" * 32)
print(f"Plan: {plan.strip().title()}")
print(f"Contract: {months} months")
print(f"Total: ${fee:.2f}")
Recap#
- Read tracebacks from the bottom up: error type, crashing line, then the chain of calls
- NameError, TypeError, ValueError, IndexError, KeyError, AttributeError, ZeroDivisionError, IndentationError
- Print debugging with
f"{name=}"andrepr()to see hidden whitespace logginglevels and handlers: turn detail up or down without deleting linesassertfor your own assumptions,raise ValueErrorfor bad input- Tests: plain
test_functions, then pytest withparametrize,approxandraises - Type hints, PEP 8 names, constants and docstrings make code easy to read
- Refactor in small steps, with saved outputs proving nothing changed
Try it yourself#
- Add a "family" plan at $80 a month for up to 4 people, plus tests for it.
- Give
membership_feealogthat records each discount applied at DEBUG level. - Add a 10% loyalty discount for contracts of 24 months or more, written test-first: write the failing test, then make it pass.
What's next#
Lesson 6: Text Wrangling: f-strings, Regular Expressions and Unicode. You'll format perfect reports, hunt for patterns like emails and phone numbers, and tame messy text from the real world.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



