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
2 changes: 2 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
for f in examples/*.py; do
echo "::group::$f"
python "$f" || { echo "::error file=$f::example failed"; status=1; }
# Development mode enables asyncio debug checks, which change behavior.
python -X dev "$f" > /dev/null || { echo "::error file=$f::example failed under -X dev"; status=1; }
echo "::endgroup::"
done
exit $status
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Found a Python behavior that surprised you? Please add it.
## 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.
2. Add a section under the matching category: `README.md` for fundamentals, or the relevant page in `docs/` for advanced topics, 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
Expand Down
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

A catalog of Python gotchas, traps and surprising behaviors, each with a minimal reproduction, an explanation of *why* it happens, and the fix.
A catalog of 52 Python gotchas, traps and surprising behaviors, from beginner-level surprises to asyncio, threading, CPython internals and data-engineering bugs. Each comes with a minimal reproduction, an explanation of *why* it happens, and the fix.

Python's syntax prioritizes readability, but a readable surface can hide complex, leaky internal state. John Ousterhout's *A Philosophy of Software Design* calls the worst symptom of complexity *unknown unknowns*: behavior you cannot predict from the interface in front of you. When the language's mechanics don't match its readable syntax, developers are forced to hold a mental model of the interpreter's state, and usually discover the truth through runtime bugs.

Here is a log of the design traps developers learn the hard way.

> Every snippet below has a runnable, self-checking twin in [`examples/`](examples/). CI runs them on CPython 3.10 to 3.14, so the outputs shown here are verified, not remembered.
> Every snippet here and in [`docs/`](docs/) has a runnable, self-checking twin in [`examples/`](examples/). CI runs them on CPython 3.10 to 3.14, so the outputs shown here are verified, not remembered.

## Contents

### Part I: Fundamentals

| # | Pitfall | What you see | The fix |
|---|---------|--------------|---------|
| | **Iterators & laziness** | | |
Expand Down Expand Up @@ -42,6 +44,17 @@ Here is a log of the design traps developers learn the hard way.
| 18 | [Banker's rounding](#18-round-uses-bankers-rounding) | `round(2.5) == 2` | `Decimal` + `ROUND_HALF_UP` |
| 19 | [`return` in `finally`](#19-return-in-finally-swallows-exceptions) | Exception vanishes | Never return from `finally` |

### Part II: Advanced

| Page | # | Covers |
|------|---|--------|
| [Concurrency: asyncio & threading](docs/concurrency.md) | 20–30 | Garbage-collected tasks, forgotten `await`, blocking the event loop, swallowed cancellation, cross-thread loop calls, non-atomic `+=`, vanishing thread exceptions, `Lock` re-entry, `threading.local` under asyncio, `fork()` deadlocks, daemon threads |
| [Object model & imports](docs/object-model.md) | 31–37 | Properties vs `__getattr__`, `@lru_cache` leaks, `__eq__` vs `__hash__`, `__exit__` swallowing errors, weak refs to bound methods, `mock.patch` targets, double-imported `__main__` |
| [Interpreter internals](docs/interpreter.md) | 38–42 | `python -O` stripping asserts, class-body comprehension scope, `locals()` writes, `id()` reuse, the 4300-digit `int`/`str` limit |
| [Data systems: time, numbers, bytes](docs/data-systems.md) | 43–52 | `hash()` sharding, NaN in containers, nanosecond timestamps in floats and `datetime`, float prices, DST arithmetic, fixed UTC offsets, `utcnow()`, `struct` alignment, non-standard JSON |

See also [In the wild](#in-the-wild-home-assistant) for how a large production codebase hits and guards against these.

---

## Iterators & laziness
Expand Down Expand Up @@ -205,6 +218,8 @@ print(bob.items) # Output: ['apple'] (Bob never added anything!)

**The fix:** Create per-instance state in `__init__`, or use a dataclass with `field(default_factory=list)`. Dataclasses refuse a bare `items: list = []` default for exactly this reason.

**In the wild:** Home Assistant [removed class-level mutable defaults](https://github.com/home-assistant/core/pull/170119) from an integration after a reviewer spotted them. They hadn't caused a bug yet only because nothing mutated them.

[Example](examples/06_shared_class_attributes.py)

### 7. Modifying a List During Iteration
Expand Down Expand Up @@ -484,6 +499,28 @@ print(save()) # Output: saved

---

## In the wild: Home Assistant

[Home Assistant Core](https://github.com/home-assistant/core) is one of the largest asyncio codebases in open source: thousands of integrations, written by different people, sharing a single event loop. Several pitfalls above show up in its recent bug fixes, and its core has guard rails built specifically against them.

**Bugs fixed:**

| Fix | Pitfall |
|-----|---------|
| [Alert notifications fired in a tight loop during the DST fall-back hour](https://github.com/home-assistant/core/pull/188537) | [#48](docs/data-systems.md#48-aware-datetime-arithmetic-is-wall-clock-arithmetic): `now() + delay` is wall-clock arithmetic |
| [Automatic backups ran an hour off after every DST change](https://github.com/home-assistant/core/pull/188836) | [#49](docs/data-systems.md#49-a-utc-offset-is-not-a-time-zone): a parsed `+02:00` is not a time zone |
| [Hikvision updated state from a library thread](https://github.com/home-assistant/core/pull/160027) | [#24](docs/concurrency.md#24-loop-methods-arent-thread-safe): loop APIs aren't thread-safe |
| [Class-level mutable defaults in an integration](https://github.com/home-assistant/core/pull/170119) | [#6](#6-shared-mutable-class-attributes): shared class attributes |

**Guard rails in core:**

- **Task tracking.** `hass.async_create_task()` stores every task in a set and removes it on completion, so tasks can't be garbage-collected mid-flight ([#20](docs/concurrency.md#20-fire-and-forget-tasks-get-garbage-collected)).
- **Blocking-call detection.** [`block_async_io.py`](https://github.com/home-assistant/core/blob/dev/homeassistant/block_async_io.py) wraps `time.sleep`, `open`, `os.listdir`, `importlib.import_module`, SSL certificate loading and more, and reports any call made from the event loop ([#22](docs/concurrency.md#22-blocking-calls-freeze-the-event-loop)).
- **Thread checks.** `verify_event_loop_thread()` compares `threading.get_ident()` with the loop's thread and raises when a loop-only API is called from elsewhere ([#24](docs/concurrency.md#24-loop-methods-arent-thread-safe)).
- **Linting.** The project [enabled 19 more flake8-bugbear rules](https://github.com/home-assistant/core/pull/170494) through ruff, covering mutable defaults, loop-variable shadowing and similar traps.

The lesson generalizes: when a pitfall can't be designed away, make it fail loudly. Every guard rail above turns a silent, intermittent bug into an immediate error with a clear message.

## Running the examples

Each file in [`examples/`](examples/) prints the surprising output and `assert`s it, so a script that runs without error confirms the behavior described above.
Expand Down
Loading
Loading