Python Code Review Exercises

Reading code is a skill that separates competent developers from great ones. These exercises present flawed Python code for you to analyze, critique, and rewrite. Each one targets a real pattern that shows up in production codebases, and each one will make you a sharper programmer.

Writing code that works is one thing. Writing code that another developer can read, maintain, and trust is something else entirely. Code review is where these two goals collide, and it is one of the fastest ways to level up as a Python developer. The exercises in this article simulate real review scenarios. You will see buggy functions, questionable design choices, and security gaps. Your job is to find the problems, explain why they matter, and propose a better version.

Why Practice Code Review

Code review is not just about catching typos. In a team setting, it functions as the last line of defense before code reaches production. It is where architecture decisions get challenged, readability gets enforced, and hidden bugs get caught. Python's flexibility as a dynamically typed language means the interpreter will not flag many of the mistakes that a careful reviewer would. Bad code can run without errors for months before it causes a problem at scale.

Practicing code review trains your eye to notice things that automated tools miss. Linters like Flake8 and formatters like Black handle style and formatting, but they cannot evaluate whether a function does too many things, whether an exception handler is hiding real errors, or whether a piece of logic will break under unexpected input. That kind of analysis requires a human, and it requires practice.

Note

For each exercise below, read the code carefully before expanding the solution. Try to identify every issue on your own first. The goal is to build your instincts, not just memorize answers.

Beginner Exercises

These exercises focus on foundational Python issues: naming conventions, basic error handling, PEP 8 compliance, and common beginner pitfalls. If you are newer to Python, start here and work through each one before moving on.

Exercise 1: The Catch-All Function

Review This Code

Data Processing Utility

A junior developer wrote this function to process a mixed list of user input. Review it and identify as many issues as you can.

def process(data):
    for item in data:
        if "@" in item:
            print("Email: " + item)
        else:
            try:
                num = int(item)
                print("Number: " + str(num * 2))
            except:
                print("Other: " + item)
Reveal Solution

Issues found:

  • Vague function name. process tells you nothing about what this function does. A name like categorize_input or classify_user_data would communicate intent.
  • No return value. The function uses print() for output instead of returning structured data. This makes the function impossible to test and unusable in any pipeline.
  • Bare except clause. Catching every exception silently hides real errors. If data contains a non-string item like None, the "@" in item check will throw a TypeError, and it will be swallowed.
  • Naive email detection. Checking for @ alone would match strings like "price@100" that are not email addresses.
  • No docstring or type hints. The function signature gives no indication of expected input or output types.

Improved version:

def classify_user_data(data: list[str]) -> dict[str, list]:
    """Classify a list of string inputs into emails, numbers, and other values."""
    results = {"emails": [], "numbers": [], "other": []}

    for item in data:
        if "@" in item and "." in item.split("@")[-1]:
            results["emails"].append(item)
        else:
            try:
                results["numbers"].append(int(item) * 2)
            except ValueError:
                results["other"].append(item)

    return results

Exercise 2: The Dangerous Default

Review This Code

Shopping Cart Builder

This function is supposed to create independent shopping carts for different users. Something unexpected happens when it is called multiple times. Find the bug.

def create_cart(item, cart=[]):
    cart.append(item)
    return cart

# Expected: each call creates a new cart
alice_cart = create_cart("notebook")
bob_cart = create_cart("pen")

print(alice_cart)  # ['notebook', 'pen']  -- wrong!
print(bob_cart)    # ['notebook', 'pen']  -- wrong!
Reveal Solution

The bug: Mutable default arguments in Python are evaluated once at function definition time, not on each call. Every invocation of create_cart that relies on the default shares the same list object. This is one of the more well-known Python gotchas, and it causes real production bugs when developers use default lists or dictionaries as function parameters.

Improved version:

def create_cart(item: str, cart: list[str] | None = None) -> list[str]:
    """Add an item to a cart. Creates a new cart if none is provided."""
    if cart is None:
        cart = []
    cart.append(item)
    return cart

The fix is to use None as the default and create a new list inside the function body. This pattern applies to any mutable default: lists, dictionaries, and sets should all use None instead.

Exercise 3: String Building the Hard Way

Review This Code

Log Message Formatter

This function builds a formatted log string. It works, but the approach has performance and readability problems. Identify them.

def format_log(level, timestamp, user, message):
    log = ""
    log = log + "[" + level + "]"
    log = log + " " + timestamp
    log = log + " | User: " + user
    log = log + " | " + message
    return log
Reveal Solution

Issues found:

  • Repeated string concatenation. Each + operation creates a new string object. For a simple function like this the performance hit is negligible, but the pattern is a bad habit that scales poorly when used in loops or with large datasets.
  • Poor readability. The structure of the final output is not obvious from the code. You have to mentally trace through each concatenation to understand the format.
  • No type hints or docstring.
  • No input validation. Passing None for any argument will crash the function with a TypeError.

Improved version:

def format_log(level: str, timestamp: str, user: str, message: str) -> str:
    """Format a structured log entry string."""
    return f"[{level}] {timestamp} | User: {user} | {message}"

F-strings are the idiomatic way to build formatted strings in modern Python. They are faster than concatenation, and the output format is immediately visible in the code.

Intermediate Exercises

These exercises introduce more nuanced problems: file handling, resource management, class design, and logical errors that pass casual inspection. You will need to think about edge cases and how the code behaves under failure conditions.

Exercise 4: The Leaky File Handler

Review This Code

JSON Configuration Loader

This function reads a JSON configuration file. It has a resource management problem and several other issues. Find them all.

import json

def load_config(filepath):
    f = open(filepath, "r")
    data = f.read()
    config = json.loads(data)
    f.close()
    return config
Reveal Solution

Issues found:

  • No context manager. If json.loads() throws a json.JSONDecodeError, the file handle is never closed. The f.close() call is unreachable in the error path. Using a with statement guarantees cleanup regardless of exceptions.
  • Redundant read step. json.loads() takes a string, but json.load() reads directly from a file object. The intermediate data variable is unnecessary.
  • No error handling. A missing file will raise an unhandled FileNotFoundError. Corrupted JSON will raise a JSONDecodeError. Neither is caught or given a useful error message.
  • No type hints or docstring.

Improved version:

import json
from pathlib import Path

def load_config(filepath: str | Path) -> dict:
    """Load and return a JSON configuration file as a dictionary.

    Raises:
        FileNotFoundError: If the config file does not exist.
        json.JSONDecodeError: If the file contains invalid JSON.
    """
    filepath = Path(filepath)
    if not filepath.exists():
        raise FileNotFoundError(f"Config file not found: {filepath}")

    with open(filepath, "r", encoding="utf-8") as f:
        return json.load(f)

Exercise 5: The Class That Does Too Much

Review This Code

User Account Manager

This class handles user registration. Review it for design problems, security flaws, and violations of good Python practices.

import json

class UserManager:
    users = []

    def register(self, username, password):
        for u in self.users:
            if u["username"] == username:
                print("User exists!")
                return False
        self.users.append({
            "username": username,
            "password": password
        })
        with open("users.json", "w") as f:
            f.write(json.dumps(self.users))
        print("User registered!")
        return True

    def login(self, username, password):
        for u in self.users:
            if u["username"] == username and u["password"] == password:
                print("Login successful!")
                return True
        print("Invalid credentials!")
        return False
Reveal Solution

Issues found:

  • Class-level mutable attribute. users = [] defined at the class level means every instance of UserManager shares the same list. This is the class-variable version of the mutable default argument problem from Exercise 2.
  • Plaintext passwords. Storing passwords as raw strings is a critical security flaw. Passwords should be hashed using a library like bcrypt or Python's built-in hashlib.
  • Print statements instead of logging or exceptions. Using print() for error reporting makes the class impossible to integrate with any real application.
  • File I/O mixed with business logic. The register method handles validation, storage in memory, and file persistence all at once. These responsibilities should be separated.
  • Linear search for duplicate checking. Iterating through the full list to check for existing users is O(n). A dictionary or set lookup would be O(1).
  • No __init__ method. Instance-level state should be initialized in the constructor.
  • No type hints or docstrings.

Improved version (partial -- focusing on structure):

import hashlib
import secrets

class UserManager:
    def __init__(self):
        self._users: dict[str, str] = {}

    def register(self, username: str, password: str) -> None:
        """Register a new user with a hashed password."""
        if username in self._users:
            raise ValueError(f"Username already exists: {username}")

        salt = secrets.token_hex(16)
        hashed = hashlib.sha256((salt + password).encode()).hexdigest()
        self._users[username] = f"{salt}:{hashed}"

    def authenticate(self, username: str, password: str) -> bool:
        """Verify credentials against stored hash."""
        if username not in self._users:
            return False

        stored = self._users[username]
        salt, expected_hash = stored.split(":")
        actual_hash = hashlib.sha256((salt + password).encode()).hexdigest()
        return secrets.compare_digest(actual_hash, expected_hash)

Exercise 6: The Silent Failure

Review This Code

API Response Processor

This function processes API responses and extracts user data. It has a subtle bug that will cause silent data loss. Can you spot it?

def extract_users(api_responses):
    users = {}
    for response in api_responses:
        try:
            data = response["data"]
            for user in data["users"]:
                users[user["email"]] = {
                    "name": user["name"],
                    "role": user["role"]
                }
        except Exception:
            continue
    return users
Reveal Solution

Issues found:

  • Overly broad exception handling. Catching Exception and continuing silently means any error -- a KeyError from a missing field, a TypeError from unexpected data, even a MemoryError -- gets swallowed without a trace. If one response in a list of a thousand is malformed, you will never know data was skipped.
  • Silent data loss via continue. Combined with the broad except, continue means the function never reports partial failures. This makes debugging extremely difficult in production.
  • Duplicate email overwrites. If two responses contain the same email address with different data, the second one silently overwrites the first. This may or may not be intentional, but it should be documented.
  • No logging. Errors should at minimum be logged so you can trace data quality issues.

Improved version:

import logging

logger = logging.getLogger(__name__)

def extract_users(api_responses: list[dict]) -> dict[str, dict]:
    """Extract user records from a list of API response payloads."""
    users = {}

    for i, response in enumerate(api_responses):
        try:
            data = response["data"]
        except (KeyError, TypeError) as e:
            logger.warning("Response %d has invalid structure: %s", i, e)
            continue

        for user in data.get("users", []):
            try:
                email = user["email"]
                if email in users:
                    logger.info("Duplicate email %s in response %d, overwriting", email, i)
                users[email] = {
                    "name": user["name"],
                    "role": user.get("role", "unknown"),
                }
            except KeyError as e:
                logger.warning("User record missing required field %s in response %d", e, i)

    return users
Pro Tip

When reviewing exception handling, ask yourself: "If this fails, how will I know?" If the answer is "I won't," the error handling needs work. A try/except that catches everything and does nothing is often worse than no error handling at all.

Advanced Exercises

These exercises tackle security, concurrency, and architectural problems. They require you to think about how code behaves at scale, under adversarial conditions, and across different execution contexts.

Exercise 7: The Injection Vector

Review This Code

Database Query Builder

This function queries a SQLite database. It has a critical security vulnerability. Identify it and explain the consequences.

import sqlite3

def get_user(username):
    conn = sqlite3.connect("app.db")
    cursor = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    result = cursor.fetchone()
    conn.close()
    return result
Reveal Solution

Critical issue: SQL injection. The username is inserted directly into the query string using an f-string. An attacker could pass a value like ' OR '1'='1 to bypass authentication, or '; DROP TABLE users; -- to destroy data. This is one of the oldest and most dangerous classes of web application vulnerabilities.

Additional issues:

  • No context manager for the database connection. If fetchone() raises an exception, the connection is never closed.
  • Hardcoded database path. The connection string should come from configuration, not a literal in the function body.
  • SELECT * is wasteful. Fetching all columns when you may only need a few wastes memory and exposes unnecessary data.

Improved version:

import sqlite3
from pathlib import Path

def get_user(username: str, db_path: str | Path = "app.db") -> tuple | None:
    """Retrieve a user record by username using parameterized queries."""
    with sqlite3.connect(db_path) as conn:
        cursor = conn.cursor()
        cursor.execute(
            "SELECT id, username, email, role FROM users WHERE username = ?",
            (username,)
        )
        return cursor.fetchone()

Parameterized queries (using ? placeholders) let the database driver handle escaping. The user input never becomes part of the SQL statement itself, which eliminates the injection vector entirely.

Exercise 8: The Race Condition

Review This Code

Thread-Safe Counter

This class is supposed to be used across multiple threads to track event counts. It has a concurrency bug that will produce incorrect results under load. Find it.

import threading

class EventCounter:
    def __init__(self):
        self.counts = {}

    def record_event(self, event_name):
        if event_name not in self.counts:
            self.counts[event_name] = 0
        self.counts[event_name] += 1

    def get_count(self, event_name):
        return self.counts.get(event_name, 0)
Reveal Solution

The bug: race condition. The record_event method performs a check-then-act sequence (check if the key exists, then increment) that is not atomic. When two threads call record_event("click") simultaneously, the following can happen:

  • Thread A reads self.counts["click"] as 5
  • Thread B reads self.counts["click"] as 5
  • Thread A writes 6
  • Thread B writes 6

One increment is lost. Over thousands of events, the count will drift significantly from the real number.

Improved version:

import threading
from collections import defaultdict

class EventCounter:
    def __init__(self):
        self._counts: dict[str, int] = defaultdict(int)
        self._lock = threading.Lock()

    def record_event(self, event_name: str) -> None:
        """Increment the count for the given event in a thread-safe manner."""
        with self._lock:
            self._counts[event_name] += 1

    def get_count(self, event_name: str) -> int:
        """Return the current count for an event."""
        with self._lock:
            return self._counts[event_name]

The threading.Lock ensures that only one thread at a time can read or modify the counts dictionary. Using defaultdict(int) also eliminates the need for the existence check, since missing keys automatically start at 0.

Exercise 9: The Hidden Performance Trap

Review This Code

Large Dataset Deduplication

This function removes duplicate entries from a large list of records. It works correctly on small inputs, but becomes unusable on datasets with more than a few thousand items. Explain why and fix it.

def deduplicate(records):
    unique = []
    for record in records:
        if record not in unique:
            unique.append(record)
    return unique

# Used like this:
clean_data = deduplicate(raw_emails)  # raw_emails has 500,000 entries
Reveal Solution

The performance trap: The in operator on a list performs a linear scan. For each of the 500,000 records, Python checks every item already in unique to determine membership. This makes the function O(n^2) -- for half a million records, that is up to 250 billion comparisons in the worst case.

Improved version:

def deduplicate(records: list[str]) -> list[str]:
    """Remove duplicate entries while preserving original order."""
    seen: set[str] = set()
    unique: list[str] = []
    for record in records:
        if record not in seen:
            seen.add(record)
            unique.append(record)
    return unique

Using a set for membership checks brings each lookup down to O(1) average time, making the entire function O(n). For 500,000 records, this is the difference between hours and milliseconds.

If order does not matter, an even simpler approach works:

def deduplicate(records: list[str]) -> list[str]:
    """Remove duplicates (order not guaranteed)."""
    return list(set(records))

Your Code Review Checklist

Use this checklist as a quick reference when reviewing any Python code, whether it is your own or a teammate's. Each item represents a category of issues that shows up repeatedly in real codebases.

  1. Naming. Are function names, variables, and classes descriptive enough that the code reads like documentation? Does the naming follow PEP 8 conventions (snake_case for functions and variables, PascalCase for classes)?
  2. Error handling. Are exceptions specific (not bare except)? Are errors logged or raised with useful messages? Are try blocks scoped tightly around only the code that might fail?
  3. Resource management. Are files, database connections, and network sockets opened with with statements? Will resources be cleaned up if an exception occurs mid-function?
  4. Security. Is user input ever interpolated directly into SQL, shell commands, or file paths? Are passwords stored in plaintext? Are secrets hardcoded in the source?
  5. Mutability traps. Are mutable objects (lists, dicts, sets) used as default arguments? Are class-level mutable attributes being shared across instances unintentionally?
  6. Performance. Are there membership checks on lists that should be sets? String concatenation in loops that should use join()? Nested loops that could be replaced with dictionary lookups?
  7. Idiomatic Python. Does the code use f-strings for formatting, enumerate() instead of manual counters, pathlib instead of string-based path manipulation, and comprehensions where they improve clarity?
  8. Documentation. Do public functions have docstrings? Are type hints present? Is the purpose of complex logic explained with comments?
Warning

Automated linters catch style issues, but they cannot evaluate logic, architecture, or security. Never treat a passing linter check as a substitute for a real code review.

Key Takeaways

  1. Code review is a trainable skill. The more you practice reading and critiquing code, the faster you will spot problems in your own work and in pull requests.
  2. Catch bugs at the pattern level. Many Python bugs follow predictable patterns: mutable defaults, bare excepts, missing context managers, and SQL injection via string formatting. Learning to recognize these patterns is more valuable than memorizing individual fixes.
  3. Think about failure modes. For every function you review, ask: what happens if the input is empty, None, malformed, or malicious? Code that only works on the happy path is incomplete code.
  4. Prioritize correctness and safety over style. A function with imperfect naming but solid error handling is better than a beautifully named function that hides exceptions. Focus your review energy on the issues that will cause real harm first.
  5. Make your feedback constructive. The goal of code review is to improve the code and help the author grow. Explain why something is a problem and suggest a concrete alternative, rather than just pointing out what is wrong.

These exercises cover a fraction of what you will encounter in real-world Python code, but the patterns they teach will carry over into every codebase you work in. Return to them periodically, and try writing your own review exercises for code patterns you encounter at work. The best way to sharpen your review instincts is to keep practicing.

back to articles