Python Common Error Anti-Patterns: Practice Problems & Exercises
Practice: Common Error Anti-Patterns
← Back to lessonEasy
Predict the output. Observe which exceptions a bare except: catches versus except Exception:. Understand why bare except: is dangerous.
def test_bare_except(exc):
try:
raise exc
except:
return f"Caught by bare except: {type(exc).__name__}"
def test_except_exception(exc):
try:
raise exc
except Exception:
return f"except Exception caught: {type(exc).__name__}"
except BaseException:
return None # Not caught by except Exception
print(test_bare_except(SystemExit(0)))
print(test_bare_except(KeyboardInterrupt()))
print(test_except_exception(ValueError("bad")))
print(f"except Exception missed SystemExit: {test_except_exception(SystemExit(0)) is None}")
print(f"except Exception missed KeyboardInterrupt: {test_except_exception(KeyboardInterrupt()) is None}")Solution
Caught by bare except: SystemExit
Caught by bare except: KeyboardInterrupt
except Exception caught: ValueError
except Exception missed SystemExit: True
except Exception missed KeyboardInterrupt: True
Key insight:
The Python exception hierarchy has BaseException at the top, with Exception as a subclass:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── RuntimeError
└── ... (all user-facing exceptions)
A bare except: is equivalent to except BaseException: -- it catches everything, including system signals that Python uses to shut down the process. When you write except: pass, you prevent Ctrl-C from stopping your program and sys.exit() from exiting.
except Exception: is safer because it lets SystemExit, KeyboardInterrupt, and GeneratorExit propagate naturally. But it still catches all programming bugs (TypeError, AttributeError), so specific exception types are almost always better.
Expected Output
Caught by bare except: SystemExit
Caught by bare except: KeyboardInterrupt
except Exception caught: ValueError
except Exception missed SystemExit: True
except Exception missed KeyboardInterrupt: TrueHints
Hint 1: A bare `except:` catches all exceptions including BaseException subclasses like SystemExit and KeyboardInterrupt. `except Exception:` only catches Exception subclasses.
Hint 2: SystemExit and KeyboardInterrupt inherit from BaseException, not Exception. That is why `except Exception:` does not catch them.
Predict the output. Compare a function that silently swallows all errors with one that captures error information. See why the silent version makes debugging impossible.
def silent_divide(a, b):
try:
return a / b
except:
return -1
def logged_divide(a, b):
try:
return a / b
except Exception as e:
return {"error": str(e), "type": type(e).__name__}
# Silent version — all failures look the same
r1 = silent_divide(10, 0)
r2 = silent_divide(10, "a")
r3 = silent_divide(None, 5)
print(f"silent_divide(10, 0) = {r1}")
print(f"silent_divide(10, 'a') = {r2}")
print(f"silent_divide(None, 5) = {r3}")
print(f"All failures look identical: {r1 == r2 == r3}")
# Logged version — each failure is distinguishable
for a, b in [(10, 0), (10, "a"), (None, 5)]:
result = logged_divide(a, b)
print(f"logged_divide({a}, {b!r}): error={result['error']!r}, type={result['type']!r}")Solution
silent_divide(10, 0) = -1
silent_divide(10, 'a') = -1
silent_divide(None, 5) = -1
All failures look identical: True
logged_divide(10, 0): error='division by zero', type='ZeroDivisionError'
logged_divide(10, 'a'): error="unsupported operand type(s) for /: 'int' and 'str'", type='TypeError'
logged_divide(None, 5): error="unsupported operand type(s) for /: 'NoneType' and 'int'", type='TypeError'
Why the silent version is catastrophic in production:
All three calls return -1. The caller has no way to distinguish:
- A valid input that triggered division by zero (recoverable -- maybe use a default)
- A type error from passing a string (caller bug -- needs fixing)
- A None value reaching the function (upstream data problem -- needs investigation)
The logged version preserves both the error message and the error type. Even without a full traceback, an engineer can immediately see the root cause. In production, you would use logger.exception() instead of returning a dict, which also captures the full stack trace.
Expected Output
silent_divide(10, 0) = -1
silent_divide(10, 'a') = -1
silent_divide(None, 5) = -1
All failures look identical: True
logged_divide(10, 0): error='division by zero', type='ZeroDivisionError'
logged_divide(10, 'a'): error="unsupported operand type(s) for /: 'int' and 'str'", type='TypeError'
logged_divide(None, 5): error="unsupported operand type(s) for /: 'NoneType' and 'int'", type='TypeError'Hints
Hint 1: The silent version returns -1 for every possible failure. The caller cannot distinguish a division by zero from a type error from a None input.
Hint 2: The logged version captures both the error message and the error type, making each failure distinguishable.
Predict the output. The "Pokemon" handler catches everything. The specific handler only catches what it is designed for. See which caller bugs get hidden.
def pokemon_parse(value):
"""Gotta catch 'em all — catches every exception."""
try:
return int(value)
except Exception:
return 0
def specific_parse(value):
"""Only catches ValueError — lets type bugs propagate."""
try:
return int(value)
except ValueError:
return 0
# Pokemon version hides bugs
for val in ['42', 'abc', None, [1, 2]]:
print(f"pokemon_parse({val!r}) = {pokemon_parse(val)}")
# Specific version surfaces bugs
for val in ['42', 'abc', None, [1, 2]]:
try:
result = specific_parse(val)
print(f"specific_parse({val!r}) = {result}")
except TypeError as e:
print(f"specific_parse({val!r}) raised TypeError: {e}")Solution
pokemon_parse('42') = 42
pokemon_parse('abc') = 0
pokemon_parse(None) = 0
pokemon_parse([1,2]) = 0
specific_parse('42') = 42
specific_parse('abc') = 0
specific_parse(None) raised TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
specific_parse([1,2]) raised TypeError: int() argument must be a string, a bytes-like object or a real number, not 'list'
The Pokemon handler hides two categories of bugs:
-
pokemon_parse(None)returns0-- but passingNoneto a string parser is a bug in the caller. The caller should never passNonehere. Silently returning0means the bug propagates further downstream, eventually causing a mysterious data error. -
pokemon_parse([1, 2])returns0-- passing a list toint()is also a caller bug. Hiding it behind a default value means the caller never learns they have a type mismatch.
The specific handler correctly catches ValueError (invalid string like 'abc') and returns a default. But TypeError (wrong argument type) propagates, surfacing the bug immediately where it can be fixed.
Rule: Only catch exceptions your code is designed to handle. Let unexpected exceptions propagate -- they represent bugs that need investigation, not errors that need default values.
Expected Output
pokemon_parse('42') = 42
pokemon_parse('abc') = 0
pokemon_parse(None) = 0
pokemon_parse([1,2]) = 0
specific_parse('42') = 42
specific_parse('abc') = 0
specific_parse(None) raised TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
specific_parse([1,2]) raised TypeError: int() argument must be a string, a bytes-like object or a real number, not 'list'Hints
Hint 1: The "Pokemon" handler catches `except Exception` -- every exception type. It treats a caller bug (passing None or a list) the same as an invalid string.
Hint 2: The specific handler only catches ValueError, letting TypeError propagate. This surfaces caller bugs immediately instead of hiding them behind a default value.
Predict the output. Compare error-code-style returns (returning None on failure) with exception-based error reporting. See how error codes hide bugs.
users_db = {"alice": {"name": "alice", "age": 30}, "bob": {"name": "bob", "age": 25}}
# Error code style — returns None for ALL failures
def lookup(user_id):
try:
return users_db[user_id]
except:
return None
# Exception style — distinct failure modes
def lookup_exc(user_id):
if not isinstance(user_id, str):
raise TypeError(f"user_id must be str, got {type(user_id).__name__}")
return users_db[user_id] # Raises KeyError if not found
print("Error code style:")
print(f" lookup('alice') = {lookup('alice')}")
print(f" lookup('unknown') = {lookup('unknown')}")
print(f" lookup(42) = {lookup(42)}")
print(f" Bug hidden: {lookup('unknown') == lookup(42)}")
print("Exception style:")
print(f" lookup_exc('alice') = {lookup_exc('alice')}")
for arg in ["unknown", 42]:
try:
lookup_exc(arg)
except (KeyError, TypeError) as e:
print(f" lookup_exc({arg!r}) raised {type(e).__name__}")Solution
Error code style:
lookup('alice') = {'name': 'alice', 'age': 30}
lookup('unknown') = None
lookup(42) = None
Bug hidden: True
Exception style:
lookup_exc('alice') = {'name': 'alice', 'age': 30}
lookup_exc('unknown') raised KeyError
lookup_exc(42) raised TypeError
Why error code returns are an anti-pattern:
The error code version returns None for both "user not found" (expected) and "caller passed an integer instead of a string" (bug). The caller sees None and has no way to distinguish the two. The bug silently continues.
The exception version raises KeyError for "not found" and TypeError for "wrong argument type." Each failure mode is distinct, inspectable, and carries a message. The caller can handle KeyError (user genuinely missing) while letting TypeError propagate as a bug.
In languages without exceptions (C, Go), error codes are the primary mechanism. In Python, exceptions are the standard mechanism for signaling errors. Returning error codes when exceptions are available reduces the information available to callers and hides bugs.
Expected Output
Error code style:
lookup('alice') = {'name': 'alice', 'age': 30}
lookup('unknown') = None
lookup(42) = None
Bug hidden: True
Exception style:
lookup_exc('alice') = {'name': 'alice', 'age': 30}
lookup_exc('unknown') raised KeyError
lookup_exc(42) raised TypeErrorHints
Hint 1: Returning None for errors makes it impossible to distinguish "not found" from "caller passed wrong type." Both return None.
Hint 2: Using exceptions, KeyError means "not found" and TypeError means "caller bug." Each failure has a distinct, inspectable type.
Medium
Demonstrate the difference between raise NewError(str(e)) (destroys context) and raise NewError("msg") from e (preserves chain). Inspect __cause__ to prove the chain is intact.
def fetch_user_bad(user_id):
try:
raise ConnectionError("connection refused")
except ConnectionError as e:
raise RuntimeError(str(e)) # Context LOST
def fetch_user_good(user_id):
try:
raise ConnectionError("connection refused")
except ConnectionError as e:
raise RuntimeError(f"Failed to fetch user {user_id}") from e # Context PRESERVED
print("--- Bad version (lost context) ---")
try:
fetch_user_bad(42)
except RuntimeError as e:
print(f"Exception type: {type(e).__name__}")
print(f"Message: {e}")
print(f"__cause__: {e.__cause__}")
print(f"__context__: {e.__context__}")
print(f"Original traceback preserved: {e.__cause__ is not None}")
print("--- Good version (chained) ---")
try:
fetch_user_good(42)
except RuntimeError as e:
print(f"Exception type: {type(e).__name__}")
print(f"Message: {e}")
print(f"__cause__ type: {type(e.__cause__).__name__}")
print(f"__cause__ message: {e.__cause__}")
print(f"Original traceback preserved: {e.__cause__ is not None}")Solution
--- Bad version (lost context) ---
Exception type: RuntimeError
Message: connection refused
__cause__: None
__context__: None
Original traceback preserved: False
--- Good version (chained) ---
Exception type: RuntimeError
Message: Failed to fetch user 42
__cause__ type: ConnectionError
__cause__ message: connection refused
Original traceback preserved: True
What happens with raise RuntimeError(str(e)):
A brand new RuntimeError is created with only the string message from the original exception. The original ConnectionError object -- including its type, its own traceback, and any chained causes it might have -- is completely discarded. In a production log, you see RuntimeError: connection refused with no indication that a ConnectionError was the root cause.
What happens with raise RuntimeError("msg") from e:
Python stores the original exception as __cause__ on the new exception. When the traceback is printed, Python shows both:
ConnectionError: connection refused
The above exception was the direct cause of the following exception:
RuntimeError: Failed to fetch user 42
An on-call engineer sees the full chain and immediately knows the root cause is a connection failure, not a generic runtime error.
Quick reference:
raise X from e-- explicit chain, sets__cause__(use when wrapping)raise X from None-- explicit suppression, hides original contextraise Xinside except -- implicit chain, sets__context__(automatic)
Expected Output
--- Bad version (lost context) ---
Exception type: RuntimeError
Message: connection refused
__cause__: None
__context__: None
Original traceback preserved: False
--- Good version (chained) ---
Exception type: RuntimeError
Message: Failed to fetch user 42
__cause__ type: ConnectionError
__cause__ message: connection refused
Original traceback preserved: TrueHints
Hint 1: `raise RuntimeError(str(e))` creates a brand new exception. The original exception `e` is not stored anywhere on the new exception -- its type, message, and traceback are all gone.
Hint 2: `raise RuntimeError("msg") from e` stores `e` as `__cause__` on the new exception. Python displays both exceptions in the traceback with "The above exception was the direct cause."
Compare three anti-patterns where try/except is used for normal flow control, when a cleaner non-exception alternative exists. Both versions produce the same output -- but one is clear and the other hides logic in exception handlers.
items = ["A", "B", "C"]
# BAD: exception as loop termination
def process_bad(data):
results = []
i = 0
while True:
try:
results.append(data[i] + "_processed")
i += 1
except IndexError:
break
return results
# GOOD: iterate directly
def process_good(data):
return [item + "_processed" for item in data]
print("--- Exception as loop termination ---")
print(f"Bad result: {process_bad(items)}")
print(f"Good result: {process_good(items)}")
print(f"Results equal: {process_bad(items) == process_good(items)}")
# BAD: exception as dict.get replacement
config = {"host": "localhost", "port": 8080}
def get_bad(d, key, default=None):
try:
return d[key]
except KeyError:
return default
# GOOD: use .get()
def get_good(d, key, default=None):
return d.get(key, default)
print("--- Exception as dict.get replacement ---")
print(f"Bad result: {get_bad(config, 'timeout', 'default_val')}")
print(f"Good result: {get_good(config, 'timeout', 'default_val')}")
# BAD: exception as type dispatch
def double_bad(value):
try:
return value * 2
except TypeError:
return str(value) * 2
# GOOD: explicit type check
def double_good(value):
if isinstance(value, (int, float)):
return value * 2
return str(value) * 2
print("--- Exception as type dispatch ---")
print(f"Bad: double(5) = {double_bad(5)}")
print(f"Bad: double('hi') = {double_bad('hi')}")
print(f"Good: double(5) = {double_good(5)}")
print(f"Good: double('hi') = {double_good('hi')}")Solution
--- Exception as loop termination ---
Bad result: ['A_processed', 'B_processed', 'C_processed']
Good result: ['A_processed', 'B_processed', 'C_processed']
Results equal: True
--- Exception as dict.get replacement ---
Bad result: default_val
Good result: default_val
--- Exception as type dispatch ---
Bad: double(5) = 10
Bad: double('hi') = hihi
Good: double(5) = 10
Good: double('hi') = hihi
Why the "bad" versions are anti-patterns even though they produce correct output:
-
Exception as loop termination: The
while True+IndexErrorpattern hides the loop termination condition inside an exception handler. A reader must trace through the except block to understand when the loop ends.for item in datastates the iteration clearly. -
Exception as dict.get replacement:
try: d[key] except KeyError: defaultdoes exactly whatd.get(key, default)does, but with three lines instead of one. The exception handler adds visual noise and cognitive overhead. Python dicts have.get()for this exact reason. -
Exception as type dispatch: The logic "if numeric multiply, else stringify" is hidden inside an exception handler. A reader sees
value * 2and must mentally simulate what exceptions it might raise to understand the branching.isinstance()makes the type dispatch explicit.
When EAFP is appropriate: Parsing (json.loads), file access, and cases where checking in advance would be redundant or racy. When a clean non-exception alternative exists (for, .get(), isinstance()), prefer it.
Expected Output
--- Exception as loop termination ---
Bad result: ['A_processed', 'B_processed', 'C_processed']
Good result: ['A_processed', 'B_processed', 'C_processed']
Results equal: True
--- Exception as dict.get replacement ---
Bad result: default_val
Good result: default_val
--- Exception as type dispatch ---
Bad: double(5) = 10
Bad: double('hi') = hihi
Good: double(5) = 10
Good: double('hi') = hihiHints
Hint 1: Using IndexError to terminate a loop when `for item in collection` exists is using exceptions for flow control unnecessarily.
Hint 2: Using `try: d[k] except KeyError: default` when `d.get(k, default)` exists adds complexity without benefit.
Demonstrate that a file opened without with leaks when an exception occurs. Then show that the with statement guarantees cleanup.
import tempfile
import os
# Leaky version
def write_leaky(path, data):
f = open(path, "w")
f.write(data)
if "FAIL" in data:
raise ValueError("Bad data")
f.close()
return f
# Safe version
def write_safe(path, data):
with open(path, "w") as f:
f.write(data)
if "FAIL" in data:
raise ValueError("Bad data")
return f
print("--- Leaky version ---")
# Success case
tmp1 = tempfile.mktemp()
f1 = write_leaky(tmp1, "good data")
print(f"File closed after success: {f1.closed}")
# Error case
tmp2 = tempfile.mktemp()
try:
f2 = write_leaky(tmp2, "FAIL data")
except ValueError:
pass
print(f"File closed after error: {f2.closed}")
print("--- Safe version ---")
# Success case
tmp3 = tempfile.mktemp()
f3 = write_safe(tmp3, "good data")
print(f"File closed after success: {f3.closed}")
# Error case
tmp4 = tempfile.mktemp()
try:
f4 = write_safe(tmp4, "FAIL data")
except ValueError:
pass
print(f"File closed after error: {f4.closed}")
# Cleanup temp files
for p in [tmp1, tmp2, tmp3, tmp4]:
if os.path.exists(p):
os.unlink(p)Solution
--- Leaky version ---
File closed after success: True
File closed after error: False
--- Safe version ---
File closed after success: True
File closed after error: True
What happens in the leaky version:
When "FAIL" in data is True, raise ValueError("Bad data") executes. Python immediately jumps out of write_leaky -- the f.close() line is never reached. The file object f2 still exists (it was assigned before the raise), but f2.closed is False. The file descriptor is leaked.
In production, each leaked file descriptor consumes a kernel resource. After approximately 1024 leaks (the default ulimit -n on most systems), the process receives OSError: [Errno 24] Too many open files and can no longer open any files, sockets, or pipes.
What happens in the safe version:
The with statement calls f.__enter__() when entering the block and f.__exit__() when leaving -- regardless of whether the block completed normally or raised an exception. __exit__ calls f.close(). Even though the ValueError propagates, the file is properly closed first.
Rule: Always use with for resources that need cleanup: files, database connections, sockets, locks, temporary files.
Expected Output
--- Leaky version ---
File closed after success: True
File closed after error: False
--- Safe version ---
File closed after success: True
File closed after error: TrueHints
Hint 1: When an exception occurs between `open()` and `f.close()`, the close call is skipped. The file descriptor leaks.
Hint 2: The `with` statement guarantees `__exit__` (which calls `close()`) runs regardless of whether the block completed normally or raised an exception.
Predict the output. Compare if not x (boolean blindness) with precise validation. See which valid inputs get incorrectly rejected.
def validate_blind(value):
"""Uses truthiness check — treats all falsy values as invalid."""
if not value:
return "REJECTED"
return "accepted"
def validate_precise(value):
"""Uses explicit checks — distinguishes between different invalid states."""
if value is None:
return "REJECTED (None)"
if isinstance(value, str) and not value.strip():
return "REJECTED (empty string)"
if isinstance(value, (list, dict, set)) and len(value) == 0:
return "REJECTED (empty collection)"
return "accepted"
print("--- Blind version (if not x) ---")
test_values = [0, '', None, [], 0.0, 42]
labels = ['0', "''", 'None', '[]', '0.0', '42']
for val, label in zip(test_values, labels):
result = validate_blind(val)
extra = ""
if result == "REJECTED" and isinstance(val, (int, float)) and val == 0:
extra = " (but 0 is valid!)" if isinstance(val, int) else " (but 0.0 is valid!)"
print(f"validate_blind({label}): {result}{extra}")
print("--- Precise version (is None) ---")
for val, label in zip(test_values, labels):
result = validate_precise(val)
print(f"validate_precise({label}): {result}")Solution
--- Blind version (if not x) ---
validate_blind(0): REJECTED (but 0 is valid!)
validate_blind(''): REJECTED
validate_blind(None): REJECTED
validate_blind([]): REJECTED
validate_blind(0.0): REJECTED (but 0.0 is valid!)
validate_blind(42): accepted
--- Precise version (is None) ---
validate_precise(0): accepted
validate_precise(''): REJECTED (empty string)
validate_precise(None): REJECTED (None)
validate_precise([]): REJECTED (empty collection)
validate_precise(0.0): accepted
validate_precise(42): accepted
Boolean blindness in practice:
The if not value check treats all these as equivalent:
None-- "not provided" (probably invalid)0-- a valid integer (e.g., a score of zero, a balance of zero)0.0-- a valid float (e.g., a temperature of 0 degrees)""-- an empty string (might be invalid depending on context)[]-- an empty list (might be invalid depending on context)False-- a valid boolean value
Decision guide:
| What you want to check | Use |
|---|---|
| Only None is invalid | if x is None: |
| None or empty string | if not x: (only for strings where "" is invalid) |
| Any falsy value is invalid | if not x: (rare -- make sure 0 is truly invalid) |
| Specific type constraints | isinstance() + explicit checks |
Expected Output
--- Blind version (if not x) ---
validate_blind(0): REJECTED (but 0 is valid!)
validate_blind(''): REJECTED
validate_blind(None): REJECTED
validate_blind([]): REJECTED
validate_blind(0.0): REJECTED (but 0.0 is valid!)
validate_blind(42): accepted
--- Precise version (is None) ---
validate_precise(0): accepted
validate_precise(''): REJECTED (empty string)
validate_precise(None): REJECTED (None)
validate_precise([]): REJECTED (empty collection)
validate_precise(0.0): accepted
validate_precise(42): acceptedHints
Hint 1: `if not x` treats 0, 0.0, empty string, empty list, None, and False identically. When 0 or 0.0 are valid inputs, this check incorrectly rejects them.
Hint 2: Use `if x is None` when only None is invalid. Use explicit type and emptiness checks when you need to distinguish between different falsy values.
Hard
The function below contains 5 anti-patterns. Identify each one, then study the fixed version. Run the fixed version to verify it handles valid zeros, type errors, missing keys, and resource cleanup correctly.
import logging
import tempfile
import os
logging.basicConfig(level=logging.WARNING, format="%(message)s")
logger = logging.getLogger("pipeline")
# FIXED version — all anti-patterns corrected
def process_records(records, output_path, results=None, discount=None):
# Fix 1: None sentinel instead of mutable default
if results is None:
results = []
errors = []
# Fix 2: context manager for file resource
with open(output_path, "w") as f:
for i, record in enumerate(records):
try:
# Fix 3: explicit type coercion at boundary
price = float(record["price"])
qty = int(record["quantity"])
total = price * qty
# Fix 4: explicit None check (0.0 discount is valid)
if discount is not None:
total = total * (1 - float(discount) / 100)
results.append(total)
f.write(f"record {i}: {total}\n")
# Fix 5: catch specific exceptions, not bare except
except (ValueError, TypeError) as e:
logger.warning("Record %d failed: %s: %s", i, type(e).__name__, e)
errors.append({"record": i, "error": str(e), "type": type(e).__name__})
except KeyError as e:
logger.warning("Record %d missing field: %s", i, e)
errors.append({"record": i, "error": str(e), "type": "KeyError"})
return results, errors
# Test data with edge cases
records = [
{"price": "40", "quantity": "3"}, # Valid: 120.0
{"price": "99", "quantity": "2.5"}, # Valid: 247.5 (float qty)
{"price": "abc", "quantity": "2"}, # Bad price
{"quantity": "5"}, # Missing price key
]
tmp = tempfile.mktemp()
results, errors = process_records(records, tmp, discount=0.0)
print(f"Results: {results}")
print(f"Errors: {len(errors)}")
for i, err in enumerate(errors):
print(f"Error {i}: record {err['record']} - {err['type']}")
# Verify file was closed properly
if os.path.exists(tmp):
os.unlink(tmp)Solution
Results: [120.0, 247.5]
Errors: 2
Error 0: record 2 - ValueError
Error 1: record 3 - KeyError
The 5 anti-patterns that were fixed:
-
Mutable default argument (
results=[]): Changed toresults=Nonewithif results is None: results = []inside the function body. Without this fix, every call accumulates results into the same shared list. -
Resource leak (
f = open(...)withoutwith): Changed towith open(output_path, "w") as f:. Without this fix, if any record processing raises an unexpected exception, the file is never closed and the file descriptor leaks. -
Bare except / catching too broadly (
except: pass): Changed toexcept (ValueError, TypeError)andexcept KeyError-- specific exception types with logging. Without this fix, all failures are silenced and the caller has no diagnostic information. -
Boolean blindness (
if discount:): Changed toif discount is not None:. Without this fix,discount=0.0(a valid zero percent discount) is treated as "no discount" because0.0is falsy. -
Lost exception context: The fixed version logs each error with the exception type and message using
logger.warning(), and collects structured error records. The original pattern ofexcept: passdiscarded all error information.
Why the test data matters:
"quantity": "2.5"tests that float conversion works (not just int)"price": "abc"tests ValueError handling- Missing
"price"key tests KeyError handling discount=0.0tests that zero discount is applied (not ignored)
Expected Output
Results: [120.0, 247.5]
Errors: 2
Error 0: record 2 - ValueError
Error 1: record 3 - KeyErrorHints
Hint 1: Count the anti-patterns: bare except, mutable default, resource leak (no with), lost exception context, boolean blindness on discount. There are at least 5.
Hint 2: The fixed version should: use None sentinel for results, use with-statement for the file, catch specific exceptions, preserve exception context, and use `is not None` for the discount check.
Build a 3-layer exception chain (database connection error, wrapped by data access layer, wrapped by service layer). Walk the chain to prove all context is preserved. This is the production pattern for exception handling in layered architectures.
# Domain exception hierarchy
class AppError(Exception):
"""Base for all application errors."""
pass
class DataAccessError(AppError):
"""Raised by the data access layer."""
pass
class ServiceError(AppError):
"""Raised by the service layer."""
pass
# Layer 1: Database
def db_connect(host):
raise ConnectionError(f"Database connection refused")
# Layer 2: Data access
def query_user(user_id):
try:
conn = db_connect("db.internal")
except ConnectionError as e:
raise DataAccessError(
f"Query failed: SELECT * FROM users WHERE id={user_id}"
) from e
# Layer 3: Service
def get_user_profile(user_id):
try:
return query_user(user_id)
except DataAccessError as e:
raise ServiceError(
f"User service failed for user_id={user_id}"
) from e
# Walk the exception chain
print("--- Layer traversal ---")
try:
get_user_profile(99)
except ServiceError as e:
print(f"Caught: {type(e).__name__}")
print(f"Message: {e}")
print(f"Cause: {type(e.__cause__).__name__}")
print(f"Cause message: {e.__cause__}")
print(f"Root cause: {type(e.__cause__.__cause__).__name__}")
print(f"Root cause message: {e.__cause__.__cause__}")
# Count chain length
chain = []
current = e
while current is not None:
chain.append(type(current).__name__)
current = current.__cause__
print(f"Full chain length: {len(chain)}")
print("--- Chain types ---")
print(" -> ".join(chain))Solution
--- Layer traversal ---
Caught: ServiceError
Message: User service failed for user_id=99
Cause: DataAccessError
Cause message: Query failed: SELECT * FROM users WHERE id=99
Root cause: ConnectionError
Root cause message: Database connection refused
Full chain length: 3
--- Chain types ---
ServiceError -> DataAccessError -> ConnectionError
How the chain is built:
db_connect()raisesConnectionError("Database connection refused").query_user()catches theConnectionErrorand raisesDataAccessError(...) from e. TheConnectionErroris stored asDataAccessError.__cause__.get_user_profile()catches theDataAccessErrorand raisesServiceError(...) from e. TheDataAccessErroris stored asServiceError.__cause__.
The result is a linked list: ServiceError.__cause__ is DataAccessError, whose __cause__ is ConnectionError, whose __cause__ is None.
In a production traceback, Python prints:
ConnectionError: Database connection refused
The above exception was the direct cause of the following exception:
DataAccessError: Query failed: SELECT * FROM users WHERE id=99
The above exception was the direct cause of the following exception:
ServiceError: User service failed for user_id=99
An on-call engineer reads bottom-to-top: the service failed because the query failed because the database connection was refused. Root cause identified in seconds.
Without from e: Each layer would create a standalone exception. The traceback would show only the outermost ServiceError with no indication that a connection failure was the root cause.
Expected Output
--- Layer traversal ---
Caught: ServiceError
Message: User service failed for user_id=99
Cause: DataAccessError
Cause message: Query failed: SELECT * FROM users WHERE id=99
Root cause: ConnectionError
Root cause message: Database connection refused
Full chain length: 3
--- Chain types ---
ServiceError -> DataAccessError -> ConnectionErrorHints
Hint 1: Each layer wraps the exception from the layer below using `raise X from e`. The `__cause__` attribute forms a linked list of exceptions.
Hint 2: Walk the chain by following `__cause__` until it is None. Each step reveals a layer of the system that failed.
Build a production-quality batch sensor processor that correctly avoids all 10 anti-patterns from the lesson. The processor must accept records with string fields (data boundary), validate ranges, handle missing keys, manage a file resource, use a None sentinel for accumulator, and distinguish valid zeros from invalid data.
import logging
import tempfile
import os
from dataclasses import dataclass, field
logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s")
logger = logging.getLogger("sensor")
@dataclass
class BatchResult:
successes: list = field(default_factory=list)
failures: list = field(default_factory=list)
def process_sensors(
records,
output_path,
accumulator=None, # Not accumulator=[] (anti-pattern 6)
max_temp=None, # None means no cap; 0.0 is a valid cap (anti-pattern 9)
):
if accumulator is None:
accumulator = []
result = BatchResult()
# Anti-pattern 5 fix: context manager
with open(output_path, "w") as f:
for i, record in enumerate(records):
try:
# Anti-pattern 7 fix: explicit type coercion at boundary
sensor_id = str(record["sensor_id"])
temp = float(record["temperature"])
humidity = float(record["humidity"])
# Anti-pattern 9 fix: explicit range check, not truthiness
if not (-50 <= temp <= 100):
raise ValueError(f"Temperature {temp} outside range [-50, 100]")
if not (0 <= humidity <= 100):
raise ValueError(f"Humidity {humidity} outside range [0, 100]")
# Anti-pattern 9 fix: explicit None check (0.0 cap is valid)
if max_temp is not None and temp > max_temp:
raise ValueError(f"Temperature {temp} exceeds cap {max_temp}")
heat_index = round(temp + 0.33 * humidity - 4.0, 2)
processed = {
"sensor_id": sensor_id,
"temp": temp,
"humidity": humidity,
"heat_index": heat_index,
}
result.successes.append(processed)
accumulator.append(processed)
f.write(f"{sensor_id}: {heat_index}\n")
# Anti-pattern 2 fix: catch specific types only
except (ValueError, TypeError) as e:
# Anti-pattern 10 fix: log with context before recording
logger.warning("Record %d (%s): %s", i, record.get("sensor_id", "unknown"), e)
result.failures.append({
"index": i,
"sensor_id": record.get("sensor_id", "unknown"),
"error_type": type(e).__name__,
"error": str(e),
})
except KeyError as e:
logger.warning("Record %d missing field %s", i, e)
result.failures.append({
"index": i,
"sensor_id": record.get("sensor_id", "unknown"),
"error_type": "KeyError",
"error": str(e),
})
# Anti-pattern 1 fix: no bare except — KeyboardInterrupt etc propagate
return result, f
# Test data with edge cases
records = [
{"sensor_id": "sensor-01", "temperature": "22.5", "humidity": "60"},
{"sensor_id": "sensor-02", "temperature": "150", "humidity": "40"},
{"sensor_id": "sensor-03", "temperature": "18.0", "humidity": "75"},
{"sensor_id": "sensor-04", "temperature": "broken", "humidity": "50"},
{"sensor_id": "sensor-05", "temperature": "25.0"},
{"sensor_id": "sensor-06", "temperature": "0.0", "humidity": "0"},
]
tmp = tempfile.mktemp()
acc1 = []
result, file_obj = process_sensors(records, tmp, accumulator=acc1)
# Verify independence: call again with no accumulator
result2, _ = process_sensors(records[:1], tmp)
print(f"Successes: {len(result.successes)}")
for s in result.successes:
print(f" {s['sensor_id']}: temp={s['temp']}, humidity={s['humidity']}, heat_index={s['heat_index']}")
print(f"Failures: {len(result.failures)}")
for f_item in result.failures:
print(f" Record {f_item['index']} ({f_item['sensor_id']}): {f_item['error_type']} - {f_item['error']}")
# Verify accumulator independence (anti-pattern 6 test)
acc2 = []
process_sensors(records[:1], tmp, accumulator=acc2)
print(f"Accumulator independence: {acc1 is not acc2}")
# Verify file closed (anti-pattern 5 test)
print(f"File closed: {file_obj.closed}")
if os.path.exists(tmp):
os.unlink(tmp)Solution
Successes: 3
sensor-01: temp=22.5, humidity=60.0, heat_index=38.3
sensor-03: temp=18.0, humidity=75.0, heat_index=38.75
sensor-06: temp=0.0, humidity=0.0, heat_index=-4.0
Failures: 3
Record 1 (sensor-02): ValueError - Temperature 150.0 outside range [-50, 100]
Record 3 (sensor-04): ValueError - could not convert string to float: 'broken'
Record 4 (sensor-05): KeyError - 'humidity'
Accumulator independence: True
File closed: True
All 10 anti-patterns addressed:
| Anti-Pattern | How It Is Avoided |
|---|---|
| 1. Swallowing exceptions | No bare except: pass -- specific types caught, logged, recorded |
| 2. Catching too broadly | Only (ValueError, TypeError) and KeyError -- not except Exception |
| 3. Lost exception context | Errors include type name, message, record index, and sensor ID |
| 4. Exceptions for flow control | Direct iteration with for i, record in enumerate(records) |
| 5. Resource leak | with open(output_path, "w") as f: guarantees close |
| 6. Mutable default | accumulator=None with if accumulator is None: accumulator = [] |
| 7. Silent type coercion | float(record["temperature"]) at boundary, explicit conversion |
| 8. Ignoring return values | Results appended directly, no result = list.append() |
| 9. Boolean blindness | if max_temp is not None -- allows max_temp=0.0 as valid cap |
| 10. Catch-reraise without log | logger.warning() with full context for every failure |
Critical test: sensor-06 with zeros. Temperature 0.0 and humidity 0.0 are valid sensor readings. A boolean blindness check (if not temp) would reject them. The explicit range check if not (-50 <= temp <= 100) correctly accepts zero because 0.0 is within the range.
Expected Output
Successes: 3
sensor-01: temp=22.5, humidity=60.0, heat_index=38.3
sensor-03: temp=18.0, humidity=75.0, heat_index=38.75
sensor-06: temp=0.0, humidity=0.0, heat_index=-4.0
Failures: 3
Record 1 (sensor-02): ValueError - Temperature 150.0 outside range [-50, 100]
Record 3 (sensor-04): ValueError - could not convert string to float: 'broken'
Record 4 (unknown): KeyError - 'humidity'
Accumulator independence: True
File closed: TrueHints
Hint 1: The processor must handle: None sentinel for mutable default, with-statement for file, specific exception catching, explicit None check for max_temp (0.0 is valid), and logging before continuing.
Hint 2: Valid zeros must be accepted: temperature=0.0 and humidity=0.0 are legitimate readings. The boolean blindness anti-pattern would reject them.
