Skip to main content

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 what raise from e does 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 like 0 and ""
  • 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

CategoryAnti-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 data contain invalid types that the ORM rejected?
  • Did cache.invalidate() raise a connection timeout?
  • Did user_id not exist in the database?
  • Was there a syntax error in the values argument?
  • 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 - if raw_value is None (caller bug, not a parse error)
  • OverflowError - if raw_value is 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 clauseGuidance
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, specifically psycopg2.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:

CallEffect
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, returns None
  • sorted(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:

  • None
  • 0 (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

SituationUse
None is the ONLY invalid valueif 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 uncertainif 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:

  1. Use finally: for cleanup instead of putting it in except:. The finally block 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 (the finally version also releases on success via the lock.acquire/try pattern, but the correct idiom for locks is with lock:).
  2. 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-PatternBad CodeRoot ProblemFix
Swallow exceptionsexcept: passHides all failures, no diagnosticsCatch specific types, log with logger.exception()
Catch too broadlyexcept Exception: return 0Masks programming bugs as "handled"Catch minimum needed: except ValueError:
Lose exception contextraise NewError(str(e))Destroys original tracebackraise NewError("msg") from e
Exceptions for flow controltry: d[k] except: return defaultHides logic, exception overheadd.get(k, default)
Resource leakf = open(x) without withfd/connection leak under errorsAlways use with open(x) as f:
Mutable default argumentdef f(lst=[]):Shared state across all callsdef f(lst=None): if lst is None: lst = []
Type assumptionprice * multiplier without coercingFails on string input from formsValidate and coerce types at the data boundary
Ignoring return valuesresult = list.sort()sort() returns None, not the listUse sorted(list) to get a new list
Boolean blindnessif not age: (rejects 0)Treats all falsy values identicallyif age is None: when 0 is valid
Catch-cleanup-reraise without logCleanup + raise, no loggingContext lost if caller silences errorlogger.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:

  • BaseException subclasses: 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 to d.get(key, default), though .get() is clearer
  • try: 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:

  1. The caller might silence it. Generic top-level handlers (except Exception: return 500) catch and suppress the exception without logging details.

  2. Context is lost if you wait. At the point of the except block 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.

  3. 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:

  1. Mutable default argument (results=[]) - all calls share the same list
  2. Resource leak (f = open(...) without with) - file not closed if exception occurs
  3. Boolean blindness (if discount:) - rejects discount=0.0 as "no discount"
  4. Losing exception context (raise RuntimeError(str(e))) - destroys original traceback
  5. Swallowing exceptions (outer except: pass) - hides all errors from the caller
  6. 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:

  1. Accept a list of raw records (dicts from an external API, may have string number fields)
  2. Process each record: parse types, calculate a derived field, validate a range
  3. Collect all successfully processed records AND all failures (with reasons)
  4. Log each failure with full context but never swallow exceptions that indicate programmer bugs
  5. Clean up a file resource even if processing fails partway through
  6. Accept an optional accumulator list (processed_so_far=None, not [])
  7. 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-PatternSymptomsFix
Bare except: passSilent failures, no logs, impossible to debugCatch specific types + logger.exception()
except Exception:Masks programmer bugs as handled errorsUse the minimum specific exception types
raise New(str(e))Traceback shows new exception, not root causeraise NewError("msg") from original
Exception for flow controlLogic hidden in exception handlersUse .get(), isinstance(), for item in collection
f = open() without withFile descriptor leaks, "too many open files"Always with open() as f:
def f(lst=[])Shared mutable state across all callsdef f(lst=None) then if lst is None: lst = []
price * rate without coercingTypeError on string data from forms/JSONCoerce and validate types at data boundaries
result = list.sort()result is None, crash on next useUse 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 logContext lost if caller silences errorlogger.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 than except Exception:, which is always better than except:
  • Always use raise NewError("msg") from original when 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 with statements 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; use None and create the mutable object inside the function body
  • The if not x: truthiness check is dangerous when 0, "", or [] are legitimate values - use if x is None: when None is the only invalid sentinel
  • list.sort() returns None and mutates in place; sorted(list) returns a new sorted list - assigning the return value of sort() 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
© 2026 EngineersOfAI. All rights reserved.