Production Python for AI teams
Tooling, project structure, CI, and the habits that separate a demo from a system people depend on.
Why this matters in AI / ML / GenAI
The gap between a notebook that works and a service on call is process: formatting, linting, typing, tests, pinned dependencies, and reproducible runs. Interviewers probe this because it is what actually breaks in production.
The toolchain
Four tools cover most of it:
| Tool | Purpose |
|---|---|
| ruff | Lint and format, extremely fast, replaces flake8/isort/black |
| mypy | Static type checking against your hints |
| pytest | Tests, fixtures, parametrization |
| uv or poetry | Dependency resolution and lockfiles |
Add pre-commit so these run before code is committed rather than after review. Configure them in pyproject.toml so the editor, CLI, and CI all read one source of truth. Arguments about formatting disappear when a tool decides.
Reproducibility
An ML result you cannot reproduce is an anecdote. Five things to control:
- Pin dependencies with a committed lockfile.
- Seed every random source: Python, NumPy, and the framework.
- Version data, not just code — DVC, or at minimum a dataset hash recorded with the run.
- Log the config for every run: hyperparameters, data version, git commit.
- Track experiments with MLflow or Weights & Biases so results are comparable months later.
For LLM systems add: prompt version, model version, and temperature. "The model got worse" is unanswerable unless you know which prompt and which model version produced last week's output.
Performance, when it matters
Profile before optimizing. cProfile for a whole script, time.perf_counter for a block. Optimizing the wrong function is the most common wasted week in ML engineering.
Usual wins in order: vectorize instead of looping, batch API and GPU calls, cache repeated work, then reach for concurrency. Rewriting in a faster language is almost never the first answer.
Memory: stream with generators rather than building giant lists, use float32 instead of float64, and delete large intermediates. Out-of-memory kills in a container are usually a data-loading pattern, not the model.
Habits that compound
- Write the function signature and its test before the body.
- Small pull requests. A 2000-line PR gets a rubber stamp, not a review.
- Log the event and the context, never a bare "error occurred".
- Delete dead code — the repository is not an archive; git already is one.
- Write the README as you build: what it does, how to run it, how to test it.
- Read the source of the libraries you depend on. It is ordinary Python, and the answer is usually there.
Copy-paste examples
Copy into your own editor, or load one into the compiler below and press Run.
pyproject.toml for tooling
One config file that ruff, mypy, and pytest all read.
# pyproject.toml
[project]
name = "ai-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.115.5",
"uvicorn==0.32.1",
"pydantic==2.10.3",
"numpy==2.1.3",
"pandas==2.2.3",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]
[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
disallow_untyped_defs = true
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"GitHub Actions CI
Lint, type-check, and test on every push. Cheap insurance.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: ruff check .
- run: ruff format --check .
- run: mypy src
- run: pytest --cov=src --cov-report=term-missingSeed everything for reproducibility
Runs here with NumPy. Add the torch lines in a real training script.
import os
import random
import numpy as np
def set_seed(seed: int = 42) -> None:
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
# torch.manual_seed(seed)
# torch.cuda.manual_seed_all(seed)
# torch.backends.cudnn.deterministic = True
set_seed(42)
first = [random.random(), float(np.random.rand())]
set_seed(42)
second = [random.random(), float(np.random.rand())]
print("run 1:", [round(v, 6) for v in first])
print("run 2:", [round(v, 6) for v in second])
print("reproducible:", first == second)Profile before you optimize
Measure first. The slow part is rarely where you assume.
import time
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally:
print(f"{label:14s} {(time.perf_counter() - start) * 1000:7.2f} ms")
data = [f"document number {i}" for i in range(50_000)]
with timer("naive concat"):
joined = ""
for d in data[:5000]:
joined += d
with timer("str.join"):
joined = "".join(data[:5000])
with timer("list comp"):
lengths = [len(d) for d in data]
with timer("sum builtin"):
total = sum(len(d) for d in data)
print("total chars:", total)Run manifest for every experiment
Log config, data version, and git commit — future you needs this.
import json
import hashlib
from datetime import datetime, timezone
def data_fingerprint(rows):
payload = json.dumps(rows, sort_keys=True).encode()
return hashlib.sha256(payload).hexdigest()[:12]
rows = [{"text": "sample a", "label": 1}, {"text": "sample b", "label": 0}]
manifest = {
"run_id": "exp-2026-09-03-01",
"timestamp": datetime.now(timezone.utc).isoformat(),
"git_commit": "a1b2c3d",
"config": {"model": "bert-base", "epochs": 3, "lr": 2e-5, "seed": 42},
"data": {"n_rows": len(rows), "fingerprint": data_fingerprint(rows)},
"metrics": {"accuracy": 0.913, "f1": 0.897},
}
print(json.dumps(manifest, indent=2))Score your own project
Try it — in-browser Python
Set your real answers to True or False and see where the gaps are.
Output
Python runs in your browser. First run downloads the runtime.
Press Run (or Ctrl+Enter) to execute.
CPython in WebAssembly. Stdlib works. NumPy and pandas load on demand. No input(), no GPU, no network installs.
Takeaways
- ruff, mypy, pytest, and a lockfile in pre-commit and CI cover most quality problems.
- Reproducibility needs pinned deps, seeds, versioned data, and a logged config per run.
- Profile before optimizing; vectorize and batch before reaching for concurrency.