# Python Senior Interview Cheat Sheet

Quick-recall reference for the 15 topics in [TOPICS.md](TOPICS.md).
Python 3.11+ unless noted.

---

## 1. Async / concurrency

```python
import asyncio

# Run concurrently, results in input order
results = await asyncio.gather(*(fetch(u) for u in urls))
# gather: one exception cancels NOTHING by default — it propagates the first
# error but sibling tasks keep running. Use return_exceptions=True to collect.

# TaskGroup (3.11+): structured concurrency — on error, cancels all siblings,
# raises ExceptionGroup
async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(fetch(u1))
    t2 = tg.create_task(fetch(u2))
result = t1.result()  # available after the block

# Timeout
async with asyncio.timeout(5):          # 3.11+, cancellation-friendly
    await slow()
await asyncio.wait_for(slow(), timeout=5)  # older; wraps in a Task

# First completed
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
    t.cancel()
await asyncio.gather(*pending, return_exceptions=True)  # await the cancels!
```

**Key facts**
- The event loop runs ONE coroutine at a time; `await` is the only yield point.
  CPU-bound code blocks the whole loop → `await loop.run_in_executor(None, fn)`
  or `asyncio.to_thread(fn)`.
- A coroutine object does nothing until awaited or wrapped in a task.
  `asyncio.create_task()` schedules it immediately.
- `[await f(u) for u in urls]` is SEQUENTIAL — each await finishes before the
  next starts.
- Cancellation: `task.cancel()` only *requests*; `CancelledError` is raised at
  the next `await` inside the task. Always `try/finally` for cleanup. Since 3.8
  `CancelledError` inherits from `BaseException` so `except Exception` doesn't
  swallow it.
- Async generators: `async def gen(): yield x` + `async for`.
  Async context manager: `__aenter__`/`__aexit__` or
  `@contextlib.asynccontextmanager`.
- Sync-to-async bridge: `asyncio.run(main())` — one per program, at the top.

## 2. Threading vs multiprocessing vs asyncio

| | threading | multiprocessing | asyncio |
|---|---|---|---|
| Parallel CPU? | No (GIL)* | **Yes** | No |
| I/O-bound? | Yes | overkill | **Yes (best at scale)** |
| Memory | shared | separate (pickling!) | shared |
| Cost per unit | ~MB stack / thread | process fork/spawn | ~KB per task |
| Race conditions | yes, need locks | fewer (isolated) | only across `await` points |

\* GIL: one thread executes Python bytecode at a time. Released during I/O and
by C extensions (numpy). 3.13+ has an experimental free-threaded build
(`--disable-gil`); 3.12 has per-interpreter GIL (subinterpreters).

```python
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor(max_workers=8) as pool:      # I/O-bound
    results = list(pool.map(fetch_sync, urls))

with ProcessPoolExecutor() as pool:                  # CPU-bound
    results = list(pool.map(crunch, chunks))         # args/results must pickle
```

- Thread safety: `threading.Lock`, `RLock`, `Event`, `queue.Queue` (thread-safe;
  `asyncio.Queue` is NOT thread-safe).
- `x += 1` is not atomic even with the GIL — read-modify-write interleaves.
- Rule of thumb: I/O-bound + many connections → asyncio; I/O-bound + blocking
  libs → threads; CPU-bound → processes.

## 3. Decorators

```python
import functools

def timing(func):                       # plain decorator
    @functools.wraps(func)              # preserves __name__, __doc__, signature
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__}: {time.perf_counter() - start:.3f}s")
    return wrapper

def retry(times=3, exceptions=(Exception,)):   # decorator WITH arguments:
    def decorator(func):                       # factory → decorator → wrapper
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == times - 1:
                        raise
        return wrapper
    return decorator

@retry(times=5, exceptions=(ConnectionError,))
def call(): ...
```

- `@decorator` ≡ `func = decorator(func)`, applied bottom-up when stacked.
- Decorating async functions: the wrapper must be `async def` and `await func(...)`.
- Class decorator: receives the class, returns (usually) the same class mutated.
- Built-ins to name-drop: `@functools.cache` / `@lru_cache(maxsize=...)`,
  `@cached_property`, `@singledispatch`, `@staticmethod`, `@classmethod`,
  `@property`.
- Gotcha: `@lru_cache` on a method keeps instances alive (cache holds `self`).

## 4. Generators & iterators

```python
# Iterator protocol: __iter__ returns self, __next__ raises StopIteration
class Countdown:
    def __init__(self, n): self.n = n
    def __iter__(self): return self
    def __next__(self):
        if self.n <= 0: raise StopIteration
        self.n -= 1
        return self.n + 1

def chunks(seq, size):                  # generator: lazy, one-shot
    for i in range(0, len(seq), size):
        yield seq[i:i + size]

def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)    # delegation + recursion
        else:
            yield item

gen = (x * x for x in data if x > 0)    # genexpr: no memory for full list
```

- Generators are lazy and exhaust: iterate twice → second time is empty.
- `yield from gen` delegates (also forwards `.send()`/`.throw()`).
- `next(gen, default)` avoids `StopIteration`.
- `itertools` greatest hits: `chain`, `islice`, `groupby` (input must be
  sorted by the same key!), `product`, `combinations`, `count`, `batched` (3.12+),
  `zip(*iters, strict=True)` (3.10+).
- Generator-based pipeline: `sum(1 for line in f if line.strip())` — constant
  memory over huge files.
- `return x` inside a generator → `StopIteration(x)`, ends iteration.

## 5. Data model / dunder methods

```python
class Money:
    __slots__ = ("amount", "currency")          # no __dict__: less RAM, no
                                                # dynamic attrs
    def __init__(self, amount, currency):
        self.amount, self.currency = amount, currency
    def __repr__(self):                         # unambiguous, for devs
        return f"Money({self.amount!r}, {self.currency!r})"
    def __str__(self):                          # readable, for users
        return f"{self.amount} {self.currency}"
    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented               # NOT False — lets other side try
        return (self.amount, self.currency) == (other.amount, other.currency)
    def __hash__(self):                         # defining __eq__ sets
        return hash((self.amount, self.currency))  # __hash__ = None unless you do this
    def __add__(self, other): ...
    def __lt__(self, other): ...                # + functools.total_ordering
```

- `__new__` creates the instance (rarely overridden: immutables, singletons);
  `__init__` initializes it.
- `__getattr__`: called only when normal lookup FAILS. `__getattribute__`:
  called for EVERY access (easy infinite recursion — delegate to `super()`).
- Context manager: `__enter__` / `__exit__(exc_type, exc, tb)`; return truthy
  from `__exit__` to suppress the exception.
- `__call__` makes instances callable; `__contains__` powers `in`;
  `__len__` + `__getitem__` give you iteration and slicing for free (old
  protocol).
- Truthiness: `__bool__`, falls back to `__len__`.
- Mutable default argument gotcha: `def f(x, acc=[])` — one shared list,
  evaluated at def time. Use `acc=None` + `acc = acc or []`.

## 6. Typing & static analysis

```python
from typing import Protocol, TypeVar, Generic, Literal, TypedDict, overload
from collections.abc import Iterable, Callable, Sequence

def find(items: Sequence[str], target: str) -> int | None: ...   # 3.10+ unions

T = TypeVar("T")
def first(items: Iterable[T], default: T | None = None) -> T | None: ...

class Repository(Protocol):            # structural typing — no inheritance needed
    def get(self, id: int) -> str: ...
    def save(self, id: int, data: str) -> None: ...

def sync(repo: Repository) -> None: ...   # anything with get/save matches

class Config(TypedDict):
    host: str
    port: int

Mode = Literal["r", "w", "a"]

class Stack(Generic[T]):               # or `class Stack[T]:` in 3.12+
    def push(self, item: T) -> None: ...
    def pop(self) -> T: ...
```

- Prefer `collections.abc` types for parameters (`Iterable`, `Mapping`) and
  concrete types for returns (`list`, `dict`).
- `Protocol` vs ABC: Protocol = duck typing checked statically, no registration;
  ABC = nominal, runtime `isinstance`, can provide default method impls.
- `Any` disables checking (contagious); `object` is the safe "anything" that
  forces you to narrow.
- Narrowing: `isinstance`, `assert x is not None`, `match`, `TypeGuard`.
- `mypy --strict` / `pyright` in CI; `reveal_type(x)` to debug.
- `from __future__ import annotations` → all annotations lazy strings (helps
  forward refs and import cycles).

## 7. OOP advanced

```python
class A: ...
class B(A): ...
class C(A): ...
class D(B, C): ...          # MRO: D → B → C → A → object (C3 linearization)
D.__mro__

class Base:
    def __init_subclass__(cls, **kwargs):       # runs for every subclass —
        super().__init_subclass__(**kwargs)     # 90% of metaclass use cases
        registry[cls.__name__] = cls
```

- `super()` follows the MRO of the *instance's* class, not the parent — that's
  how diamond hierarchies call each method exactly once (cooperative
  multiple inheritance; every `__init__` must call `super().__init__()`).
- Descriptors: object with `__get__`/`__set__` assigned at CLASS level —
  the mechanism behind `property`, methods, `classmethod`, slots.

```python
class Positive:                                  # reusable validation descriptor
    def __set_name__(self, owner, name): self.name = "_" + name
    def __get__(self, obj, objtype=None): return getattr(obj, self.name)
    def __set__(self, obj, value):
        if value <= 0: raise ValueError(f"{self.name} must be > 0")
        setattr(obj, self.name, value)

class Order:
    price = Positive()
    qty = Positive()
```

- Dataclasses:

```python
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)              # immutable + memory-efficient
class Point:
    x: float
    y: float
    tags: list[str] = field(default_factory=list)  # NEVER a mutable default
```

- dataclass = codegen for `__init__`/`__repr__`/`__eq__`, stdlib.
  attrs = same idea, more features (validators).
  pydantic = runtime VALIDATION + parsing/serialization — for data crossing
  boundaries (APIs, config), not for internal structs.

## 8. Context managers & resource handling

```python
import contextlib

@contextlib.contextmanager
def transaction(conn):
    conn.begin()
    try:
        yield conn          # ← body of the with-block runs here
        conn.commit()
    except BaseException:
        conn.rollback()
        raise               # re-raise: don't silently swallow

with contextlib.ExitStack() as stack:            # dynamic number of resources
    files = [stack.enter_context(open(p)) for p in paths]
    # all closed in reverse order, even on error

with contextlib.suppress(FileNotFoundError):     # cleaner than try/except/pass
    os.remove(path)
```

- Exceptions raised in the with-body appear at the `yield` inside a
  `@contextmanager` — hence the try around it.
- `contextlib.closing(obj)` for things with `.close()` but no `__exit__`;
  `redirect_stdout`, `chdir` (3.11+), `nullcontext`.
- Async versions: `@asynccontextmanager`, `AsyncExitStack`, `aclosing`.

## 9. Error handling & robustness

```python
class AppError(Exception): ...                   # domain hierarchy
class NotFound(AppError): ...
class UpstreamError(AppError): ...

try:
    resp = call_upstream()
except ConnectionError as e:
    raise UpstreamError("billing service down") from e   # keeps cause chain
    # `from None` hides the chain; bare `raise` re-raises current

try:
    ...
except (ValueError, KeyError) as e: ...
except Exception:
    logger.exception("unexpected")   # logs traceback automatically
    raise
else:
    ...                              # ran only if no exception
finally:
    ...                              # always (even on return)

# 3.11+: exception groups (e.g. from TaskGroup)
try:
    async with asyncio.TaskGroup() as tg: ...
except* ValueError as eg:            # except* matches INSIDE the group
    for exc in eg.exceptions: ...
```

- Catch the NARROWEST exception you can handle; never bare `except:`
  (catches `KeyboardInterrupt`/`SystemExit`).
- EAFP (`try/except KeyError`) over LBYL (`if k in d:`) — idiomatic and
  race-free.
- `e.add_note("context")` (3.11+) enriches tracebacks.
- Retries: exponential backoff + jitter; only retry idempotent operations.

## 10. Testing (pytest)

```python
import pytest
from unittest.mock import Mock, AsyncMock, patch

@pytest.fixture
def db():
    conn = make_test_db()
    yield conn                     # teardown after the yield
    conn.close()

@pytest.mark.parametrize("raw,expected", [("1", 1), ("-3", -3), ("07", 7)])
def test_parse(raw, expected):
    assert parse(raw) == expected

def test_invalid():
    with pytest.raises(ValueError, match="invalid"):
        parse("abc")

def test_service(monkeypatch):
    monkeypatch.setenv("API_KEY", "test")
    monkeypatch.setattr("myapp.client.fetch", lambda url: {"ok": True})

@patch("myapp.service.EmailClient")   # patch where it's USED, not where defined
def test_signup(MockEmail):
    MockEmail.return_value.send.assert_called_once_with(to="x@y.z")

@pytest.mark.asyncio                  # pytest-asyncio
async def test_fetch():
    client = AsyncMock()
    client.get.return_value = {"ok": True}
    assert await fetch_data(client) == {"ok": True}
```

- Fixture scopes: `function` (default) < `class` < `module` < `session`.
  `conftest.py` shares fixtures without imports.
- `Mock` records calls (`assert_called_once_with`, `call_args_list`);
  `MagicMock` adds dunders; `spec=RealClass` catches typo'd attributes.
- Senior talking points: test behavior not implementation; fast deterministic
  unit tests + a few integration tests (pyramid); dependency injection beats
  patching; coverage % is a floor, not a goal.

## 11. Memory & performance

- CPython memory = reference counting (immediate) + cyclic GC (for reference
  cycles only, generational). `weakref` avoids keeping objects alive (caches,
  observers).
- `sys.getrefcount(x)`, `gc.collect()`, `tracemalloc` for leak hunts.
- `__slots__`: ~half the memory per instance, faster attribute access.
- Interning/caching: small ints (-5..256) and some strings are shared — `is`
  works "by accident"; always compare values with `==` (`is` only for `None`).

```python
python -m timeit -s "data=list(range(1000))" "sum(data)"
python -m cProfile -s cumulative script.py
```

Classic traps:
- String concat in a loop is O(n²) → `"".join(parts)`.
- `x in list` is O(n); `x in set/dict` is O(1). List `insert(0)`/`pop(0)` O(n)
  → `collections.deque`.
- Big intermediate lists → generators.
- `dict`/`set` lookups dominate: `collections.Counter`, `defaultdict` beat
  manual loops.
- Real speedups in order: better algorithm → caching (`lru_cache`) → batch the
  I/O → numpy/vectorize → multiprocessing → Cython/Rust extension.

## 12. Closures & scoping

- LEGB lookup: Local → Enclosing → Global → Builtins.
- Assignment makes a name local for the WHOLE function (→ `UnboundLocalError`);
  escape hatches: `nonlocal` (enclosing), `global`.

```python
def make_counter():
    count = 0
    def inc():
        nonlocal count          # without it: UnboundLocalError
        count += 1
        return count
    return inc

# THE classic gotcha — late binding: closures capture variables, not values
fns = [lambda: i for i in range(3)]
[f() for f in fns]              # [2, 2, 2] !
fns = [lambda i=i: i for i in range(3)]     # default arg captures value → [0, 1, 2]

from functools import partial
log_error = partial(log, level="ERROR")     # partial application
```

- Closure state lives in `fn.__closure__` (cells). Same late-binding bug
  appears with functions defined in loops and with `lambda` callbacks.

## 13. Packaging & project layout

```
myproject/
├── pyproject.toml          # single source of truth (PEP 621)
├── src/mypkg/              # src layout: forces installed-package imports,
│   ├── __init__.py         # prevents "works on my machine" path accidents
│   └── core.py
└── tests/
```

```toml
[project]
name = "mypkg"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27"]

[project.scripts]
mypkg = "mypkg.cli:main"        # entry point → console command

[project.optional-dependencies]
dev = ["pytest", "mypy", "ruff"]
```

- Tooling in 2026: `uv` (fast, lockfile, replaces pip+venv+pip-tools+pipx),
  `poetry` still common, `ruff` = linter+formatter.
- Editable install: `pip install -e ".[dev]"` / `uv sync`.
- Lock apps (reproducible deploys), constrain-not-pin libraries.
- Know the words: wheel vs sdist, virtualenv isolation, `python -m` to run
  modules, `__init__.py` vs namespace packages.

## 14. Logging & observability

```python
import logging

logger = logging.getLogger(__name__)     # per-module logger, NEVER the root
                                         # logger in library code

# In the APP entry point only (libraries never configure handlers):
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(name)s %(levelname)s %(message)s",
)

logger.info("user %s logged in", user_id)   # lazy %-formatting: no f-string
                                            # cost when level is filtered out
logger.exception("payment failed")          # inside except: adds traceback
```

- Hierarchy: `getLogger("a.b")` propagates to `"a"` then root — configure
  handlers once at the top, set levels per subsystem.
- Levels: DEBUG < INFO < WARNING < ERROR < CRITICAL. Both logger AND handler
  filter.
- Structured logging: JSON formatter (or `structlog`) → machine-parseable;
  attach context via `extra={"request_id": rid}` or a `contextvars.ContextVar`
  (async-safe request IDs — `threading.local` breaks under asyncio).
- Senior talking points: correlation IDs across services, log at boundaries,
  never log secrets/PII, metrics+traces+logs = three pillars (OpenTelemetry).

## 15. Metaclasses & import machinery

```python
class Model(type):                       # a metaclass is the type of a class
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        cls._fields = [k for k in ns if not k.startswith("_")]
        return cls

class User(metaclass=Model):
    name: str

type(obj) → its class;  type(cls) → its metaclass (usually `type`)
Widget = type("Widget", (Base,), {"size": 10})   # classes created at runtime
```

- Rule: don't use a metaclass if `__init_subclass__` (registration/validation)
  or a class decorator can do it. Real users: ABCs, enums, ORMs (Django),
  Protocol.
- Imports: modules run TOP-TO-BOTTOM once, then cached in `sys.modules`
  (that's why mutable module state is a hidden singleton).
- Circular imports: break them by importing inside the function, moving shared
  code to a third module, or `if TYPE_CHECKING:` for type-only imports.
- `python -m pkg.mod` runs a module as `__main__` with the right `sys.path`;
  `if __name__ == "__main__":` guards script-only code (also required for
  multiprocessing spawn).

---

## Rapid-fire one-liners (classic screener questions)

- **`is` vs `==`** — identity vs equality; `is` only for `None`/sentinels.
- **Shallow vs deep copy** — `list(x)`/`copy.copy` copies the container,
  `copy.deepcopy` recurses.
- **`*args, **kwargs`** — tuple / dict capture; `f(*seq, **mapping)` unpacks.
  Keyword-only after `*`, positional-only before `/`.
- **GIL in one sentence** — a mutex ensuring one thread runs Python bytecode
  at a time, so threads don't parallelize CPU-bound pure-Python code.
- **Why is `dict` ordered?** — insertion order guaranteed since 3.7.
- **`==` on floats** — `math.isclose(a, b)`; money → `decimal.Decimal`.
- **Sort stability** — Timsort is stable; multi-key: sort by secondary key
  first, or `key=lambda x: (x.a, -x.b)`.
- **`match` statement (3.10+)** — structural pattern matching, destructures
  sequences/mappings/objects; `case _:` is the default.
- **Walrus `:=`** — assign inside an expression:
  `while (chunk := f.read(8192)):`.
- **String immutability** — every "mutation" builds a new string; that's the
  O(n²) concat trap.
