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.
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
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.
processtells you nothing about what this function does. A name likecategorize_inputorclassify_user_datawould 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
datacontains a non-string item likeNone, the"@" in itemcheck will throw aTypeError, 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
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
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
Nonefor any argument will crash the function with aTypeError.
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
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 ajson.JSONDecodeError, the file handle is never closed. Thef.close()call is unreachable in the error path. Using awithstatement guarantees cleanup regardless of exceptions. - Redundant read step.
json.loads()takes a string, butjson.load()reads directly from a file object. The intermediatedatavariable is unnecessary. - No error handling. A missing file will raise an unhandled
FileNotFoundError. Corrupted JSON will raise aJSONDecodeError. 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
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 ofUserManagershares 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
bcryptor Python's built-inhashlib. - 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
registermethod 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
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
Exceptionand continuing silently means any error -- aKeyErrorfrom a missing field, aTypeErrorfrom unexpected data, even aMemoryError-- 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,continuemeans 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
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
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
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
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.
- 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)?
- Error handling. Are exceptions specific (not bare
except)? Are errors logged or raised with useful messages? Aretryblocks scoped tightly around only the code that might fail? - Resource management. Are files, database connections, and network sockets opened with
withstatements? Will resources be cleaned up if an exception occurs mid-function? - 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?
- Mutability traps. Are mutable objects (lists, dicts, sets) used as default arguments? Are class-level mutable attributes being shared across instances unintentionally?
- 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? - Idiomatic Python. Does the code use f-strings for formatting,
enumerate()instead of manual counters,pathlibinstead of string-based path manipulation, and comprehensions where they improve clarity? - Documentation. Do public functions have docstrings? Are type hints present? Is the purpose of complex logic explained with comments?
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
- 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.
- 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.
- 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.
- 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.
- 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.