Written for a reader who already knows what a hash table, a closure and a linearised MRO are, and wants
the CPython specifics rather than the introduction. Pages 1–31 are the language as it is actually
implemented: object and scope semantics, the container types and their costs, comprehensions and the iteration
protocol, pattern matching, the descriptor and operator protocols, exception groups, the import machinery, the
type system, async/await, the GIL and the free-threaded build — then four cards on
the interpreter itself: object layout and the two-mechanism collector, the specialising adaptive interpreter and
its bytecode, attribute lookup and C3, and binary-operator dispatch. The remaining pages index the built-ins,
the operators, the data model and the standard library by category. Type in the filter box — or press
/ — to narrow the index; hover any name for its signature.
| Flag | Effect |
|---|---|
-O / -OO | Set __debug__=False: strips assert and if __debug__; -OO also strips docstrings |
-B | Do not write __pycache__/*.pyc (also PYTHONDONTWRITEBYTECODE) |
-u | Unbuffered stdout/stderr — the fix when logs vanish in a pipe or container |
-W error | Turn warnings into exceptions. -W error::DeprecationWarning to pick one |
-X dev | Development mode: extra checks, default warning filters, faulthandler on |
-X importtime | Print per-import cost — the first thing to run on a slow CLI |
-X gil=0 | Free-threaded build only: run without the GIL (3.13+, officially supported in 3.14) |
-P | Do not prepend the script's directory to sys.path (safer imports) |
-I | Isolated: -P -s plus ignore all PYTHON* environment variables |
-v -q -E -s | Trace imports · quiet banner · ignore env vars · ignore user site-packages |
Always python -m pip, never bare pip. The bare command may belong to a different interpreter; -m guarantees you install into the Python you are running.
uv and ruff are third-party (Astral). pip and venv ship with CPython; ensurepip re-installs pip into a broken env.
Read it back at runtime with tomllib (stdlib, read-only, binary mode) or
importlib.metadata.version("mytool"). setup.py and distutils are gone —
distutils was removed in 3.12.
Uniform reference semantics, no value types: every name is a pointer to a heap object carrying an identity, a type and a value, and assignment rebinds the pointer without copying. Functions, classes, modules and None are ordinary first-class objects; the only thing that varies is whether the referent is mutable. Layout and reclamation are in Object Layout & Memory.
The consequence that costs people time: parameter passing is call-by-object-reference, so a function can mutate its argument but cannot rebind the caller’s name.
| Immutable | Mutable |
|---|---|
int float complex bool | list |
str bytes | dict set bytearray |
tuple frozenset range | most class instances |
None Ellipsis NotImplemented | array, deque, memoryview targets |
A tuple is immutable but can hold mutable things: t=([1],) then t[0].append(2) works. Hashability follows immutability — that tuple is not hashable.
Python 3 forbids mixing tabs and spaces for indentation outright (TabError). PEP 8: 4 spaces, 79-column lines (most projects use 88 — black/ruff format default).
An object is falsy if __bool__ returns False, else if __len__ returns 0, else it is truthy. Falsy built-ins: False None 0 0.0 0j Decimal(0) Fraction(0) "" b"" () [] {} set() range(0).
len(x) > 0 or compare to True. Write if items: and if flag:. But test for None explicitly with is None — 0, "" and [] are legitimate values that are also falsy.CPython interns small ints and some strings. is on numbers or strings is a bug waiting for a bigger input. In 3.8+ you get a SyntaxWarning for is with a literal.
A name is looked up in four scopes, innermost first: Local → Enclosing function → Global (module) → Built-in. Class bodies are not in that chain for nested functions.
def f(): print(x); x = 1 raises UnboundLocalError on the print, not the assignment.Closures capture the variable, not its value. Bind with a default argument, or functools.partial.
Comprehensions and generator expressions get their own scope, so the loop variable does not leak — unlike a for statement, whose variable survives the loop. The walrus operator deliberately assigns into the enclosing scope.
Arbitrary precision, no overflow. Literals may carry _ separators and a base prefix.
Converting an int of more than 4300 digits to str raises ValueError since 3.11 (a DoS fix). Raise it with sys.set_int_max_str_digits().
IEEE-754 binary64: 53 bits of mantissa, ~15–17 significant decimal digits. Most decimal fractions are not representable.
Decimal from a string. Decimal(0.1) faithfully copies the float's error.Strings are immutable sequences of Unicode code points. Every mutation
method returns a new string.
Since 3.12 (PEP 701) f-strings may reuse the same quote inside, nest arbitrarily, contain backslashes and span multiple lines: f"{d["key"]}" is now legal.
| Piece | Values |
|---|---|
| align | < left · > right · ^ centre · = pad after the sign |
| sign | + always · - negatives only · space — a space for positives |
| grouping | , thousands · _ underscore (also every 4 digits for b o x) |
| type: int | d b o x X c n |
| type: float | f F e E g G n % — g is the default, % multiplies by 100 |
| type: str | s (default); .precision truncates |
# / z | alternate form (0x prefix) · coerce negative zero to +0 (3.11+) |
Logging takes %-style lazily: log.info("got %s", obj), not an f-string — the formatting is skipped entirely if the level is off.
New in 3.14. A t"..." literal hands you the pieces before interpolation, so a library can escape them properly — the foundation for safe HTML and SQL builders. It has no __str__; you must process it.
Python 3 keeps text and bytes strictly apart. str is a sequence of Unicode code points with no encoding; bytes is a sequence of 0–255 integers with no meaning. The bridge is always explicit.
| Handler | On bad input |
|---|---|
strict | raise UnicodeDecodeError / EncodeError — the default |
replace | substitute U+FFFD � (decode) or ? (encode) |
ignore | drop the offending bytes — silent data loss |
surrogateescape | round-trip undecodable bytes through lone surrogates — how Python survives filenames that are not valid UTF-8 |
backslashreplace | \xNN escapes — good for logs |
xmlcharrefreplace | &#NNN;, encode only |
PEP 686 makes UTF-8 mode the default in a future release; passing encoding= explicitly is forward-compatible either way. In 3.15 the default for open() becomes UTF-8.
e + a combining accent. macOS filenames arrive in NFD. Normalise before comparing or hashing user text.s[start:stop:step]Slices never raise IndexError — out-of-range bounds clamp. A bare index does raise. That asymmetry hides bugs; xs[5:6] silently gives [].
xs.sort() returns None. ys = xs.sort() gives you None. Same for reverse(), append(), extend() — every in-place method. Use sorted() when you want a value.Timsort is stable: equal keys keep their original order, so you can sort by secondary key first, then primary. There is no cmp=; wrap an old comparator in functools.cmp_to_key.
grid = [[0]*3]*3 makes three references to one row. grid[0][0]=1 changes all three. Write [[0]*3 for _ in range(3)].| Operation | list | deque | dict / set |
|---|---|---|---|
| index / key lookup | O(1) | O(n) | O(1) |
| append / add | O(1)* | O(1) | O(1) |
| insert or pop at front | O(n) | O(1) | — |
x in s | O(n) | O(n) | O(1) |
If a membership test sits inside a loop, the container should be a set. That single change is the most common real-world Python speed-up there is.
Dicts preserve insertion order — an implementation detail in 3.6, guaranteed since 3.7. Equality still ignores order.
RuntimeError: dictionary changed size during iteration. Iterate list(d), or build a new dict.Hashable = has __hash__ and the hash never changes. All immutables qualify; list, dict and set do not. True == 1 == 1.0, so they are the same key: {1: "a", True: "b"} has one entry.
defaultdict creates on read. Merely testing dd["missing"] inserts the key. Use k in dd or dd.get(k) to look without writing.Set and dict iteration order is arbitrary but deterministic within one run — and for str/bytes keys it changes between runs because hashing is salted. Set PYTHONHASHSEED=0 for reproducibility, or sort before you print.
Reach for a genexp when the result feeds straight into sum, any, max, join or a loop: constant memory instead of a full list. Reach for a list when you need to index it, keep it, or walk it twice.
An iterable has __iter__. An iterator has __iter__ (returning itself) and __next__, and raises StopIteration when exhausted. for calls iter() then next() until it stops.
zip, map, filter, reversed, file objects and generators all yield once and are then empty — a second for over them does nothing. Wrap in list() if you need it twice.A generator holds its whole local state between yields. That makes it the cheapest possible coroutine, and the reason with inside a generator is dangerous: the file is only closed when the generator is exhausted, closed or collected.
Statements, with the desugaring worth carrying in your head:
Read else as no break
. It is the clean way to express searched the whole thing and found nothing
without a found-flag.
for x in xs: xs.remove(x) skips elements. Iterate over a copy (for x in xs[:]) or, better, build a new list with a comprehension.assert vanishes under python -O. Never validate arguments, permissions or user data with it. And assert (cond, "msg") with parentheses asserts a non-empty tuple — always true, always useless.Structural pattern matching (PEP 634) decomposes a value and binds names in one step. It is not a C switch — the power is in destructuring, not in equality.
| Pattern | Matches |
|---|---|
42, "go", None, True | Literal. None True False compare with is, the rest with == |
name | Capture — always matches and binds. A bare name is never a constant test |
_ | Wildcard: matches, binds nothing |
[a, b, *rest] | Sequence — any Sequence except str, bytes, bytearray |
{"k": v, **rest} | Mapping — a partial match; extra keys are fine |
Point(x=0, y=y) | Class pattern: isinstance check, then attributes |
Point(0, y) | Positional — requires __match_args__ on the class |
Color.RED, mod.CONST | Value pattern — a dotted name is compared, not bound |
x as p, str() as s | AS pattern: match and also bind the whole |
int() | float() | Or-pattern; every alternative must bind the same names |
case RED: does not test against your RED constant — it matches everything and rebinds RED. Use a dotted name (case Color.RED:) or a literal.@dataclass and NamedTuple set __match_args__ for you. Builtins accept one positional sub-pattern for their own value: case str() | bytes():, case int(n) if n > 0:.
No case matching and no case _ is not an error — the whole statement is simply skipped. Add an explicit case _: raise ValueError(...) when silence would hide a bug.
match and case are soft keywords: existing code with variables named match still works.
| Form | Meaning |
|---|---|
a | Positional-or-keyword |
a=1 | Default. Evaluated once, at definition time |
*args | Remaining positionals, as a tuple |
* | Everything after it is keyword-only |
**kwargs | Remaining keywords, as a dict |
/ | Everything before it is positional-only (3.8+) |
def add(x, items=[]): shares one list across every call, forever. Write items=None and if items is None: items = [] in the body. The same applies to {}, set(), datetime.now() and any call.PEP 8 says do not assign a lambda to a name — use def, which gives the function a real __name__ for tracebacks. Lambdas are for arguments.
Since 3.12 CPython no longer consumes a C stack frame per Python frame, so deep recursion raises a clean RecursionError rather than crashing the process.
A decorator is a callable that takes a function and returns a replacement. @d above def f is exactly f = d(f).
@functools.wraps. Without it the wrapper hides the original's name, docstring, annotations and signature — breaking help(), tracebacks, pickling and every framework that introspects.Stacked decorators apply bottom-up: @a over @b over def f gives a(b(f)).
@cache on a method keeps every self alive forever — the cache holds a strong reference, so instances never get collected. Cache module-level functions, or use cached_property.class C: items = [] then c.items.append(1) changes it for all of them. Assign per-instance in __init__.Data descriptors on the type → the instance __dict__ → non-data descriptors and class attributes along the MRO → __getattr__. __getattribute__ intercepts everything; __getattr__ runs only after normal lookup fails. The full algorithm, and why @property cannot be shadowed while a method can, is in Attribute Lookup, Descriptors & the MRO.
super() walks the MRO, not the parent — which is what makes cooperative multiple inheritance work. Every class in such a chain must call super() and accept **kwargs.
| Group | Methods |
|---|---|
| Construction | __new__ __init__ __del__ __init_subclass__ __set_name__ |
| Display | __repr__ (unambiguous, for you) __str__ (readable, for users) __format__ |
| Comparison | __eq__ __ne__ __lt__ __le__ __gt__ __ge__ __hash__ |
| Container | __len__ __getitem__ __setitem__ __delitem__ __contains__ __iter__ __reversed__ |
| Numeric | __add__ __radd__ __iadd__ __mul__ __neg__ __abs__ __round__ __index__ |
| Callable / context | __call__ __enter__ __exit__ __aenter__ __aexit__ |
| Attributes | __getattr__ __getattribute__ __setattr__ __delattr__ __dir__ |
| Descriptors | __get__ __set__ __delete__ |
__eq__ and Python sets __hash__ = None — your instances become unhashable and unusable as dict keys. Add __hash__ yourself, or use @dataclass(frozen=True), or eq=False.Smaller and faster for many small objects, and it turns typos into AttributeError instead of a silent new attribute. Cost: no arbitrary attributes, and no __weakref__ unless you list it.
The decorator writes __init__, __repr__ and __eq__ from the annotated class attributes, in order.
| Option | Effect |
|---|---|
frozen=True | Immutable; assignment raises. Makes it hashable (with eq=True) |
slots=True | 3.10+ — add __slots__: smaller, faster, no stray attributes |
kw_only=True | 3.10+ — every field keyword-only; sidesteps the default-ordering rule |
order=True | Add __lt__ … comparing the field tuple |
eq=False | Keep identity equality and the inherited __hash__ |
TypeError at class creation. Reorder, give a default, or use kw_only=True.Immutable, indexable, unpackable and comparable as a plain tuple — ideal for return values and dict keys. collections.namedtuple("Point","x y") is the untyped original.
Since 3.11, str(Color.RED) is 'Color.RED' and f"{Color.RED}" agrees — the 3.10 mismatch is gone. IntEnum formats as its number.
| Need | Use |
|---|---|
| Mutable record with behaviour | @dataclass |
| Immutable key / return value | NamedTuple or @dataclass(frozen=True) |
| Fixed set of names | Enum family |
| Just annotating a dict's shape | TypedDict — no runtime class at all |
| Validation & parsing from JSON | pydantic or attrs |
Keep the try body as small as possible, and put the code that must not be guarded in else. That is the whole point of the clause.
except Exception is broad; bare except: is worse — it swallows KeyboardInterrupt and SystemExit too. Catch the narrowest type you can actually handle. Never write except: pass.Raising inside an except block sets __context__ automatically, and you get During handling of the above exception, another occurred
. from sets __cause__ and says so.
except Exception deliberately misses KeyboardInterrupt and SystemExit, which sit beside it under BaseException. That is the design, and the reason to prefer it over a bare except.
Give a package one base class so callers can catch everything with one clause. Subclass ValueError/OSError when your error genuinely is one.
Several except* clauses may fire for one group — unlike except, where the first match wins. This is how asyncio.TaskGroup reports several concurrent failures at once.
3.11+ tracebacks carry fine-grained ^^^^ markers under the exact failing sub-expression, and 3.12+ suggests the name you probably meant.
with expr as name calls expr.__enter__() and binds its return value; on the way out it calls __exit__(exc_type, exc, tb). A truthy return from __exit__ swallows the exception — almost never what you want.
try/finally is not optional. Without it an exception in the with body propagates through the yield and your teardown never runs.A module is one .py file. A package is a directory Python can import — with an __init__.py (regular) or without one (namespace package, PEP 420). Modules are executed once and cached in sys.modules.
random.py, json.py or email.py shadows the real one for the whole program, with baffling errors. Same for a stray __pycache__ next to a deleted source file.A module run directly has __name__ == "__main__"; imported, it has its own name. raise SystemExit(main()) sets the process exit status without sys.exit imports. A package with a __main__.py runs with python -m pkg.
The src layout stops your tests importing the working copy instead of the installed package — the single most common packaging bug. Install with pip install -e . and imports are the same in tests, CI and production.
A imports B, B imports A: whichever loads second sees a half-built module and fails on a name. Fixes, in order of preference: move the shared thing to a third module; import inside the function that needs it; use import a (module object) instead of from a import thing; or guard type-only imports:
Annotations are not checked at runtime. They are read by mypy, pyright/Pylance and your editor, and by libraries that opt in (dataclasses, pydantic, FastAPI).
Since 3.9 the built-in containers are generic — write list[int], dict[str, int], tuple[int, ...], set[str]. typing.List and friends are deprecated. Since 3.10, X | Y replaces Union[X, Y] and X | None replaces Optional[X].
The old spelling — T = TypeVar("T"), class Box(Generic[T]) — still works and is what you need below 3.12.
3.14 implements PEP 649/749: annotations are evaluated on demand, not at definition time. Forward references just work, and the from __future__ import annotations workaround is no longer needed.
| Mode | Meaning |
|---|---|
r w a | read · truncate-or-create · append |
x | exclusive create — FileExistsError if it exists |
b / t | binary (gives bytes, no encoding) / text, the default |
+ | add the other direction: r+, w+, a+ |
Pass newline="" when reading or writing CSV, otherwise embedded newlines in quoted fields are mangled.
JSON has no date, tuple, set or bytes type; tuples become lists and all keys become strings. Round-tripping a dict with int keys does not give you int keys back.
"\b" is a backspace character; r"\b" is the word-boundary you meant. This is the number-one regex bug in Python.| Atom | Matches | Atom | Matches |
|---|---|---|---|
. | any char except newline | \d \D | digit / non-digit |
^ $ | start / end of string (or line with re.M) | \w \W | word char [a-zA-Z0-9_] / not |
[abc] [^abc] | class / negated class | \s \S | whitespace / not |
\b \B | word boundary / not | \A \Z | absolute start / end |
* + ? | 0+, 1+, 0 or 1 | {m,n} | m to n times |
*? +? ?? | lazy — as few as possible | *+ ++ | possessive, no backtracking (3.11+) |
(...) | capturing group | (?:...) | group, no capture |
(?P<name>...) | named group → m["name"] | (?P=name) | backreference by name |
a|b | alternation, leftmost wins | \1 | backreference to group 1 |
(?=...) (?!...) | lookahead: positive / negative | (?<=...) (?<!...) | lookbehind (fixed width) |
(a+)+b, (\s*\w+)*$ — can take exponential time on a non-matching input. Anchor the pattern, make quantifiers lazy or possessive, or use a parser. Do not parse HTML, JSON or CSV with regex.Cooperative concurrency in one thread: a coroutine runs until it awaits, hands control back to the event loop, and resumes when its result is ready. It makes I/O concurrent — network calls, disk, subprocesses. It does nothing for CPU-bound code.
| Rule | Why |
|---|---|
await only inside async def | Otherwise SyntaxError |
| Calling a coroutine does nothing | It returns a coroutine object; it runs when awaited or scheduled |
One asyncio.run() per program | It creates and closes the loop. Never call it from inside a coroutine |
| Never block in a coroutine | time.sleep, requests.get, heavy CPU: they stall every task |
| Hold a reference to bare tasks | create_task keeps only a weak reference — unreferenced tasks can vanish. TaskGroup solves this |
CancelledError inherits from BaseException, not Exception, so except Exception will not eat it — but except BaseException will. If you catch it, re-raise.| Workload | Tool |
|---|---|
| Many network calls | asyncio + httpx/aiohttp, or anyio/trio |
| A few blocking calls in async code | asyncio.to_thread |
| Blocking I/O, no async library | ThreadPoolExecutor |
| CPU-bound | ProcessPoolExecutor, or the free-threaded build |
In the default build one global interpreter lock lets only one thread execute Python bytecode at a time. Threads still win for I/O — the GIL is released around every blocking call, and by C extensions such as NumPy. For CPU work in the standard build you need processes.
concurrent.futures is the right default: the same API for both, exceptions surface at .result(), and the with block joins everything.
x += 1 is not atomic. Read, add, store — the GIL can switch between them. Any shared mutable state needs a lock, or a queue.Queue.spawn start method the child re-imports your module, so process-creating code must sit behind if __name__ == "__main__": or you fork-bomb yourself.PEP 703 builds (python3.14t) run with no GIL at all: real parallel threads for pure Python. Officially supported since 3.14, but a separate build — single-threaded code is somewhat slower and C extensions must opt in.
3.14 ships concurrent.interpreters (PEP 734): interpreters in one process, each with its own GIL, so they run in parallel without the pickling cost of separate processes.
| Symptom | Reach for |
|---|---|
| Waiting on the network or disk | threads, or asyncio |
| Burning CPU in pure Python | processes, or the free-threaded build |
| Burning CPU in NumPy / C | threads — those release the GIL |
| Thousands of concurrent sockets | asyncio — threads do not scale that far |
Built-in fixtures worth knowing: tmp_path, capsys, monkeypatch, caplog, request. Shared fixtures live in conftest.py.
Levels: DEBUG 10 · INFO 20 · WARNING 30 (the default) · ERROR 40 · CRITICAL 50. Libraries should only ever getLogger(__name__) and never call basicConfig — configuring logging is the application's job.
| Trap | Fix |
|---|---|
def f(x, items=[]) | One list for all calls, forever. Use items=None |
[[0]*3]*3 | Three references to one row. Use a comprehension |
lambda: i in a loop | Late binding — all closures see the last i. Use lambda i=i: |
| Mutating a list while iterating | Skips items. Iterate a copy or build a new list |
x is 256 works, x is 257 does not | is is identity. Use == |
0.1 + 0.2 != 0.3 | math.isclose, or Decimal for money |
except: pass | Hides KeyboardInterrupt and your bugs. Name the exception |
assert user.is_admin | Vanishes under -O. Raise instead |
zip(a, b) with different lengths | Silently truncates. Pass strict=True |
s += x in a loop | O(n²) copying. "".join(parts) |
x in big_list inside a loop | O(n) each time. Make it a set |
A local file named random.py | Shadows the stdlib for the whole program |
d[k] += 1 on a new key | KeyError. Counter or defaultdict(int) |
open() with no encoding= | Locale-dependent; breaks on another machine |
| Comparing NFC and NFD text | Normalise first (unicodedata.normalize) |
copy() of a nested structure | Shallow. copy.deepcopy when it matters |
datetime.now() for durations | Wall clock jumps. time.perf_counter() |
| Naive datetimes | Always attach a tzinfo; store UTC, display local |
cProfile for where, timeit for how much. Intuition about Python performance is usually wrong.set/dict for membership, deque for both ends, bisect for sorted lookup, array/bytes for homogeneous numbers.@functools.cache on a pure function is often a 100× win for free.str.join, sum, map, itertools, bytes.translate, NumPy.try that does not raise costs nothing at all, while if k in d: d[k] pays two lookups. Prefer try/except KeyError when the miss is rare, d.get when it is not. See Execution Model & Bytecode.Every value is a heap-allocated PyObject: a refcount, a type pointer, and whatever the type adds. There are no unboxed scalars, no tagged pointers and no value types — [1, 2, 3] is an array of three pointers to three PyLongObjects. That single fact explains most of Python’s memory profile and most of its speed profile.
Reference counting frees acyclic garbage deterministically, at the moment the last reference drops — which is why with works and why CPython needs no finalisation queue. A generational mark-and-sweep collector exists only to break cycles among container objects. Three generations; a collection is triggered by allocation-minus-deallocation thresholds, not by memory pressure.
3.12 moved the collector to a mostly-incremental scheme and made gen0 threshold-scaled; 3.13 removed the untracking of tuples that never contain containers. Neither changes the contract: do not rely on __del__ ordering, and use weakref.finalize when you need a callback.
sys.intern() on millions of repeated dict keys turns every subsequent comparison into a pointer compare.| Type | Representation |
|---|---|
list | Growable pointer array, over-allocated ~12.5% — amortised O(1) append, O(n) front insert |
dict | Compact (PEP 509 era): a sparse index array of small ints plus a dense insertion-ordered entry array. Hence ordering, and ~30% less memory than 3.5 |
set | Open addressing, no dense array — so no ordering guarantee, unlike dict |
str | PEP 393 flexible representation: 1, 2 or 4 bytes per code point, chosen by the widest character. O(1) indexing, and one astral character quadruples the string |
| instances | A __dict__ pointer, usually key-sharing with the class; __slots__ replaces it with a fixed array |
Hashing of str and bytes is SipHash-1-3 with a per-process random key (PEP 456) — set PYTHONHASHSEED for reproducibility. Small-int hashing is the identity, modulo 2**61-1, which is why hash(1) == hash(1.0) == hash(Decimal(1)) and why they collide as dict keys on purpose.
Source is parsed by a PEG parser (PEP 617, since 3.9) into an AST, compiled to a stack-based bytecode over a control-flow graph, and executed by an eval loop. There is no user-visible IR between AST and bytecode, and no ahead-of-time type information.
Hot instructions rewrite themselves in place into type-specialised variants guarded by an inline cache: BINARY_OP becomes BINARY_OP_ADD_INT, LOAD_ATTR becomes a slot offset, CALL becomes a direct dispatch. A guard failure deoptimises back to the generic form. This is quickening, not JIT compilation — it happens per code object, after a warm-up counter, with no machine code emitted.
The corollary for optimisation: monomorphic code is fast, polymorphic code is not. A loop that sees one type per site specialises and stays specialised; one that alternates types thrashes the guards and pays for both.
| 3.11–3.14 change | Consequence |
|---|---|
| Frames are a contiguous data stack, not heap objects | Calls got roughly 3× cheaper; sys._getframe() materialises one lazily |
| Python-to-Python calls are inlined in the eval loop | No C recursion per Python frame, so deep recursion raises cleanly |
| Zero-cost exceptions | try costs nothing when nothing raises; the handler table is out of line. EAFP is now genuinely free on the happy path |
| 3.13 experimental copy-and-patch JIT; 3.14 tail-calling interpreter | Real machine code, off by default; the tail-call build is ~5–10% on top |
sys.monitoring (PEP 669, 3.12) | Event-based instrumentation with no cost when unused — what profilers should target instead of settrace |
Names are the other cost: LOAD_FAST indexes an array, LOAD_GLOBAL probes two dicts (now with an inline cache), and an attribute is a full lookup. Hoisting append = out.append out of a loop is not superstition — it turns two lookups per iteration into an array index.
obj.x is type(obj).__getattribute__(obj, "x"). The default implementation, in order:
type(obj).__mro__ for "x". If found and it is a data descriptor (defines __set__ or __delete__), call its __get__ and stop.obj.__dict__. If present, return it.__get__ — every plain function), call __get__; otherwise return it as-is.AttributeError, which triggers type(obj).__getattr__ if the class defines one.@property is a data descriptor, so it beats the instance dict — which is why assigning to a read-only property raises instead of shadowing it. A plain method is a non-data descriptor, so obj.method = something does shadow it.Functions, property, classmethod, staticmethod, __slots__ entries and functools.cached_property are all descriptors. Bound methods are created on every access: f.__get__(obj, cls) returns a fresh MethodType, which is why obj.m is obj.m is False.
The MRO is the C3 merge of the class, its bases’ MROs and the list of bases — the unique order preserving local precedence and monotonicity. If no such order exists, the class statement itself raises TypeError.
super() is a proxy over the MRO of type(self), starting after the class where the call textually appears — not over the base class. Cooperative multiple inheritance therefore requires every class in the chain to call super() and to tolerate keyword arguments it does not consume. The zero-argument form works because the compiler inserts an implicit __class__ cell.
A class body is executed once, in its own scope, and is not in the lexical chain of methods defined inside it — which is why a comprehension in a class body cannot see the class-level names it sits beside.
There is no numeric tower coercion and no implicit conversion. a + b is resolved entirely by type slots:
type(b) is a proper subclass of type(a) and overrides __radd__, try b.__radd__(a) first — the subclass gets right of way.type(a).__add__(a, b).NotImplemented singleton, try type(b).__radd__(b, a).NotImplemented, raise TypeError.NotImplemented, do not raise, and never return NotImplementedError. Returning the sentinel is what lets the other operand’s reflected method run; raising short-circuits a protocol that was about to succeed. Special methods are looked up on the type, never on the instance — assigning obj.__add__ does nothing.The six comparisons have no default relationship. __lt__ and __gt__ are each other’s reflections; the reflected call happens whenever the first returns NotImplemented. Only __eq__ gets a default (identity), and only __ne__ is derived. @functools.total_ordering fills in the rest at a small speed cost.
Containers short-circuit on identity before calling __eq__ — x in [nan_obj] is True for that exact object and False for an equal-valued one. That is the documented behaviour, not a bug.
Which release introduced the thing you are about to use — the question that actually matters when a target machine is not on the newest Python.
| Ver | Headline additions |
|---|---|
| 3.9 Oct 2020 | dict | dict merge · str.removeprefix/removesuffix · list[int] as an annotation · zoneinfo · functools.cache · math.lcm |
| 3.10 Oct 2021 | match statement (PEP 634) · X | Y union types · parenthesised context managers · zip(strict=True) · itertools.pairwise · much better error messages |
| 3.11 Oct 2022 | 10–60% faster (PEP 659) · exception groups and except* · add_note() · asyncio.TaskGroup and timeout · tomllib · Self · StrEnum · fine-grained error locations in tracebacks |
| 3.12 Oct 2023 | PEP 695 generics — def f[T](), type X = ... · f-strings unshackled (PEP 701) · @override · itertools.batched · pathlib.Path.walk · per-interpreter GIL · distutils removed |
| 3.13 Oct 2024 | New interactive REPL (colour, multiline, paste) · experimental free-threaded build (PEP 703) · experimental JIT · TypeIs · warnings.deprecated · PEP 667 locals() semantics · more dead batteries removed |
| 3.14 Oct 2025 | Deferred annotations (PEP 649/749) and the annotationlib module · t-strings (PEP 750) · free-threading officially supported · concurrent.interpreters (PEP 734) · except without parentheses (PEP 758) · tail-calling interpreter · safe external debugger attach (PEP 768) |
requires-python = ">=3.11" in pyproject.toml stops pip installing your package where it cannot run. Backports worth knowing: tomli, typing_extensions (every new typing feature, on old Pythons), exceptiongroup, backports.zoneinfo.
Release cadence: one minor version each October, two years of bugfixes, then three of security-only — five years in all. 3.9 reached end of life in October 2025.
Every name in the index was checked against CPython 3.14.6 running on this machine — the build script imports each module and asserts that each documented attribute exists, so a name that was renamed or removed cannot survive a rebuild. Signatures in the hover tooltips come from inspect.signature where the object exposes one.
The prose half is written from the Language Reference, the Library Reference and the PEPs they cite. Four well-known sheets were used as coverage checks — what a Python sheet has to contain, not what it should say: Real Python’s cheat sheet for the beginner's spine, gto76’s Comprehensive Python Cheatsheet for the density target and the standard-library breadth, Eric Matthes’ Python Crash Course sheets for what a learner looks up first, and Martelli, Ravenscroft, Holden & McGuire, Python in a Nutshell (O’Reilly, 4th ed.) for the object model and the shape of the reference half.
Where those sources disagree with 3.14, 3.14 wins — several widely-copied sheets still show
typing.List, datetime.utcnow(), distutils and pre-PEP-604 unions.
The four interpreter cards are pitched at a reader who wants the mechanism, and are drawn from the
CPython source and its design documents rather than from the tutorial layer: the data model chapter of the
Language Reference, Objects/dictobject.c and Objects/listobject.c for the container
representations, PEP 393 (flexible string representation), PEP 456 (SipHash), PEP 659 (the specialising adaptive
interpreter), PEP 669 (sys.monitoring), PEP 703 (free-threading) and PEP 734 (subinterpreters), plus
Michele Simionato’s write-up of C3 linearisation. Sizes, thresholds and the specialised opcodes were read off
this interpreter with sys.getsizeof, gc.get_threshold and
dis.dis(…, adaptive=True), not quoted.
The Traps card and the version table are the parts that are not in the official documentation at all: they are collected from what actually goes wrong.
Filter box at the top matches names, signatures and descriptions across the whole index — type
async, path, 3.12, deprecated or hash to slice it by
topic. Press / to jump to it, Esc to clear. The page is one self-contained file: no fonts,
no scripts, no analytics, and it prints to landscape.