# Python

Applies when: Python 3.11+ is doing real work — data pipelines, model serving, batch jobs,
internal AI services — and the code has outgrown the notebook it was born in. Python's failure
mode is not that it is hard to write. It is that it is easy to keep writing, past the point
where anyone can reason about what runs when.

## Service layer & structure

### "It's just a script" is a prediction, not a description

Every unmaintainable Python service was once a script that worked. The transition is not marked
by a rewrite; it is marked by the third `if __name__ == "__main__"` branch and a `--mode` flag.
When one file holds both an argument parser and business logic, split it.

```
src/pkg/
  domain/     # pure functions and dataclasses. Imports nothing below it.
  adapters/   # DB, S3, HTTP, model registry — everything slow or failure-prone.
  services/   # orchestration: calls adapters, hands data to domain.
  cli.py      # argument parsing and nothing else.
```

The rule that makes it work: **`domain/` may not import `adapters/`.** Enforce it in CI with
`import-linter` or a grep in a test. An architectural rule nobody checks is a comment.

### Separate I/O from computation, because that is what makes it testable

Untested Python stays untested because every interesting function also fetches something.

```python
def score_customers(day):                          # wrong: fetch, compute, write, all fused
    df = pd.read_sql("select * from customers", engine)
    df["score"] = model.predict(feature_store.fetch(df.id.tolist()))
    df.to_sql("scores", engine, if_exists="append")

def score_frame(cust: pd.DataFrame, f: pd.DataFrame, m: Scorer) -> pd.DataFrame:
    return cust.assign(score=m.predict(f))         # right: pure core, no network

def run_scoring(day: date, repo: CustomerRepo, store: FeatureStore, m: Scorer) -> int:
    cust = repo.due_for_scoring(day)               # thin imperative shell
    return repo.save_scores(score_frame(cust, store.fetch(cust["id"].tolist()), m))
```

`score_frame` gets a real unit test with three rows and a fake model. `run_scoring` gets one
integration test. That ratio is the point.

### Dependency injection without a framework

Python does not need a DI container. It needs you to stop reaching for module-level globals.

```python
engine = create_engine(os.environ["DB_URL"])  # wrong: welded in, connects at import

class FeatureStore(Protocol):                 # right: declare the seam, then inject it
    def fetch(self, ids: Sequence[int]) -> pd.DataFrame: ...

@dataclass(frozen=True, slots=True)
class ScoringService:
    repo: CustomerRepo
    store: FeatureStore
```

Wire it once in `main()`. `Protocol` is structural — your test fake satisfies it without
inheriting anything, and the type checker still verifies it. **Import-time side effects are the
real bug:** anything that opens a connection, reads an env var, or loads a 2 GB model at module
scope makes your tests slow, your `--help` slow, and your import order load-bearing. Do that
work inside a function.

### Footguns that survive code review

**Mutable default arguments** are evaluated once at definition and shared by every call forever.
`def add(x, acc=[])` accumulates across calls; use `acc=None` and `acc = [] if acc is None else
acc`. The dataclass equivalent is `field(default_factory=list)` — a bare `= []` on a dataclass
field raises, which is one place Python protects you.

- **Late-binding closures.** `[lambda: i for i in range(3)]` returns three functions that all
  yield `2`. Bind explicitly: `lambda i=i: i`.
- **Bare `except:`** swallows `KeyboardInterrupt` and `SystemExit`; you meant `except
  Exception:`, and probably narrower. Mutating a list while iterating it silently skips elements.
- **`datetime.utcnow()`** is naive and deprecated in 3.12+; use `datetime.now(timezone.utc)`. A
  naive datetime crossing a process boundary is a corruption bug waiting for a DST change.
- **`==` on floats.** Use `math.isclose`/`np.isclose` with a tolerance you can justify.

## Security

### Pickle is remote code execution with a friendly API

`pickle.loads` on untrusted bytes is arbitrary code execution — not a hardening gap, not a
sandbox escape, just execution. The format calls `__reduce__` on unpickling by design. It bites
hardest in data work because pickle hides inside things that do not look like pickle:
`joblib.load` on an artifact from object storage, `np.load(allow_pickle=True)`, `pd.read_pickle`,
`torch.load` (pass `weights_only=True` — the default since torch 2.6, but be explicit), and any
cache or Celery backend configured with a pickle serializer. Prefer formats that cannot execute:
`safetensors` for weights, Parquet or Arrow for frames, JSON for config. If a pickle must cross a
trust boundary, sign it and verify the signature *before* it reaches the unpickler.

### The rest, briefly and without excuses

```python
subprocess.run(f"convert {path} out.png", shell=True)  # wrong: path owns your shell
subprocess.run(["convert", path, "out.png"])           # right: no shell, no injection

pd.read_sql(f"select * from users where org = '{org}'", conn)               # wrong
pd.read_sql("select * from users where org = %(org)s", conn, {"org": org})  # right

yaml.load(fh)       # wrong (and needs an explicit Loader= since PyYAML 5.1)
yaml.safe_load(fh)  # right
```

Identifiers cannot be parameterised — validate table and column names against an allowlist,
never interpolate user input into them.

**Path traversal.** `os.path.join("/data", user_input)` returns `user_input` outright when it is
absolute, and `..` walks up regardless.

```python
root, target = Path("/data").resolve(), (Path("/data") / user_input).resolve()
if not target.is_relative_to(root):
    raise ValueError("path escapes root")
```

**Dependencies.** A floating `requirements.txt` means the build is not reproducible and a
compromised release lands in production on the next deploy. Pin a full transitive lockfile with
hashes (`uv lock`, `pip-compile --generate-hashes`, Poetry), commit it, install with
`--require-hashes`, and keep dependency bumps as their own reviewable commits.

**Secrets.** Read from the environment or a secret manager at call time, never at import, and
never with a hardcoded fallback: `os.environ["API_KEY"]` failing loudly beats
`os.environ.get("API_KEY", "sk-dev-…")` shipping. In notebooks the output cells are part of the
file — a printed dataframe or a traceback carrying a connection string is committed to git the
same as source. Strip outputs in a pre-commit hook.

## Comments, typing & documentation

### Type hints are executable documentation

A docstring saying "returns a dataframe of scores" rots. `-> pd.DataFrame` is checked. Type the
boundaries — public functions, service constructors, anything crossing a module — and run `mypy`
or `pyright` in CI. Untypeable internals can stay untyped; a checker nobody can make pass gets
switched off, and then you have neither. Specificity is where the value is: `dict` says nothing,
a `TypedDict` or frozen dataclass gives you the shape and fails on a typo, and in numeric code
`npt.NDArray[np.float64]` catches the dtype confusion that otherwise surfaces as silent
precision loss. Use `from __future__ import annotations` so they cost nothing at import.

### Docstrings earn their keep on what types cannot express

Do not retype the signature in prose — that is two things to keep in sync and no new information.
Carry units, ranges, side effects, invariants, what it raises, what it costs. Pick one convention
(Google, NumPy, reST) and hold it so tooling can parse it.

```python
def retry_embedding(text: str, max_attempts: int = 5) -> list[float]:
    """Embed `text`, retrying on transient provider errors.

    Retries only on 429 and 5xx; a 400 is bad input and raises immediately.
    Backoff is exponential with full jitter. Not idempotent from the provider's
    billing perspective — every attempt is a charged call.

    Raises: EmbeddingError — all attempts exhausted.
    """
```

### Document why the constant is that number

The highest-value comment in a data codebase, and almost always the missing one. A magic number
with no provenance cannot be changed by anyone, because nobody knows what breaks.

```python
TOLERANCE, MAX_ATTEMPTS = 1e-6, 5   # wrong: unfalsifiable

# float32 accumulation over ~1e5 rows drifts past 1e-7; 1e-6 clears the observed
# drift with margin. Tighten only if this pipeline moves to float64.
TOLERANCE = 1e-6

# Provider rate-limit window is 60s; 5 attempts of full-jitter exponential backoff
# span roughly that window. More attempts just queue behind the limit.
MAX_ATTEMPTS = 5
```

The test of such a comment: does it tell the next reader what evidence would make the value
wrong? If not, it was not worth writing.

## Performance & correctness

### Measure before you optimise

The rule above all others applies hardest here, because Python performance intuition is unusually
bad — the bottleneck is rarely the code being reviewed for it. Use `cProfile` for a first cut,
`py-spy top --pid` for a live process including production (no code changes, samples a running
interpreter), `line_profiler` once you know the function and need the line, and `memray` or
`tracemalloc` for memory, which is more often the real problem. Keep the number: without a
baseline you cannot prove the work paid for itself.

### Where Python is genuinely slow, and where it is a myth

- **Genuinely slow:** the interpreter loop. Per-element work in bytecode — `df.iterrows()`,
  `df.apply(axis=1)`, a nested loop over a million-element list — pays interpreter overhead per
  element, a real one-to-two-orders-of-magnitude cost. Attribute lookup and calls in hot loops
  are not free either.
- **Myth:** that numeric work is slow. numpy, pandas, polars, torch and scikit-learn execute in
  C, Rust or BLAS; the Python is a control plane. A vectorised expression over ten million rows
  can beat a `for` loop over ten thousand.
- **Usually the actual answer:** it is not CPU at all — it is an unbatched call per row to a
  database or an embedding API, a dataframe read with default dtypes eating memory until the box
  swaps, or a serialisation step nobody counted.

### Generators and memory

Memory is where data services die, usually as an OOM kill with no traceback. `[transform(r) for r
in read_all()]` materialises everything; `(transform(r) for r in read_all())` streams in constant
memory. Use `yield`, `itertools.islice`/`chain`, and chunked reads (`pd.read_sql(chunksize=...)`).
Note the trade: a generator is single-pass and cannot be `len()`'d — if the consumer needs two
passes, materialise deliberately rather than by accident. For dataframes the cheap wins are
`dtype=` on read, `category` for low-cardinality strings, and reading only the columns you need.

### The GIL, accurately

CPython's global interpreter lock means **one thread executes Python bytecode at a time** within
an interpreter. That is the entire claim. What follows:

- **Threads do not speed up pure-Python CPU work.** Four threads on a CPU-bound loop is one
  core's throughput plus contention.
- **Threads do help I/O.** The GIL is released around blocking I/O — sockets, disk, sleep — so an
  HTTP-fanout or database-bound service scales fine on threads or `asyncio`.
- **Extensions release it.** numpy, BLAS and torch drop the GIL for the duration of a heavy call,
  so parallelism inside those libraries is real.
- **`multiprocessing` sidesteps it** at the cost of process memory and pickling everything
  crossing the boundary — frequently more expensive than the work itself.
- Python 3.12 gave subinterpreters their own GIL (PEP 684); 3.13 shipped an experimental
  free-threaded build and 3.14 made it officially supported, still not the default. Free
  threading is a real answer, not yet the default one — verify your C extensions support it.

Diagnose before choosing: CPU-bound wants `multiprocessing` or a vectorised rewrite, I/O-bound
wants threads or `asyncio`. Choosing wrong adds complexity and no speed.

### When to reach for another language

In this order, and do not skip steps:

1. **Fix the algorithm.** Most "Python is slow" is O(n²) with a dictionary missing.
2. **Vectorise.** Push the loop into numpy or polars, where it runs as C or Rust.
3. **Batch the I/O.** One query for a thousand ids beats a thousand queries.
4. **Cache** what is expensive to compute and safe to be stale.
5. **Then** Cython, `numba`, or a Rust extension via PyO3 — for a hot kernel, not a service.

Rewriting a service in another language to fix a bottleneck you never profiled turns a
performance problem into a rewrite problem. The first four steps are cheaper, reversible, and
usually sufficient.

---
MIT licensed. Written by Smit Desai — <https://laravel.org.in>
