Python for people who run things — the GIL, choosing a concurrency model, streaming instead of loading, and the subprocess and logging patterns that survive production.
Source to bytecode to the eval loop — and the three ways around the one lock in the middle.
The GIL only guards the eval loop. Everything below it — blocking I/O, C extensions, subprocesses — runs with the lock released, which is why threads still help for the work ops code actually does.
The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.
Concurrency, environments, and lazy iteration.
CPython's Global Interpreter Lock means only one thread executes Python bytecode at a time. Two threads doing arithmetic will not use two cores; they will take turns, and the switching overhead can make the threaded version slower than the serial one.
But the GIL is released around blocking calls. Every socket read, file read,
time.sleep() and database round trip drops the lock so other threads run. Threads are
genuinely effective for I/O-bound work — which, for ops tooling, is most work. Polling 300
endpoints with 30 threads is a real 30× speedup.
Well-written C extensions do the same. NumPy releases the GIL for the duration of a large array operation, so numeric code can use multiple cores despite it.
CPython 3.13 shipped an experimental free-threaded build (PEP 703) that removes the GIL entirely. It is opt-in, carries a single-thread performance cost, and much of the C ecosystem is still catching up — worth watching, not yet worth depending on.
| Workload | Right tool | Why |
|---|---|---|
| HTTP calls, DB queries, file I/O | threading or asyncio | GIL is released while blocked |
| Thousands of concurrent connections | asyncio | No thread stack per connection |
| Parsing, crunching, compression in pure Python | multiprocessing | Only way to get real cores |
| Numeric arrays | NumPy / Polars | C code releases the GIL for you |
| Shelling out to other tools | subprocess + threads | The work isn't in Python at all |
Threads share memory, cost ~8 MB of stack each, and are preemptive — you can be interrupted between any two bytecodes, so shared mutable state needs locks. Good to a few hundred.
Processes get their own interpreter and their own GIL, so they use real cores. They cost tens of MB each and communication means pickling across a pipe. Use for CPU-bound work, and beware: passing large objects can cost more than the computation saves.
asyncio runs one thread with an event loop; tasks yield at every
await. Tens of thousands of concurrent connections on one core, and no locks needed
because switches only happen at points you can see. The catch is total: one blocking call
freezes everything. A single synchronous requests.get() inside an async
handler stops the entire loop.
A virtual environment is a directory with its own site-packages and a
python symlink. Activating it puts that bin/ first on
PATH. There is no magic — which is why you can also just call
.venv/bin/python directly and skip activation entirely, and why that is the right
thing to do in a cron job or a systemd unit.
requests>=2.28 resolves to whatever is newest at install time. Two installs a
month apart give two different dependency trees, and a transitive dependency you have never heard
of can break your build on a Tuesday. Pin transitively — pip-compile, Poetry,
uv lock — and commit the lock. In a container, install from the lockfile and never
from a range.
uv is worth knowing about: a Rust-implemented resolver and installer that is
typically 10–100× faster than pip and speaks the same interfaces. For CI, that difference is real
minutes per build.
f.readlines() reads the whole file into a list. On a 40 GB log that is 40 GB of
RSS and an OOM kill. Iterating the file object directly reads a buffer at a time and holds one
line — constant memory regardless of file size.
Generators extend that to your own code. A function with yield produces values on
demand; chain several and you have a streaming pipeline where nothing is ever fully materialised.
This is the single highest-value Python idiom for ops work, where the input is usually bigger than
the box.
Memory behaviour, subprocess, logging, and making it fast.
CPython frees an object the moment its reference count hits zero — deterministic, no pause. A cycle detector runs periodically to catch objects that reference each other and would otherwise never reach zero.
But freeing an object does not return memory to the OS. CPython manages memory in arenas of 1 MB (256 KB in older versions), and an arena is only released when every block in it is free. One long-lived object in an arena pins the whole megabyte. After processing a large batch, RSS stays high even though Python considers the memory free — it will be reused by Python, just not returned.
Operationally: a worker whose RSS grows and plateaus is normal. One that grows without bound is
a leak — usually an unbounded cache, a list that's appended to forever, or a logging handler
holding references. For long-running workers, the pragmatic answer is what gunicorn's
--max-requests does: recycle the process periodically and stop worrying about it.
Four rules cover almost every subprocess bug:
shell=True hands your string to
/bin/sh, and any interpolated value becomes shell syntax. It is command injection in
a script you wrote yourself.run() does not raise by default. Use
check=True, or check .returncode yourself.stdout=PIPE with wait(). If the child
fills the pipe buffer (~64 KB) it blocks writing while you block waiting. Classic deadlock.
run() and communicate() handle this; Popen.wait()
doesn't.Use the logging module, get the logger with logging.getLogger(__name__)
so every message carries its module, and configure handlers once in main() — never at
import time, which breaks anything importing your module.
Use lazy formatting: log.debug("got %s", expensive()) evaluates
the argument only if DEBUG is enabled. An f-string is evaluated always, even when the message is
discarded — in a hot loop that is real cost for output nobody sees.
In containers, log JSON to stdout. Every log pipeline — Loki, ELK, CloudWatch — parses structured lines natively, and one exception spanning twelve lines of traceback becomes one searchable event rather than twelve unrelated ones.
Each bytecode dispatch costs tens of nanoseconds. A Python-level loop over ten million items is seconds; the same work inside a C-implemented builtin is milliseconds. The optimisation is almost always to push the loop down into C.
sum(x), any(), max(), ''.join(),
sorted() — all C loops. Prefer them to hand-written equivalents.append in a loop; the append lookup happens once.set membership is O(1); list membership is O(n). Getting this wrong
inside a loop is the most common accidental O(n²) in ops scripts.functools.lru_cache for pure functions called repeatedly with the same arguments.And profile before any of it. cProfile for call counts,
py-spy for a live process you cannot restart — which, on a production box, is usually
the only option you have.
Most ops Python is a script that reads something, does something, and has to be safe to run from cron at 3am. This skeleton covers the parts that matter when nobody is watching.
It makes the script testable — assert main(['--namespace','x']) == 0
runs the whole thing in-process — and it gives cron, systemd and Kubernetes a real exit code to act
on. log.exception() inside the handler logs the traceback at ERROR without re-raising,
so the failure is recorded rather than printed to a stderr nobody captured.
| Tool / idiom | What it's for |
|---|---|
py-spy top --pid N | Profile a running process without restarting it |
py-spy dump --pid N | Stack trace of every thread — for a hung process |
python -X tracemalloc=5 | Allocation tracking with 5 frames of context |
python -m cProfile -s cumtime s.py | Where the time goes, by cumulative cost |
python -m venv .venv | Isolated environment — no activation needed to use it |
uv pip sync requirements.lock | Fast, exact, reproducible install |
pip-compile requirements.in | Turn ranges into a transitively pinned lockfile |
subprocess.run([...], check=True, timeout=N) | The only correct default form |
logging.getLogger(__name__) | Per-module logger, configurable from one place |
log.debug('x=%s', v) | Lazy formatting — not evaluated if DEBUG is off |
functools.lru_cache(maxsize=N) | Memoise a pure function, bounded |
concurrent.futures.ThreadPoolExecutor | I/O parallelism without touching threads directly |
concurrent.futures.ProcessPoolExecutor | CPU parallelism across real cores |
yield from f | Stream a file instead of loading it |
pathlib.Path | Path handling that doesn't break on separators |
ruff check . && ruff format . | Lint and format, fast enough for a pre-commit hook |
mypy --strict | Catch the type errors that only show up in the 3am code path |