Skip to main content

Python Common Error Anti-Patterns: Practice Problems & Exercises

Practice: Common Error Anti-Patterns

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Bare Except Catches EverythingEasy
bare-exceptSystemExitKeyboardInterruptpredict-output

Predict the output. Observe which exceptions a bare except: catches versus except Exception:. Understand why bare except: is dangerous.

Python
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: True
Hints

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.

#2Silent Swallow vs Logged HandlerEasy
swallowing-exceptionsloggingsilent-failurepredict-output

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.

Python
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.

#3Pokemon Exception Handling — Gotta Catch 'Em AllEasy
catching-too-broadlyexcept-Exceptionmasking-bugspredict-output

Predict the output. The "Pokemon" handler catches everything. The specific handler only catches what it is designed for. See which caller bugs get hidden.

Python
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:

  1. pokemon_parse(None) returns 0 -- but passing None to a string parser is a bug in the caller. The caller should never pass None here. Silently returning 0 means the bug propagates further downstream, eventually causing a mysterious data error.

  2. pokemon_parse([1, 2]) returns 0 -- passing a list to int() 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.

#4Error Code Returns vs ExceptionsEasy
error-codesreturn-valuesanti-patternpredict-output

Predict the output. Compare error-code-style returns (returning None on failure) with exception-based error reporting. See how error codes hide bugs.

Python
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 TypeError
Hints

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

#5Lost Exception Context — raise fromMedium
exception-chainingraise-from__cause__traceback

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.

Python
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 context
  • raise X inside 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: True
Hints

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."

#6Overuse of try/except — When Not to CatchMedium
overuse-try-exceptEAFP-vs-LBYLflow-controlpredict-output

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.

Python
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:

  1. Exception as loop termination: The while True + IndexError pattern 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 data states the iteration clearly.

  2. Exception as dict.get replacement: try: d[key] except KeyError: default does exactly what d.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.

  3. Exception as type dispatch: The logic "if numeric multiply, else stringify" is hidden inside an exception handler. A reader sees value * 2 and 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') = hihi
Hints

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.

#7Resource Leak Under ExceptionMedium
resource-leakcontext-managerwith-statementfile-descriptor

Demonstrate that a file opened without with leaks when an exception occurs. Then show that the with statement guarantees cleanup.

Python
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: True
Hints

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.

#8Boolean Blindness — Falsy Values That Are ValidMedium
boolean-blindnessfalsytruthinessNone-checkpredict-output

Predict the output. Compare if not x (boolean blindness) with precise validation. See which valid inputs get incorrectly rejected.

Python
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 checkUse
Only None is invalidif x is None:
None or empty stringif not x: (only for strings where "" is invalid)
Any falsy value is invalidif not x: (rare -- make sure 0 is truly invalid)
Specific type constraintsisinstance() + 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): accepted
Hints

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

#9Fix All Anti-Patterns in a Data PipelineHard
multiple-anti-patternsrefactoringproduction-codefix-bug

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.

Python
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:

  1. Mutable default argument (results=[]): Changed to results=None with if results is None: results = [] inside the function body. Without this fix, every call accumulates results into the same shared list.

  2. Resource leak (f = open(...) without with): Changed to with 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.

  3. Bare except / catching too broadly (except: pass): Changed to except (ValueError, TypeError) and except KeyError -- specific exception types with logging. Without this fix, all failures are silenced and the caller has no diagnostic information.

  4. Boolean blindness (if discount:): Changed to if discount is not None:. Without this fix, discount=0.0 (a valid zero percent discount) is treated as "no discount" because 0.0 is falsy.

  5. 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 of except: pass discarded 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.0 tests 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 - KeyError
Hints

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.

#10Exception Chaining in a Multi-Layer SystemHard
exception-chainingraise-frommulti-layerproduction-pattern

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.

Python
# 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:

  1. db_connect() raises ConnectionError("Database connection refused").
  2. query_user() catches the ConnectionError and raises DataAccessError(...) from e. The ConnectionError is stored as DataAccessError.__cause__.
  3. get_user_profile() catches the DataAccessError and raises ServiceError(...) from e. The DataAccessError is stored as ServiceError.__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 -> ConnectionError
Hints

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.

#11Build a Resilient Batch Processor — All Anti-Patterns AvoidedHard
production-patternall-anti-patternsbatch-processingresilient

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.

Python
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-PatternHow It Is Avoided
1. Swallowing exceptionsNo bare except: pass -- specific types caught, logged, recorded
2. Catching too broadlyOnly (ValueError, TypeError) and KeyError -- not except Exception
3. Lost exception contextErrors include type name, message, record index, and sensor ID
4. Exceptions for flow controlDirect iteration with for i, record in enumerate(records)
5. Resource leakwith open(output_path, "w") as f: guarantees close
6. Mutable defaultaccumulator=None with if accumulator is None: accumulator = []
7. Silent type coercionfloat(record["temperature"]) at boundary, explicit conversion
8. Ignoring return valuesResults appended directly, no result = list.append()
9. Boolean blindnessif max_temp is not None -- allows max_temp=0.0 as valid cap
10. Catch-reraise without loglogger.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: True
Hints

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.

© 2026 EngineersOfAI. All rights reserved.