Common Error Anti-Patterns - Mistakes That Fail Silently in Production
Reading time: ~22 minutes | Level: Foundation → Engineering
Here is the most dangerous line of Python you will ever write:
try:
result = process_payment(order)
except:
pass
No exception is ever surfaced. No log is ever written. The payment silently fails. The user is charged but receives nothing. The business loses money. The on-call engineer is paged at 3am with zero diagnostic information.
This is not a hypothetical. It is a real production incident pattern seen in codebases at every experience level.
The code above contains exactly two characters - : pass - that erase all observability from the system. This lesson catalogues the 10 anti-patterns like it: code that compiles, runs, produces no immediate error, and destroys your ability to understand what your system is doing.
This is the capstone lesson of the error-handling module. By the end, you will recognize these patterns on sight.
What You Will Learn
- The 10 most dangerous Python error-handling anti-patterns, each with name, bad code, root cause, and fix
- Why swallowing exceptions is the number one cause of mysterious production failures
- The difference between catching broadly and catching precisely, and why it matters
- How
raise NewError(str(e))destroys traceback context and whatraise from edoes instead - When using exceptions for flow control is harmful (and when it is actually Pythonic)
- Why unclosed resources cause data corruption and intermittent test failures
- The mutable default argument trap and why it surprises even experienced Python developers
- Boolean blindness: when
if x:silently ignores valid falsy values like0and"" - Why ignoring return values from functions like
list.sort()causes subtle bugs - The importance of logging before re-raising during cleanup exception handlers
Prerequisites
- Completed the earlier lessons in this error-handling module
- Understanding of Python exceptions, try/except/finally, and raise
- Familiarity with Python built-in types: lists, dicts, sets, context managers
Overview: The Anti-Pattern Map
| Category | Anti-patterns |
|---|---|
| Silent failures (worst - no idea something went wrong) | 1: Swallowing exceptions (bare except: pass); 2: Catching too broadly (except Exception); 10: Catch-cleanup-reraise without logging |
| Lost context (you know it failed, but not why or where) | 3: Losing exception chain (raise NewError(str(e))); 9: Boolean blindness (if x: misses falsy values) |
| Wrong tool (the language provides a better mechanism) | 4: Exceptions for flow control; 8: Ignoring return values (sort() returns None) |
| Resource bugs (data corruption, fd leaks, test flakiness) | 5: Not cleaning up resources (no with) |
| Initialization bugs (shared state across calls - hardest to find) | 6: Mutable default arguments |
| Type bugs (work in dev, break on edge-case production data) | 7: Silent type coercion expectations |
Anti-Pattern 1 - Swallowing Exceptions
Name: The Silent Swallow
The Bad Code
def save_user_profile(user_id: int, data: dict) -> bool:
try:
db.update("users", where={"id": user_id}, values=data)
cache.invalidate(f"user:{user_id}")
return True
except:
pass # "I'll handle this later"
return False
Why It Fails in Production
When this function returns False:
- Is the database down?
- Did
datacontain invalid types that the ORM rejected? - Did
cache.invalidate()raise a connection timeout? - Did
user_idnot exist in the database? - Was there a syntax error in the
valuesargument? - Was there a network partition?
You have no idea. The except: pass silently discards the exception type, the exception message, and the traceback. Every call that fails returns False and you cannot distinguish the cause.
Worse, the bare except: (without specifying an exception type) catches everything - including SystemExit, KeyboardInterrupt, GeneratorExit, and MemoryError. It can even hide bugs in the except block itself.
The Correct Code
import logging
logger = logging.getLogger(__name__)
def save_user_profile(user_id: int, data: dict) -> bool:
try:
db.update("users", where={"id": user_id}, values=data)
cache.invalidate(f"user:{user_id}")
return True
except DatabaseError as e:
logger.exception("Database update failed for user %d", user_id)
return False
except CacheError as e:
# Data was saved; only cache invalidation failed - a non-critical degradation
logger.warning("Cache invalidation failed for user %d: %s", user_id, e)
return True # The primary operation succeeded
Now when something fails:
- You know exactly which operation failed (database or cache)
- You have the full traceback via
logger.exception() - The return value distinguishes total failure from partial degradation
- A support engineer can find the root cause in seconds from the log
:::danger The Bare except: Rule
Never use a bare except: (without specifying exception types). It catches SystemExit, KeyboardInterrupt, MemoryError, and GeneratorExit - system-level signals that Python raises to shut down the process. Swallowing them prevents the program from terminating cleanly.
If you must catch a broad range, use except Exception: at minimum. But see anti-pattern 2 for why that is also problematic.
:::
Anti-Pattern 2 - Catching Too Broadly
Name: The Overzealous Handler
The Bad Code
def parse_user_age(raw_value: str) -> int:
try:
return int(raw_value)
except Exception:
return 0 # "Safe" default
Why It Fails in Production
int(raw_value) raises ValueError when the string is not a valid integer. That is the one case this code is trying to handle.
But except Exception also catches:
TypeError- ifraw_valueisNone(caller bug, not a parse error)OverflowError- ifraw_valueis a valid integer but too large for the platform- Any future exception type added to the
int()implementation
When a caller passes None instead of a string, you silently return 0 and continue - hiding a programming mistake that should be surfaced immediately. You have masked a bug in the caller by catching an exception your handler was never designed for.
The Correct Code
def parse_user_age(raw_value: str) -> int:
"""
Parse a string to an integer age.
Raises ValueError for invalid strings.
Let TypeError propagate if raw_value is not a string (caller bug).
"""
try:
age = int(raw_value)
except ValueError:
raise ValueError(
f"Cannot parse age from {raw_value!r}: expected a numeric string"
) from None
if age < 0 or age > 150:
raise ValueError(f"Age {age} is out of valid range [0, 150]")
return age
Rule: Catch the minimum set of exceptions your code is actually designed to handle. Any other exception should propagate naturally - it represents an unexpected condition that deserves investigation.
| Catch clause | Guidance |
|---|---|
except: | NEVER - catches KeyboardInterrupt, SystemExit, and all bugs |
except Exception: | ALMOST NEVER - catches everything including programming bugs |
except (A, B): | Sometimes - when both need the same handling |
except ValueError: | GOOD - specific, matches known failure mode |
Anti-Pattern 3 - Losing Exception Context
Name: The Traceback Eraser
The Bad Code
class UserNotFoundError(Exception):
pass
def get_user(user_id: int) -> dict:
try:
row = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if not row:
raise UserNotFoundError(f"User {user_id} not found")
return row
except DatabaseError as e:
# Wrap database errors in a domain error
raise UserNotFoundError(str(e)) # BUG: context is lost
Why It Fails in Production
raise UserNotFoundError(str(e)) destroys the original traceback. The new exception has no knowledge that a DatabaseError was the cause. In the log, you see:
UserNotFoundError: connection refused to host 10.0.0.1:5432
But you do not know:
- What the original exception type was (
DatabaseError, specificallypsycopg2.OperationalError) - What the original stack trace looked like (which layer failed)
- That a database connection failure is fundamentally different from a user not existing
If the on-call engineer sees UserNotFoundError in production, they investigate user data. The real problem is the database connection - a completely different domain.
The Correct Code
class DatabaseUnavailableError(Exception):
"""Raised when the database is not accessible."""
pass
class UserNotFoundError(Exception):
"""Raised when the requested user does not exist."""
pass
def get_user(user_id: int) -> dict:
try:
row = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
except DatabaseError as e:
# Chain the exceptions: the new error CAUSED BY the original
raise DatabaseUnavailableError(
f"Database unavailable while fetching user {user_id}"
) from e # ← This preserves the original exception as __cause__
if not row:
raise UserNotFoundError(f"User {user_id} not found")
return row
With raise X from e, Python stores e as X.__cause__ and shows both:
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
DatabaseUnavailableError: Database unavailable while fetching user 42
During handling of the above exception, another exception occurred:
[original DatabaseError traceback here]
Now the on-call engineer sees the full chain: the database connection failed, which caused the DatabaseUnavailableError. The root cause is immediately obvious.
Exception Chaining Quick Reference
# Explicit chaining: "This error was CAUSED BY the original"
raise NewError("message") from original_exception
# Explicit suppression: "Raise this independently, suppress the original context"
raise NewError("message") from None
# Implicit chaining (automatic, happens by default inside except blocks)
# Python sets __context__ automatically - you rarely need to think about this
raise NewError("message") # __context__ = original exception (implicit)
Anti-Pattern 4 - Using Exceptions for Flow Control
Name: The Exception Abuser
The Bad Code
def get_config_value(config: dict, key: str, default=None):
try:
return config[key]
except KeyError:
return default
def is_valid_integer(text: str) -> bool:
try:
int(text)
return True
except ValueError:
return False
When It Is Actually a Problem
Both examples above are arguably fine in Python - the EAFP (Easier to Ask Forgiveness than Permission) style is idiomatic in Python for some cases. The problem is when exceptions are used for logic that has a direct, non-exception equivalent:
# BAD: forcing exceptions to do what .get() does naturally
def process_all_users(users: list):
results = []
for i in range(len(users) + 10): # Intentionally too many
try:
results.append(process(users[i]))
except IndexError:
break # "We ran out of items" - use len() instead!
return results
This uses exceptions as a loop termination condition. Python provides range(len(users)) for exactly this - no exception needed.
# BAD: exceptions as a type-checking mechanism
def double(value):
try:
return value * 2
except TypeError:
return str(value) * 2 # If it's not numeric, treat it as a string
This uses exceptions as type dispatch. The logic is hidden in the exception handler. Use isinstance() instead.
The Correct Code - Know When Each Style Applies
# GOOD: use .get() for dict key lookups with defaults
def get_config_value(config: dict, key: str, default=None):
return config.get(key, default) # No exception, no try/except
# GOOD: use explicit iteration over the collection
def process_all_users(users: list):
return [process(user) for user in users]
# GOOD: use isinstance() for type dispatch
def double(value):
if isinstance(value, (int, float)):
return value * 2
elif isinstance(value, str):
return value * 2
else:
raise TypeError(f"Cannot double {type(value).__name__}")
# ACCEPTABLE: EAFP style when checking + accessing would be redundant
def parse_json_field(text: str) -> dict | None:
try:
return json.loads(text)
except json.JSONDecodeError:
return None # Parsing failed - return None as sentinel
:::tip EAFP vs LBYL
Python's culture favors EAFP (Easier to Ask Forgiveness than Permission) over LBYL (Look Before You Leap) for cases where the "happy path" is common and checking in advance would be redundant. File access, dict lookups, and type conversions are canonical EAFP cases. But using exceptions as a for loop control flow or type dispatch mechanism obscures logic and imposes exception overhead on the hot path. Use judgment.
:::
Anti-Pattern 5 - Not Cleaning Up Resources
Name: The Resource Leak
The Bad Code
def process_log_file(filename: str) -> list:
f = open(filename) # File opened
lines = f.readlines() # Read content
results = parse_lines(lines)
f.close() # Might never run!
return results
def write_report(filename: str, data: dict) -> None:
f = open(filename, "w")
json.dump(data, f) # If json.dump raises, f is never closed
f.close()
Why It Fails in Production
If parse_lines(lines) raises an exception, execution jumps out of process_log_file immediately - f.close() is never called. The file descriptor is leaked.
On most operating systems:
- Each process has a limit of ~1024 open file descriptors
- Leaked file descriptors accumulate over time
- Eventually the process hits the limit and cannot open any new files
- The server starts failing with
OSError: [Errno 24] Too many open files - This error appears hours or days after the bug was introduced - on a Friday night
The Correct Code - Always Use with
def process_log_file(filename: str) -> list:
with open(filename) as f: # Context manager guarantees close()
lines = f.readlines()
# f.close() called here AUTOMATICALLY, even if an exception occurred
return parse_lines(lines)
def write_report(filename: str, data: dict) -> None:
with open(filename, "w") as f:
json.dump(data, f) # Even if this raises, f is closed
# Multiple resources - nest with or use a single with with commas
def copy_file(src: str, dst: str) -> None:
with open(src) as source, open(dst, "w") as dest:
for line in source:
dest.write(line)
# Both files are closed even if writing raises
The with statement calls __enter__ on open and __exit__ on exit - regardless of whether the body completed normally or raised an exception. __exit__ calls f.close().
Other Resources That Need Context Managers
# Database connections
with db.connect() as conn:
conn.execute("SELECT 1")
# Network sockets
import socket
with socket.create_connection(("example.com", 80)) as sock:
sock.send(b"GET / HTTP/1.0\r\n\r\n")
# Locks (threading)
import threading
lock = threading.Lock()
with lock:
shared_resource.update()
# Temporary files
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as tmp:
json.dump(data, tmp)
:::warning Sockets and Network Connections File descriptors are not just for files. Open network connections, database connections, and locks are all resources with the same leak problem. Always use context managers or explicitly manage their lifecycle in try/finally blocks. :::
Anti-Pattern 6 - Mutable Default Arguments
Name: The Shared State Trap
The Bad Code
def add_tag(item: str, tags: list = []) -> list:
tags.append(item)
return tags
result1 = add_tag("python")
result2 = add_tag("fastapi")
result3 = add_tag("async")
print(result1) # ['python', 'fastapi', 'async'] ← NOT ['python']!
print(result2) # ['python', 'fastapi', 'async'] ← NOT ['fastapi']!
print(result3) # ['python', 'fastapi', 'async']
print(result1 is result2 is result3) # True - same list object!
Why It Happens
Default argument values are evaluated once when the def statement executes - not every time the function is called. The [] creates one list object at definition time. Every call that uses the default shares that same list.
At definition time: def add_tag(item: str, tags: list = []) - the [] is created once and stored in add_tag.__defaults__. Every call that uses the default shares the same list object:
| Call | Effect |
|---|---|
add_tag("python") | appends to the shared list |
add_tag("fastapi") | appends to the same list |
add_tag("async") | appends to the same list |
The Correct Code - Use None as the Sentinel
def add_tag(item: str, tags: list | None = None) -> list:
if tags is None:
tags = [] # Create a NEW list on each call that needs one
tags.append(item)
return tags
result1 = add_tag("python")
result2 = add_tag("fastapi")
result3 = add_tag("async")
print(result1) # ['python'] ← Correct!
print(result2) # ['fastapi'] ← Correct!
print(result3) # ['async'] ← Correct!
# You can still pass an explicit list to extend it:
existing = ["web"]
result4 = add_tag("python", tags=existing)
print(result4) # ['web', 'python']
The rule: Never use a mutable object (list, dict, set) as a default argument value. Use None instead, and create the mutable object inside the function body.
# ALL of these are the same bug with different types:
def bad1(data: list = []): ... # Shared list
def bad2(data: dict = {}): ... # Shared dict
def bad3(data: set = set()): ... # Shared set
# All fixed the same way:
def good1(data: list | None = None):
if data is None: data = []
...
def good2(data: dict | None = None):
if data is None: data = {}
...
:::note Immutable Defaults Are Safe
Immutable defaults - None, True, False, integers, floats, strings, tuples of immutables - are safe because they cannot be mutated. def f(x=0) and def f(x="hello") are fine. Only mutable objects (list, dict, set, custom classes) cause this bug.
:::
Anti-Pattern 7 - Silent Type Coercion Bugs
Name: The Type Assumption
The Bad Code
def calculate_discount(price, discount_percent):
"""Apply discount_percent to price and return the discounted price."""
multiplier = 1 - discount_percent / 100
return price * multiplier
# Works fine in development
result = calculate_discount(100, 20)
print(result) # 80.0
# Breaks silently in production when data comes from a web form
form_data = {"price": "100", "discount": "20"}
result = calculate_discount(form_data["price"], form_data["discount"])
# TypeError: unsupported operand type(s) for -: 'int' and 'str'
# (Because "20" / 100 raises TypeError, then 1 - that raises TypeError)
Python does not silently coerce types the way some other languages do. "20" / 100 raises TypeError. This is actually a feature, not a bug - but it means you must be explicit about conversions at the data boundary.
The Real Danger: Partial Coercion
# This is where it gets subtle:
def process_config(settings: dict) -> None:
timeout = settings.get("timeout", 30) # Returns "30" from JSON, not 30
items_per_page = settings.get("limit", 20)
# "30" > 0 evaluates to True (string comparison in Python 3)
# "30" > 30 raises TypeError in Python 3 (unlike Python 2!)
if timeout > 0: # Might be comparing string to int
connect(timeout=timeout) # connect() might silently ignore wrong type
# or raise TypeError deep inside the library
The Correct Code - Validate Types at the Boundary
from typing import TypeVar, Type
T = TypeVar("T")
def get_setting(settings: dict, key: str, expected_type: Type[T], default: T) -> T:
"""Get a setting, coercing the type explicitly and failing clearly."""
value = settings.get(key, default)
try:
return expected_type(value)
except (ValueError, TypeError) as e:
raise ValueError(
f"Setting '{key}' has invalid value {value!r}: expected {expected_type.__name__}"
) from e
def calculate_discount(price, discount_percent):
"""Apply discount, validating types explicitly."""
price = float(price)
discount_percent = float(discount_percent)
if not 0 <= discount_percent <= 100:
raise ValueError(f"discount_percent must be 0-100, got {discount_percent}")
return price * (1 - discount_percent / 100)
# In a web handler - validate at the boundary, not deep inside business logic
def handle_discount_request(form_data: dict) -> float:
price = get_setting(form_data, "price", float, 0.0)
discount = get_setting(form_data, "discount", float, 0.0)
return calculate_discount(price, discount)
:::tip Validate at the Boundary Type coercion bugs always originate at data boundaries: form submissions, JSON API responses, environment variables, configuration files, and database reads. Validate and coerce types at the entry point - in the request handler or the configuration loader - not deep inside business logic functions. Business logic should receive the correct types and can assume they are correct. :::
Anti-Pattern 8 - Ignoring Return Values
Name: The Silent No-Op
The Bad Code
# BUG: list.sort() returns None, not the sorted list
def get_sorted_users(users: list) -> list:
return users.sort() # Returns None!
result = get_sorted_users(["Charlie", "Alice", "Bob"])
print(result) # None ← Not what you wanted
print(result[0]) # TypeError: 'NoneType' object is not subscriptable
# More subtle version:
def process_names(names: list) -> list:
sorted_names = names.sort() # sorted_names is None
filtered = [n for n in sorted_names if len(n) > 3] # TypeError: NoneType is not iterable
return filtered
Why It Happens
Python has two sorting APIs with critically different designs:
list.sort()- in-place mutation, returnsNonesorted(iterable)- returns a new sorted list, does not modify the original
Many new Python developers expect list.sort() to behave like sorted() and return the sorted list. It does not - it returns None by convention, signaling that the sorting happened in-place.
# Other common None-returning mutations:
lst = [3, 1, 2]
result = lst.sort() # None
result = lst.append(4) # None
result = lst.extend([5]) # None
result = lst.reverse() # None
# All of these MUTATE lst and return None
# Assigning the return value is always a bug
The Correct Code
# Option A: In-place sort (do NOT assign the return value)
def sort_users_in_place(users: list) -> None:
users.sort() # Mutates in place; caller sees the change
# Option B: Return a new sorted list (do NOT mutate the original)
def get_sorted_users(users: list) -> list:
return sorted(users) # Returns a new list; original is unchanged
# Option C: Sort with a key
def get_users_by_name_length(users: list) -> list:
return sorted(users, key=len)
# Test all three:
names = ["Charlie", "Al", "Bob"]
sort_users_in_place(names)
print(names) # ['Al', 'Bob', 'Charlie']
original = ["Charlie", "Al", "Bob"]
sorted_copy = get_sorted_users(original)
print(sorted_copy) # ['Al', 'Bob', 'Charlie']
print(original) # ['Charlie', 'Al', 'Bob'] - unchanged
Other Functions That Return None and Are Commonly Misused
# dict.update() returns None - does NOT return the updated dict
d = {"a": 1}
result = d.update({"b": 2}) # result is None, not {"a": 1, "b": 2}
# set.add() returns None
s = {1, 2}
result = s.add(3) # result is None, not {1, 2, 3}
# re.compile().sub() does NOT return None - it returns the result
# But re.match() returns None on no match - always check!
import re
m = re.match(r"\d+", "hello")
print(m.group()) # AttributeError: 'NoneType' object has no attribute 'group'
# FIX: if m: print(m.group())
Anti-Pattern 9 - Boolean Blindness in Conditions
Name: The Falsy Trap
The Bad Code
def calculate_statistics(values):
if not values:
raise ValueError("values cannot be empty")
return sum(values) / len(values)
def display_user_info(user_id, name, age, score):
if not user_id:
print("No user ID provided")
return
if not name:
print("No name provided")
return
if not age:
print("No age provided")
return
if not score:
print("No score provided")
return
print(f"User {user_id}: {name}, age {age}, score {score}")
Why It Fails for Legitimate Falsy Values
# These calls are all incorrectly rejected:
display_user_info(user_id=0, name="Alice", age=30, score=95.5)
# "No user ID provided" - but 0 might be a valid system user ID!
display_user_info(user_id=42, name="Alice", age=0, score=95.5)
# "No age provided" - but 0 might be a valid age for an infant!
display_user_info(user_id=42, name="Alice", age=30, score=0)
# "No score provided" - but 0 is a valid score!
display_user_info(user_id=42, name="", age=30, score=95.5)
# "No name provided" - but an empty string from a form might need different handling
In Python, the following values are all falsy - bool(x) returns False:
None0(int, float)""(empty string)[](empty list){}(empty dict)set()(empty set)False
if not x: treats ALL of them identically. This is only correct when any of these values should be treated as "not provided." If 0, "", or an empty list are valid inputs, you must be explicit.
The Correct Code - Be Explicit About What You Are Checking
def display_user_info(user_id, name, age, score):
# Explicit None check - allows 0 as a valid user_id
if user_id is None:
raise ValueError("user_id is required")
# String check - empty string is a different issue from None
if not isinstance(name, str) or not name.strip():
raise ValueError("name must be a non-empty string")
# Explicit None check - allows 0 as a valid age
if age is None:
raise ValueError("age is required")
if not isinstance(age, int) or age < 0:
raise ValueError(f"age must be a non-negative integer, got {age!r}")
# Explicit None check - allows 0.0 as a valid score
if score is None:
raise ValueError("score is required")
print(f"User {user_id}: {name}, age {age}, score {score}")
# Now 0 is correctly treated as a valid value:
display_user_info(user_id=0, name="Alice", age=30, score=95.5)
# User 0: Alice, age 30, score 95.5 ← Correct!
display_user_info(user_id=42, name="Alice", age=0, score=0.0)
# User 42: Alice, age 0, score 0.0 ← Correct!
The Decision Tree for Choosing the Right Check
| Situation | Use |
|---|---|
None is the ONLY invalid value | if x is None: or if x is not None: |
Any empty/zero value is invalid (None, 0, "", [], {}) | if not x: or if x: - ONLY if ALL falsy values mean "absent/invalid" |
| Type is also uncertain | if not isinstance(x, str) or not x: - check type first, then emptiness |
Empty collection is invalid but None means "not provided" | if x is not None and len(x) == 0: - explicit about each case |
Anti-Pattern 10 - Catch-Cleanup-Reraise Without Logging
Name: The Silent Rethrow
The Bad Code
def process_batch(items: list) -> list:
lock.acquire()
try:
results = []
for item in items:
results.append(expensive_transform(item))
return results
except Exception:
# Release the lock so other threads are not blocked
lock.release()
raise # Re-raise the original exception
Why It Fails in Production
The intent is correct: acquire a lock, do work, release the lock even if something fails, then re-raise so the caller knows what went wrong. But there is a critical omission: no logging.
When this code raises, the caller might catch and log the exception - but it might also let it propagate further. If the exception is eventually caught and silenced three layers up, or if a generic error handler converts it to a 500 response, the original context is lost.
More importantly: what item was being processed? How many items had been processed successfully before the failure? What was the state of results? All of this context exists at this exact point in the call stack - and it is discarded.
The Correct Code - Log Before Reraise
import logging
import threading
logger = logging.getLogger(__name__)
lock = threading.Lock()
def process_batch(items: list) -> list:
lock.acquire()
try:
results = []
for i, item in enumerate(items):
results.append(expensive_transform(item))
return results
except Exception:
# Log with full context BEFORE releasing resources and reraising
logger.exception(
"Batch processing failed at item %d of %d (item=%r, results_so_far=%d)",
i, len(items), item, len(results),
)
raise # Re-raise the original exception unchanged
finally:
lock.release() # Use finally for cleanup - runs even on success
Notice two additional improvements:
- Use
finally:for cleanup instead of putting it inexcept:. Thefinallyblock runs whether the try block succeeded or failed - meaning the lock is released on success AND failure. The original code only released the lock on failure (thefinallyversion also releases on success via the lock.acquire/try pattern, but the correct idiom for locks iswith lock:). - Log the full context at the point of failure - item index, total count, the item itself, how many succeeded.
The Idiomatic Version Using Context Manager
import logging
import threading
logger = logging.getLogger(__name__)
lock = threading.Lock()
def process_batch(items: list) -> list:
with lock: # Automatically releases on exit, whether normal or exception
results = []
for i, item in enumerate(items):
try:
results.append(expensive_transform(item))
except Exception:
logger.exception(
"Failed to transform item %d of %d: %r",
i, len(items), item,
)
raise
return results
:::tip Log at the Point of Richest Context
The best place to log an error is the point where you have the most information about what was happening. That is almost always the except block closest to the failure - not three layers up in a generic handler. Do not wait to log until you know whether the caller will handle the exception. Log when you have the context.
:::
Comprehensive Anti-Pattern Summary Table
| Anti-Pattern | Bad Code | Root Problem | Fix |
|---|---|---|---|
| Swallow exceptions | except: pass | Hides all failures, no diagnostics | Catch specific types, log with logger.exception() |
| Catch too broadly | except Exception: return 0 | Masks programming bugs as "handled" | Catch minimum needed: except ValueError: |
| Lose exception context | raise NewError(str(e)) | Destroys original traceback | raise NewError("msg") from e |
| Exceptions for flow control | try: d[k] except: return default | Hides logic, exception overhead | d.get(k, default) |
| Resource leak | f = open(x) without with | fd/connection leak under errors | Always use with open(x) as f: |
| Mutable default argument | def f(lst=[]): | Shared state across all calls | def f(lst=None): if lst is None: lst = [] |
| Type assumption | price * multiplier without coercing | Fails on string input from forms | Validate and coerce types at the data boundary |
| Ignoring return values | result = list.sort() | sort() returns None, not the list | Use sorted(list) to get a new list |
| Boolean blindness | if not age: (rejects 0) | Treats all falsy values identically | if age is None: when 0 is valid |
| Catch-cleanup-reraise without log | Cleanup + raise, no logging | Context lost if caller silences error | logger.exception() before raise |
Interview Questions
Q1: What is the difference between except: and except Exception:? Why is bare except: dangerous?
Answer: except Exception: catches all exceptions that inherit from Exception - which is almost all user-facing exceptions including ValueError, TypeError, RuntimeError, etc.
Bare except: catches everything, including exceptions that do NOT inherit from Exception:
BaseExceptionsubclasses:SystemExit,KeyboardInterrupt,GeneratorExit
SystemExit is raised when sys.exit() is called. KeyboardInterrupt is raised when the user presses Ctrl-C. GeneratorExit is raised when a generator is closed. Catching them silently prevents clean shutdown.
Use except Exception: only when you genuinely need to catch every runtime error. Prefer catching specific exception types.
Q2: What does raise NewError("message") from original_error do? What happens without from?
Answer: raise NewError("message") from original_error performs explicit exception chaining. It sets NewError.__cause__ = original_error and NewError.__suppress_context__ = True. Python displays both exceptions in the traceback with "The above exception was the direct cause of the following exception."
Without from, if you raise inside an except block, Python still chains implicitly (sets __context__), but it displays "During handling of the above exception, another exception occurred." The distinction signals whether the new exception was explicitly caused by the original or just happened to occur while handling it.
raise NewError("message") from None explicitly suppresses the context entirely - the traceback shows only the new exception.
Q3: Why is def func(items=[]) a bug? How does Python's default argument evaluation work?
Answer: Default argument values are evaluated once at function definition time - when the def statement executes - not at each call. The [] creates a single list object, stored in func.__defaults__. Every call that uses the default shares this same object.
When you do items.append(x) inside the function, you mutate the shared default list. Subsequent calls see the accumulated mutations.
Fix: use None as the default sentinel and create the mutable object inside the function body:
def func(items=None):
if items is None:
items = []
items.append(x)
return items
Q4: What is the difference between list.sort() and sorted()? Why does assigning the return value of sort() always produce a bug?
Answer: list.sort() sorts the list in place and returns None - by design, this signals that the original list was mutated. sorted(iterable) creates and returns a new sorted list, leaving the original unchanged.
Assigning result = my_list.sort() sets result = None because sort() returns None. Using result afterward (e.g., result[0]) raises TypeError: 'NoneType' object is not subscriptable.
The Python convention is: functions that mutate in place return None; functions that create new objects return the new object. This makes the intent explicit - if a function returned self, you could not tell whether it mutated or created a copy.
Q5: When is using exceptions for flow control appropriate in Python vs. an anti-pattern?
Answer: Python's EAFP (Easier to Ask Forgiveness than Permission) culture makes some exception-for-flow-control patterns idiomatic:
Idiomatic (acceptable):
try: return d[key] except KeyError: return default- equivalent tod.get(key, default), though.get()is clearertry: return json.loads(text) except json.JSONDecodeError: return None- no non-exception way to check if JSON is valid without parsing
Anti-pattern (avoid):
- Using IndexError as a loop termination signal when
for item in collection:works fine - Using TypeError as a type-dispatch mechanism when
isinstance()is clearer - Using exceptions for conditions that are very common (exceptions are slow to raise and catch - avoid on hot paths)
The rule of thumb: exceptions should be exceptional. If the "failure" condition happens regularly and has a clean non-exception alternative, prefer the non-exception path.
Q6: Explain why a bare except Exception: raise without logging is still an anti-pattern, even though it re-raises the exception.
Answer: The assumption is: "I'll re-raise it, so the caller will log it." This is fragile because:
-
The caller might silence it. Generic top-level handlers (
except Exception: return 500) catch and suppress the exception without logging details. -
Context is lost if you wait. At the point of the
exceptblock closest to the failure, you have the richest context - loop iteration number, the specific item being processed, intermediate results. Three frames up, that context is gone. -
Multiple callers. If the function is called from multiple places, you cannot guarantee every caller logs correctly. Logging at the source ensures it happens exactly once, always.
The correct pattern: logger.exception("context here") then raise. The logger.exception() call logs at ERROR level with the full traceback. The raise propagates the original exception unchanged. This guarantees diagnostic information is captured at the richest context point, regardless of what the caller does.
Practice Challenges
Beginner - Spot the Anti-Patterns
This code contains three distinct anti-patterns from this lesson. Identify each one, name it, and explain why it is dangerous.
def load_user_data(user_id, tags=[]):
try:
data = fetch_from_db(user_id)
tags.append(f"loaded:{user_id}")
return data
except:
return None
Solution
Anti-Pattern 1: Mutable Default Argument (tags=[])
The [] is evaluated once at function definition. Every call that uses the default tags shares the same list. After calling load_user_data(1), load_user_data(2), and load_user_data(3), the default tags list contains ["loaded:1", "loaded:2", "loaded:3"]. Each subsequent call inherits all previous tags, which is almost certainly not intended.
Fix: def load_user_data(user_id, tags=None) and if tags is None: tags = []
Anti-Pattern 2: Swallowing Exceptions (except: pass variant - returns None)
The bare except: catches every exception including SystemExit and KeyboardInterrupt. The function returns None on any failure. The caller has no way to distinguish "user not found" from "database is down" from "programmer bug." No traceback, no log, no diagnostic.
Fix: Catch specific exceptions, use logger.exception(), and either re-raise or return a meaningful error indicator.
Anti-Pattern 3: Bare except: Without Type (except:)
Even setting aside the silent swallow, the bare except: (no exception type) catches KeyboardInterrupt and SystemExit. If the user presses Ctrl-C during fetch_from_db(), the function silently returns None instead of allowing the program to terminate cleanly.
Fix: At minimum except Exception:, better except (DatabaseError, TimeoutError):.
Corrected Code:
import logging
logger = logging.getLogger(__name__)
def load_user_data(user_id: int, tags: list | None = None) -> dict | None:
if tags is None:
tags = []
try:
data = fetch_from_db(user_id)
tags.append(f"loaded:{user_id}")
return data
except DatabaseError:
logger.exception("Failed to load user %d from database", user_id)
return None
Intermediate - Fix All Anti-Patterns in a Real Function
Rewrite the following function to eliminate all anti-patterns. Identify each one before fixing it.
import json
def process_orders(orders, results=[], discount=None):
f = open("order_log.txt", "a")
try:
for order in orders:
try:
total = order["price"] * order["quantity"]
if discount:
total = total * (1 - discount / 100)
results.append({"id": order["id"], "total": total})
f.write(f"Processed order {order['id']}: {total}\n")
except Exception as e:
raise RuntimeError(str(e))
except:
pass
return results
Solution
Anti-Patterns Present:
- Mutable default argument (
results=[]) - all calls share the same list - Resource leak (
f = open(...)withoutwith) - file not closed if exception occurs - Boolean blindness (
if discount:) - rejectsdiscount=0.0as "no discount" - Losing exception context (
raise RuntimeError(str(e))) - destroys original traceback - Swallowing exceptions (outer
except: pass) - hides all errors from the caller - Catching too broadly (inner
except Exception) - masks programmer bugs
Corrected Code:
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def process_orders(
orders: list[dict],
results: list[dict] | None = None,
discount: float | None = None,
) -> list[dict]:
"""
Process a list of orders, optionally applying a discount.
Args:
orders: List of order dicts with keys: id, price, quantity
results: Optional list to append results into (new list created if None)
discount: Discount percentage 0-100, or None for no discount
Returns:
List of processed order dicts with id and total
Raises:
ValueError: If discount is not in [0, 100]
KeyError: If an order dict is missing required fields
"""
if results is None:
results = []
if discount is not None:
if not 0 <= discount <= 100:
raise ValueError(f"discount must be 0-100, got {discount!r}")
with open("order_log.txt", "a", encoding="utf-8") as f:
for order in orders:
try:
total = float(order["price"]) * int(order["quantity"])
except KeyError as e:
logger.exception(
"Order %r is missing required field %s",
order.get("id", "unknown"), e,
)
raise # Re-raise so caller knows the batch failed
if discount is not None: # Explicit None check - allows 0.0
total = total * (1 - discount / 100)
results.append({"id": order["id"], "total": round(total, 2)})
f.write(f"Processed order {order['id']}: {total:.2f}\n")
return results
# Test:
orders = [
{"id": "A001", "price": "49.99", "quantity": "2"},
{"id": "A002", "price": "19.99", "quantity": "3"},
]
result = process_orders(orders, discount=0.0) # 0% discount - now accepted correctly
print(result)
# [{'id': 'A001', 'total': 99.98}, {'id': 'A002', 'total': 59.97}]
Advanced - Design a Resilient Batch Processor
Build a batch data processing function that correctly handles all 10 anti-patterns. The function must:
- Accept a list of raw records (dicts from an external API, may have string number fields)
- Process each record: parse types, calculate a derived field, validate a range
- Collect all successfully processed records AND all failures (with reasons)
- Log each failure with full context but never swallow exceptions that indicate programmer bugs
- Clean up a file resource even if processing fails partway through
- Accept an optional accumulator list (
processed_so_far=None, not[]) - Return a named result with both successes and failures
Solution
import logging
import json
from dataclasses import dataclass, field
from typing import Any
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class BatchResult:
successes: list[dict] = field(default_factory=list)
failures: list[dict] = field(default_factory=list)
@property
def success_count(self) -> int:
return len(self.successes)
@property
def failure_count(self) -> int:
return len(self.failures)
def process_sensor_records(
records: list[dict],
output_path: str = "processed.jsonl",
processed_so_far: list[dict] | None = None, # NOT mutable default
max_temperature: float | None = None, # None means no cap
) -> BatchResult:
"""
Process sensor records from an external API.
Each record must have: sensor_id (str), temperature (str|float), humidity (str|float)
Calculates heat_index = temperature + 0.33 * humidity - 4.0
Validates temperature is in [-50, 100] Celsius.
Returns BatchResult with successes and failures.
Programmer errors (KeyboardInterrupt, MemoryError) propagate unchanged.
"""
result = BatchResult()
# Anti-pattern 6 fix: safe mutable default
if processed_so_far is None:
processed_so_far = []
# Anti-pattern 5 fix: context manager for file resource
with open(output_path, "a", encoding="utf-8") as outfile:
for i, record in enumerate(records):
try:
# Anti-pattern 7 fix: explicit type coercion at data boundary
sensor_id = str(record["sensor_id"])
temperature = float(record["temperature"])
humidity = float(record["humidity"])
# Anti-pattern 9 fix: explicit range check (not truthiness)
if not (-50 <= temperature <= 100):
raise ValueError(
f"Temperature {temperature} out of valid range [-50, 100]"
)
if not (0 <= humidity <= 100):
raise ValueError(
f"Humidity {humidity} out of valid range [0, 100]"
)
# Anti-pattern 9 fix: explicit None check (0.0 is valid max_temperature)
if max_temperature is not None and temperature > max_temperature:
raise ValueError(
f"Temperature {temperature} exceeds cap {max_temperature}"
)
heat_index = temperature + 0.33 * humidity - 4.0
processed_record = {
"sensor_id": sensor_id,
"temperature": temperature,
"humidity": humidity,
"heat_index": round(heat_index, 2),
}
# Anti-pattern 8 fix: don't assign the result of append()
result.successes.append(processed_record)
processed_so_far.append(processed_record)
# Write to output file
outfile.write(json.dumps(processed_record) + "\n")
except (ValueError, KeyError, TypeError) as e:
# Anti-pattern 2 fix: catch specifically what we handle
# Anti-pattern 10 fix: log with full context before recording failure
logger.warning(
"Record %d failed validation (sensor_id=%r): %s",
i,
record.get("sensor_id", "unknown"),
e,
)
result.failures.append({
"index": i,
"record": record,
"error": str(e),
"error_type": type(e).__name__,
})
# Do NOT raise - this record failed, but continue batch processing
# Anti-pattern 1 fix: do NOT catch broadly here
# KeyboardInterrupt, MemoryError, etc. propagate naturally
logger.info(
"Batch complete: %d succeeded, %d failed",
result.success_count, result.failure_count,
)
return result
# Demonstration
test_records = [
{"sensor_id": "SEN-001", "temperature": "22.5", "humidity": "60"}, # Good
{"sensor_id": "SEN-002", "temperature": "150", "humidity": "40"}, # Temp out of range
{"sensor_id": "SEN-003", "temperature": "18.0", "humidity": "75"}, # Good
{"sensor_id": "SEN-004", "temperature": "abc", "humidity": "50"}, # Bad type
{"sensor_id": "SEN-005", "temperature": "25.0"}, # Missing humidity
{"sensor_id": "SEN-006", "temperature": "0.0", "humidity": "0"}, # Valid zeros
]
logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(message)s")
batch_result = process_sensor_records(
test_records,
output_path="/tmp/sensor_output.jsonl",
max_temperature=None, # Explicit None - not falsy check
)
print(f"\nSuccesses: {batch_result.success_count}")
for rec in batch_result.successes:
print(f" {rec}")
print(f"\nFailures: {batch_result.failure_count}")
for fail in batch_result.failures:
print(f" Index {fail['index']}: {fail['error_type']}: {fail['error']}")
# Output:
# Successes: 3
# {'sensor_id': 'SEN-001', 'temperature': 22.5, 'humidity': 60.0, 'heat_index': 38.3}
# {'sensor_id': 'SEN-003', 'temperature': 18.0, 'humidity': 75.0, 'heat_index': 38.75}
# {'sensor_id': 'SEN-006', 'temperature': 0.0, 'humidity': 0.0, 'heat_index': -4.0}
#
# Failures: 3
# Index 1: ValueError: Temperature 150.0 out of valid range [-50, 100]
# Index 3: ValueError: could not convert string to float: 'abc'
# Index 4: KeyError: 'humidity'
Quick Reference
| Anti-Pattern | Symptoms | Fix |
|---|---|---|
Bare except: pass | Silent failures, no logs, impossible to debug | Catch specific types + logger.exception() |
except Exception: | Masks programmer bugs as handled errors | Use the minimum specific exception types |
raise New(str(e)) | Traceback shows new exception, not root cause | raise NewError("msg") from original |
| Exception for flow control | Logic hidden in exception handlers | Use .get(), isinstance(), for item in collection |
f = open() without with | File descriptor leaks, "too many open files" | Always with open() as f: |
def f(lst=[]) | Shared mutable state across all calls | def f(lst=None) then if lst is None: lst = [] |
price * rate without coercing | TypeError on string data from forms/JSON | Coerce and validate types at data boundaries |
result = list.sort() | result is None, crash on next use | Use sorted(list) for a new list |
if not value: | Rejects 0, "", [] as "missing" | if value is None: when falsy is valid |
Catch + cleanup + raise, no log | Context lost if caller silences error | logger.exception() before raise |
Key Takeaways
- The single most dangerous pattern in Python is
except: pass- it silently discards exceptions and makes production debugging impossible; never use it - Catch exceptions at the minimum necessary specificity:
except ValueError:is almost always better thanexcept Exception:, which is always better thanexcept: - Always use
raise NewError("msg") from originalwhen wrapping exceptions - it preserves the original traceback as the explicit cause, visible in logs and debuggers - Resources (files, sockets, database connections, locks) must be managed with
withstatements or try/finally - any other pattern will eventually leak under error conditions - Never use a mutable object (
list,dict,set) as a default argument value; useNoneand create the mutable object inside the function body - The
if not x:truthiness check is dangerous when0,"", or[]are legitimate values - useif x is None:when None is the only invalid sentinel list.sort()returnsNoneand mutates in place;sorted(list)returns a new sorted list - assigning the return value ofsort()is always a bug- Log exceptions at the point of maximum context (the except block closest to the failure), not three layers up in a generic handler where all local variables are gone
