Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Verify examples

on:
push:
branches: [main]
pull_request:

jobs:
verify:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Run every example
run: |
status=0
for f in examples/*.py; do
echo "::group::$f"
python "$f" || { echo "::error file=$f::example failed"; status=1; }
echo "::endgroup::"
done
exit $status
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.venv/
.DS_Store
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Contributing

Found a Python behavior that surprised you? Please add it.

## What makes a good pitfall

- **Surprising.** Readable code that does something other than what it says.
- **Minimal.** Reproducible in a handful of lines, with no third-party packages.
- **Fixable.** There is a clear, idiomatic way to avoid it.
- **Verified.** The output shown is what CPython actually prints, not what you remember it printing.

## Adding one

1. Create `examples/NN_short_name.py`, numbered after the last example. Show the trap, `assert` the surprising result, then show and `assert` the fix. Follow the layout of the existing files.
2. Add a section to `README.md` under the matching category, with **The Trap** snippet, a short **Why**, **The fix**, and a link to your example. Add a row to the Contents table.
3. Run every example locally:

```sh
for f in examples/*.py; do python "$f" || echo "FAILED: $f"; done
```

4. Open a pull request. CI runs the examples on CPython 3.10 to 3.14.

If behavior differs between Python versions, say which versions in the README and guard the example with `sys.version_info`.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Ehsan Rahman

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
479 changes: 440 additions & 39 deletions README.md

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions examples/01_iterator_exhaustion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Iterator exhaustion: looking at an iterator consumes it."""

# The trap
my_string = "hello"
reversed_iter = reversed(my_string)

first = list(reversed_iter)
second = list(reversed_iter)
print(first) # ['o', 'l', 'l', 'e', 'h']
print(second) # [] (silently empty!)
assert first == ["o", "l", "l", "e", "h"]
assert second == []

# `in` consumes too: it advances the iterator until it finds a match.
squares = (n * n for n in range(6))
assert 9 in squares
assert list(squares) == [16, 25] # 0, 1, 4 and 9 are gone

# An exhausted (or empty) iterator is still truthy.
empty = (n for n in [])
assert bool(empty) is True

# The fix: materialize once, then reuse the list as often as you like.
reversed_list = list(reversed(my_string))
assert reversed_list == ["o", "l", "l", "e", "h"]
assert reversed_list == ["o", "l", "l", "e", "h"]
23 changes: 23 additions & 0 deletions examples/02_zip_truncation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""zip() silently truncates, and can swallow an element from an iterator."""

# The trap: unequal lengths drop data without a warning.
names = ["Ada", "Grace", "Linus"]
scores = [95, 88]
assert list(zip(names, scores)) == [("Ada", 95), ("Grace", 88)] # Linus vanished

# Worse: zip pulls from the first iterator before noticing the second is empty.
numbers = iter([1, 2, 3, 4])
pairs = list(zip(numbers, ["a", "b"]))
leftover = list(numbers)
print(pairs) # [(1, 'a'), (2, 'b')]
print(leftover) # [4] (3 was pulled out and thrown away)
assert pairs == [(1, "a"), (2, "b")]
assert leftover == [4]

# The fix: strict=True (Python 3.10+) turns silent truncation into an error.
try:
list(zip(names, scores, strict=True))
except ValueError as e:
print(e) # zip() argument 2 is shorter than argument 1
else:
raise AssertionError("expected ValueError")
41 changes: 41 additions & 0 deletions examples/03_lazy_generator_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Generators don't run any code until they are iterated."""


# The trap
def chunks(data, size):
if size <= 0:
raise ValueError("size must be positive")
for i in range(0, len(data), size):
yield data[i : i + size]


batches = chunks([1, 2, 3], 0) # No error here...

try:
list(batches) # ...it only surfaces here, far from the bug.
except ValueError as e:
print(f"Raised late: {e}")
else:
raise AssertionError("expected ValueError")


# The fix: validate eagerly in a normal function, then hand off to a generator.
def chunks_fixed(data, size):
if size <= 0:
raise ValueError("size must be positive")
return _chunks(data, size)


def _chunks(data, size):
for i in range(0, len(data), size):
yield data[i : i + size]


try:
chunks_fixed([1, 2, 3], 0) # Fails immediately, at the call site.
except ValueError:
pass
else:
raise AssertionError("expected ValueError")

assert list(chunks_fixed([1, 2, 3], 2)) == [[1, 2], [3]]
27 changes: 27 additions & 0 deletions examples/04_mutable_default_arguments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Mutable default arguments are created once, at definition time."""


# The trap
def add_item(item, target=[]):
target.append(item)
return target


print(add_item("Apple")) # ['Apple']
print(add_item("Banana")) # ['Apple', 'Banana'] (Apple leaked!)
assert add_item("Cherry") == ["Apple", "Banana", "Cherry"]

# The shared default is visible on the function object itself.
assert add_item.__defaults__ == (["Apple", "Banana", "Cherry"],)


# The fix: use None as a sentinel and build a fresh object per call.
def add_item_fixed(item, target=None):
if target is None:
target = []
target.append(item)
return target


assert add_item_fixed("Apple") == ["Apple"]
assert add_item_fixed("Banana") == ["Banana"]
22 changes: 22 additions & 0 deletions examples/05_list_multiplication_aliasing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Multiplying a list of lists copies references, not the inner lists."""

# The trap
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
assert grid == [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
assert grid[0] is grid[1] is grid[2]

# Same trap, different spelling: dict.fromkeys shares one value object.
groups = dict.fromkeys(["a", "b"], [])
groups["a"].append(1)
assert groups == {"a": [1], "b": [1]}

# The fix: a comprehension builds a new inner object on each pass.
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
assert grid == [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

groups = {key: [] for key in ["a", "b"]}
groups["a"].append(1)
assert groups == {"a": [1], "b": []}
40 changes: 40 additions & 0 deletions examples/06_shared_class_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Mutable class attributes are shared by every instance."""

from dataclasses import dataclass, field


# The trap
class Cart:
items = []

def add(self, item):
self.items.append(item)


alice, bob = Cart(), Cart()
alice.add("apple")
print(bob.items) # ['apple'] (Bob never added anything!)
assert bob.items == ["apple"]


# The fix: create per-instance state in __init__ ...
class CartFixed:
def __init__(self):
self.items = []

def add(self, item):
self.items.append(item)


alice, bob = CartFixed(), CartFixed()
alice.add("apple")
assert bob.items == []


# ... or let a dataclass do it with default_factory.
@dataclass
class CartData:
items: list = field(default_factory=list)


assert CartData().items is not CartData().items
31 changes: 31 additions & 0 deletions examples/07_modifying_list_during_iteration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Removing items from a list while looping over it skips elements."""

# The trap
numbers = [2, 4, 6, 8]
for n in numbers:
if n % 2 == 0:
numbers.remove(n)

print(numbers) # [4, 8] ("remove every even number" left half of them behind)
assert numbers == [4, 8]

# Dicts and sets at least fail loudly.
d = {"a": 1, "b": 2}
try:
for key in d:
del d[key]
except RuntimeError as e:
print(e) # dictionary changed size during iteration
else:
raise AssertionError("expected RuntimeError")

# The fix: build a new list (or iterate over a copy).
numbers = [2, 4, 6, 8]
numbers = [n for n in numbers if n % 2 != 0]
assert numbers == []

numbers = [2, 4, 6, 8]
for n in numbers[:]:
if n % 2 == 0:
numbers.remove(n)
assert numbers == []
19 changes: 19 additions & 0 deletions examples/08_tuple_augmented_assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""t[i] += x on a tuple raises TypeError *after* mutating the list inside it."""

# The trap
t = (1, 2, [3, 4])

try:
t[2] += [5, 6]
except TypeError as e:
print(f"Error caught: {e}")
else:
raise AssertionError("expected TypeError")

print(t) # (1, 2, [3, 4, 5, 6]) (the list mutated anyway!)
assert t == (1, 2, [3, 4, 5, 6])

# The fix: mutate the inner list directly; no reassignment, no error.
t = (1, 2, [3, 4])
t[2].extend([5, 6])
assert t == (1, 2, [3, 4, 5, 6])
22 changes: 22 additions & 0 deletions examples/09_late_binding_closures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Closures look up variables when called, not when defined."""

# The trap
funcs = [lambda: i for i in range(3)]
results = [f() for f in funcs]
print(results) # [2, 2, 2]
assert results == [2, 2, 2]

# The fix: bind the current value as a default argument ...
funcs = [lambda i=i: i for i in range(3)]
assert [f() for f in funcs] == [0, 1, 2]

# ... or use functools.partial.
from functools import partial


def identity(x):
return x


funcs = [partial(identity, i) for i in range(3)]
assert [f() for f in funcs] == [0, 1, 2]
22 changes: 22 additions & 0 deletions examples/10_loop_variable_leak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""for-loop variables outlive the loop and overwrite outer names."""

# The trap
i = "Safe string"
for i in range(5):
pass

print(i) # 4 (the original string was silently overwritten)
assert i == 4

# A loop over an empty sequence never assigns, so the old value survives.
item = "stale"
for item in []:
pass
assert item == "stale"

# Comprehensions have their own scope in Python 3 and do not leak.
x = "outer"
squares = [x * x for x in range(3)]
assert x == "outer"

# The fix: use distinct names, or move the loop into a function.
24 changes: 24 additions & 0 deletions examples/11_exception_variable_deleted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""The `as` name in an except block is deleted when the block ends."""

# The trap
e = "important data"
try:
1 / 0
except ZeroDivisionError as e:
pass

try:
print(e)
except NameError as err:
print(f"{type(err).__name__}: {err}") # NameError: name 'e' is not defined
else:
raise AssertionError("expected NameError")

# The fix: copy the exception to another name if you need it later.
error = None
try:
1 / 0
except ZeroDivisionError as exc:
error = exc

assert isinstance(error, ZeroDivisionError)
Loading
Loading