Table of Contents
- Mutable Default Arguments in Functions
- Misunderstanding Variable Scoping
- Confusing
iswith== - Improper Exception Handling (Bare
exceptClauses) - Overlooking Mutable vs. Immutable Data Structures
- Floating-Point Precision Errors
- Forgetting to Use Context Managers for Resources
- Misusing Class Attributes vs. Instance Attributes
- Late Binding in Closures and List Comprehensions
- Misconceptions About the Global Interpreter Lock (GIL) and Multithreading
1. Mutable Default Arguments in Functions
The Pitfall
Python evaluates function default arguments once when the function is defined (not on each call). If the default is a mutable object (e.g., list, dict), subsequent calls to the function will reuse the same object, leading to unexpected state retention.
Example of the Mistake:
def append_to_list(item, my_list=[]): # Mutable default!
my_list.append(item)
return my_list
print(append_to_list(1)) # Output: [1]
print(append_to_list(2)) # Output: [1, 2] (Unexpected! We expected [2])
Why It Happens
The default my_list=[] is created once when append_to_list is defined. Each call to the function reuses this same list, appending new items to it.
How to Avoid It
Use None as the default and initialize the mutable object inside the function.
Corrected Example:
def append_to_list(item, my_list=None):
if my_list is None:
my_list = [] # Initialize a new list on each call
my_list.append(item)
return my_list
print(append_to_list(1)) # Output: [1]
print(append_to_list(2)) # Output: [2] (Correct!)
2. Misunderstanding Variable Scoping
The Pitfall
Python’s scoping rules (LEGB: Local, Enclosing, Global, Built-in) can lead to unexpected behavior when modifying variables across scopes—especially in loops or nested functions.
Example 1: Loop Variable Leaking (Pre-Python 3.x)
In Python 2, loop variables leaked into the outer scope. While fixed in Python 3 for loops, list comprehensions in Python 2 still had this issue.
Example 2: Late Binding in Nested Functions
Nested functions may reference variables from an outer scope that change after the nested function is defined, leading to “late binding.”
def create_multipliers():
multipliers = []
for i in range(3):
def multiplier(x):
return i * x # 'i' is referenced from the outer scope
multipliers.append(multiplier)
return multipliers
# All multipliers use the final value of 'i' (which is 2)
m1, m2, m3 = create_multipliers()
print(m1(2)) # Output: 4 (Expected 0*2=0)
print(m2(2)) # Output: 4 (Expected 1*2=2)
print(m3(2)) # Output: 4 (Expected 2*2=4)
Why It Happens
Nested functions (like multiplier) capture variables by name, not by value. When multiplier is called, it uses the current value of i (which is 2 after the loop ends).
How to Avoid It
Force early binding by passing the variable as a default argument to the nested function:
Corrected Example:
def create_multipliers():
multipliers = []
for i in range(3):
def multiplier(x, i=i): # 'i' is captured as a default argument
return i * x
multipliers.append(multiplier)
return multipliers
m1, m2, m3 = create_multipliers()
print(m1(2)) # Output: 0 (Correct)
print(m2(2)) # Output: 2 (Correct)
print(m3(2)) # Output: 4 (Correct)
3. Confusing is with ==
The Pitfall
== checks for equality (values are the same), while is checks for identity (variables point to the same object in memory). Using is for equality can lead to subtle bugs.
Example of the Mistake:
x = "hello"
y = "hello"
print(x == y) # Output: True (values are equal)
print(x is y) # Output: True (accidentally works due to string interning)
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # Output: True (values are equal)
print(a is b) # Output: False (different objects in memory)
# Dangerous: Using 'is' to check for None (works, but '==' does too)
# But using 'is' for numbers can fail:
c = 1000
d = 1000
print(c == d) # Output: True
print(c is d) # Output: False (Python interns small integers, but not large ones)
Why It Happens
Python “interns” small integers (-5 to 256) and strings to save memory, so x is y may return True for equal small values. For larger values or mutable objects, is will return False even if values are equal.
How to Avoid It
- Use
==for checking if values are equal. - Use
isonly for checking identity (e.g.,x is Noneorx is ywhen you explicitly want to verify they’re the same object).
4. Improper Exception Handling (Bare except Clauses)
The Pitfall
Using a bare except: clause catches all exceptions, including KeyboardInterrupt (Ctrl+C) and SystemExit, which can crash your program or hide critical errors.
Example of the Mistake:
try:
risky_operation()
except: # Catches EVERY exception!
print("Something went wrong.")
If risky_operation() raises a KeyboardInterrupt (user presses Ctrl+C), the bare except will catch it, preventing the program from exiting gracefully.
Why It Happens
Bare except clauses are overly broad. They mask bugs (e.g., typos in variable names) and make debugging harder.
How to Avoid It
- Catch specific exceptions (e.g.,
ValueError,IOError). - If you need a “catch-all,” use
except Exception:(it excludesKeyboardInterruptandSystemExit).
Corrected Example:
try:
risky_operation()
except ValueError as e:
print(f"Invalid value: {e}")
except IOError as e:
print(f"File error: {e}")
except Exception as e: # Catch-all for other exceptions (use sparingly)
print(f"Unexpected error: {e}")
5. Overlooking Mutable vs. Immutable Data Structures
The Pitfall
Immutable objects (e.g., int, str, tuple) cannot be modified after creation. Mutable objects (e.g., list, dict, set) can. Confusing them leads to unexpected behavior when passing objects to functions.
Example of the Mistake:
def modify_list(my_list):
my_list.append(4) # Modifies the original list!
original_list = [1, 2, 3]
modify_list(original_list)
print(original_list) # Output: [1, 2, 3, 4] (Original list was mutated)
# Tuples are immutable, but if they contain mutable objects...
my_tuple = ([1, 2], 3)
my_tuple[0].append(4) # Modifies the list inside the tuple!
print(my_tuple) # Output: ([1, 2, 4], 3) (Tuple itself is immutable, but contents may not be)
Why It Happens
Mutable objects are passed by reference, so functions can modify the original object. Immutable objects are passed by value (a new copy is created).
How to Avoid It
- Use immutable types (
tuple,frozenset) when you don’t want accidental modifications. - If you need to modify a mutable object in a function without changing the original, pass a copy (e.g.,
my_list.copy()orlist(my_list)).
5. Overlooking Mutable vs. Immutable Data Structures
The Pitfall
Immutable objects (e.g., int, str, tuple) cannot be modified after creation. Mutable objects (e.g., list, dict, set) can. Confusing them leads to unexpected behavior when passing objects to functions.
Example of the Mistake:
def modify_list(my_list):
my_list.append(4) # Modifies the original list!
original_list = [1, 2, 3]
modify_list(original_list)
print(original_list) # Output: [1, 2, 3, 4] (Original list was mutated)
# Tuples are immutable, but if they contain mutable objects...
my_tuple = ([1, 2], 3)
my_tuple[0].append(4) # Modifies the list inside the tuple!
print(my_tuple) # Output: ([1, 2, 4], 3) (Tuple itself is immutable, but contents may not be)
Why It Happens
Mutable objects are passed by reference, so functions can modify the original object. Immutable objects are passed by value (a new copy is created).
How to Avoid It
- Use immutable types (
tuple,frozenset) when you don’t want accidental modifications. - If you need to modify a mutable object in a function without changing the original, pass a copy (e.g.,
my_list.copy()orlist(my_list)).
6. Floating-Point Precision Errors
The Pitfall
Floating-point numbers (e.g., 0.1) are stored as binary fractions, which cannot represent all decimals exactly. This leads to precision errors in arithmetic.
Example of the Mistake:
print(0.1 + 0.2) # Output: 0.30000000000000004 (Expected 0.3)
print(0.1 + 0.2 == 0.3) # Output: False
Why It Happens
0.1 in binary is a repeating fraction (0.0001100110011...), so it’s stored as an approximation. Adding two approximations leads to a small error.
How to Avoid It
- Use the
decimalmodule for precise decimal arithmetic. - Round results when comparing floating-point numbers.
Corrected Example:
from decimal import Decimal, getcontext
# Using decimal for precision
a = Decimal('0.1')
b = Decimal('0.2')
print(a + b) # Output: 0.3
# Rounding for comparison
print(round(0.1 + 0.2, 1) == 0.3) # Output: True
7. Forgetting to Use Context Managers for Resources
The Pitfall
Failing to close files, database connections, or network sockets can lead to resource leaks, corrupted data, or locked files.
Example of the Mistake:
file = open("data.txt", "w")
file.write("Hello, World!")
# If an error occurs here, 'file.close()' is never called!
file.close() # Manual close is error-prone
Why It Happens
Manual resource management relies on the developer remembering to call close(), which is easy to forget—especially if an exception interrupts the flow.
How to Avoid It
Use with statements (context managers) to auto-close resources.
Corrected Example:
with open("data.txt", "w") as file: # 'file' is auto-closed when the block ends
file.write("Hello, World!")
Context managers work for other resources too:
import sqlite3
with sqlite3.connect("mydb.db") as conn: # Connection auto-closed
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
8. Misusing Class Attributes vs. Instance Attributes
The Pitfall
Class attributes are shared across all instances, while instance attributes are unique to each instance. Confusing them leads to unexpected state sharing.
Example of the Mistake:
class Counter:
count = 0 # Class attribute (shared by all instances)
def increment(self):
self.count += 1
a = Counter()
a.increment()
print(a.count) # Output: 1
b = Counter()
print(b.count) # Output: 1 (Unexpected! 'b' was never incremented)
Why It Happens
count is a class attribute, so all instances share the same variable. When a.increment() runs, it modifies the class-level count.
How to Avoid It
Initialize instance attributes in __init__ to ensure uniqueness per instance.
Corrected Example:
class Counter:
def __init__(self):
self.count = 0 # Instance attribute (unique to each instance)
def increment(self):
self.count += 1
a = Counter()
a.increment()
print(a.count) # Output: 1
b = Counter()
print(b.count) # Output: 0 (Correct)
9. Late Binding in Closures and List Comprehensions
The Pitfall
Closures (nested functions) and list comprehensions in Python 2 bind variables late, meaning they use the variable’s value at execution time, not definition time.
Example of the Mistake:
# List comprehension in Python 2 (fixed in Python 3)
squares = [lambda: x**2 for x in range(3)]
print([f() for f in squares]) # Output: [4, 4, 4] (Expected [0, 1, 4])
In Python 3, list comprehensions have their own scope, but closures still suffer from late binding.
Why It Happens
The lambda captures x by name. When the lambda is called, x has the final value from the loop (2).
How to Avoid It
Force early binding by passing the variable as a default argument.
Corrected Example:
# Python 2/3 fix: Use default arguments to capture 'x' at definition time
squares = [lambda x=x: x**2 for x in range(3)]
print([f() for f in squares]) # Output: [0, 1, 4] (Correct)
10. Misconceptions About the Global Interpreter Lock (GIL) and Multithreading
The Pitfall
Assuming multithreading in Python speeds up CPU-bound tasks. The GIL prevents multiple threads from executing Python bytecode simultaneously, limiting parallelism.
Example of the Misconception:
import threading
def cpu_bound_task():
result = 0
for i in range(10**8):
result += i
# Two threads running CPU-bound tasks
t1 = threading.Thread(target=cpu_bound_task)
t2 = threading.Thread(target=cpu_bound_task)
t1.start()
t2.start()
t1.join()
t2.join()
# This will take ~same time as running sequentially (GIL limits parallelism)
Why It Happens
The GIL is a mutex that ensures only one thread executes Python bytecode at a time. For CPU-bound tasks, threads are serialized, negating speedups.
How to Avoid It
- Use
multiprocessingfor CPU-bound tasks (separate processes bypass the GIL). - Use multithreading only for I/O-bound tasks (threads release the GIL while waiting).
Corrected Example (CPU-Bound):
from multiprocessing import Process
def cpu_bound_task():
result = 0
for i in range(10**8):
result += i
# Two processes (bypass GIL)
p1 = Process(target=cpu_bound_task)
p2 = Process(target=cpu_bound_task)
p1.start()
p2.start()
p1.join()
p2.join()
# Runs in ~half the time of sequential execution (on a dual-core CPU)
Conclusion
Python’s simplicity belies its complexity, and even experienced developers can fall prey to these pitfalls. By understanding mutable defaults, scoping, exception handling, and other subtleties, you’ll write more reliable code. Remember to test edge cases, use linters (e.g., pylint), and consult Python’s official documentation to deepen your knowledge.