Foundation · Core

Python

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.

25 min read Level: core → advanced Foundation 07 / 10
The model

CPYTHON, END TO END

Source to bytecode to the eval loop — and the three ways around the one lock in the middle.

SOURCEyour.py__pycache__/*.pycCOMPILEASTBytecodeRUNTIMEEval loop (ceval)The GILReference countingCycle GCCONCURRENCYthreading — I/O onlymultiprocessing — real coresasyncio — one threadESCAPE HATCHESC extensions release GILnumpy / lxmlsubprocessENVIRONMENTvenvpip / uvlockfilewheels

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.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

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.

Low levelWhat is inside CPython while your function runs?
CPYTHON RUNTIMEyour moduleC extension callssyscallsGIL released onI/OCOMPILE, ONCEsource → ASTAST → bytecodecached in __pycache__code objectco_consts, co_namesEXECUTE, ALWAYSeval loop (ceval)one bytecode at a timethe GILone thread executes bytecodeframe stackMEMORYreference countingfrees most objectscycle GC, generationalfor the restobject allocatorpymalloc arenas
The GIL protects the interpreter's own state, not yours. It is released around blocking I/O and inside well-behaved C extensions — which is the entire basis for choosing between threads, processes and asyncio.
ConnectionWhere does the GIL actually bite?
request arrivesthread wakesacquires GILparse / branchGIL held — serialiseddb callGIL RELEASEDother threads runreal concurrency hereresponse builtGIL held againbytes writtenGIL released
Threads give you real concurrency on the green hops and none at all on the red ones. If your workload is mostly red, threads will not help and multiprocessing will.
Core

CORE CONCEPTS

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.

WorkloadRight toolWhy
HTTP calls, DB queries, file I/Othreading or asyncioGIL is released while blocked
Thousands of concurrent connectionsasyncioNo thread stack per connection
Parsing, crunching, compression in pure PythonmultiprocessingOnly way to get real cores
Numeric arraysNumPy / PolarsC code releases the GIL for you
Shelling out to other toolssubprocess + threadsThe 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.

the mistake that makes async slower than sync
$ # WRONG — requests is synchronous; the whole loop stops here
$ async def fetch(url):
$ return requests.get(url).text
$ # Right — an async client all the way down
$ async def fetch(client, url):
$ r = await client.get(url)
$ return r.text
$ # Or, when the library has no async version, push it off the loop:
$ loop = asyncio.get_running_loop()
$ text = await loop.run_in_executor(None, lambda: requests.get(url).text)

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.

requirements.txt is not a lockfile

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.

reproducible, and fast
$ python -m venv .venv && .venv/bin/pip install -r requirements.lock
or, with uv:
$ uv venv && uv pip sync requirements.lock
In a Dockerfile — no activation, no ambiguity about which python:
$ RUN python -m venv /opt/venv
$ ENV PATH=/opt/venv/bin:$PATH
$ COPY requirements.lock .
$ RUN pip install --no-cache-dir -r requirements.lock
requirements.lock copied before the source = the layer caches

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.

streaming, not loading
$ # 40 GB in memory. Killed.
$ lines = open('app.log').readlines()
$ errors = [l for l in lines if 'ERROR' in l]
$ # Constant memory, any file size
$ def read_lines(path):
$ with open(path) as f:
$ yield from f
$ def only(lines, needle):
$ for l in lines:
$ if needle in l:
$ yield l
$ for line in only(read_lines('app.log'), 'ERROR'):
$ handle(line)
Nothing is materialised. One line is in memory at a time.
Advanced

ADVANCED

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.

finding a leak
$ python -X tracemalloc=5 app.py
$ import tracemalloc; tracemalloc.start()
$ snap1 = tracemalloc.take_snapshot()
$ ...do the work...
$ snap2 = tracemalloc.take_snapshot()
$ for s in snap2.compare_to(snap1, 'lineno')[:10]: print(s)
app/cache.py:42: size=812 MiB (+812 MiB), count=2104881 (+2104881)
An unbounded dict in cache.py. functools.lru_cache(maxsize=N) exists
precisely so you don't hand-roll this.

Four rules cover almost every subprocess bug:

  • Pass a list, never a string. 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.
  • Always set a timeout. Without one, a hung child hangs your script forever — and in a CI job or a cron, forever means until someone notices.
  • Check the return code. run() does not raise by default. Use check=True, or check .returncode yourself.
  • Don't use 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.
the safe form
$ # WRONG — injection, no timeout, no error check
$ os.system(f'kubectl delete pod {name}')
$ # Right
$ import subprocess
$ try:
$ r = subprocess.run(
$ ['kubectl', 'delete', 'pod', name],
$ capture_output=True, text=True, timeout=30, check=True)
$ except subprocess.TimeoutExpired:
$ log.error('kubectl timed out after 30s')
$ except subprocess.CalledProcessError as e:
$ log.error('kubectl failed rc=%s: %s', e.returncode, e.stderr.strip())
name is one argv element. It cannot become shell syntax.

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.

a logging setup worth copying
$ import logging, sys, json
$ class JsonFormatter(logging.Formatter):
$ def format(self, r):
$ d = {'ts': self.formatTime(r), 'level': r.levelname,
$ 'logger': r.name, 'msg': r.getMessage()}
$ if r.exc_info: d['exc'] = self.formatException(r.exc_info)
$ return json.dumps(d)
$ def setup(level='INFO'):
$ h = logging.StreamHandler(sys.stdout)
$ h.setFormatter(JsonFormatter())
$ logging.basicConfig(level=level, handlers=[h], force=True)
$ log = logging.getLogger(__name__)
$ log.info('processed %d records in %.2fs', n, elapsed)

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.
  • A comprehension beats 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.
  • NumPy/Polars for numeric work — one operation over a million elements, in C, GIL released.
  • 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.

profiling without touching the process
$ pip install py-spy
$ py-spy top --pid 4412
Total Samples 4100
%Own %Total OwnTime TotalTime Function (filename:line)
68.00% 71.00% 28.4s 29.7s _match (app/rules.py:88)
9.00% 92.00% 3.8s 38.4s process (app/worker.py:41)
68% in one function. No restart, no code change, no instrumentation.
$ py-spy record -o profile.svg --pid 4412 --duration 30
flame graph of a live production process
In practice

AN OPS SCRIPT SKELETON

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.

an ops script that behaves
$ #!/usr/bin/env python3
$ """Reconcile pod counts against the expected inventory."""
$ import argparse, logging, sys, signal
$ log = logging.getLogger('reconcile')
$ def parse_args(argv=None):
$ p = argparse.ArgumentParser(description=__doc__)
$ p.add_argument('--namespace', required=True)
$ p.add_argument('--dry-run', action='store_true')
$ p.add_argument('--log-level', default='INFO')
$ return p.parse_args(argv)
$ def main(argv=None) -> int:
$ args = parse_args(argv)
$ setup_logging(args.log_level)
$ signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
exit 143 = 128+15, the same code the shell reports for SIGTERM
$ try:
$ n = reconcile(args.namespace, dry_run=args.dry_run)
$ except Exception:
$ log.exception('reconcile failed')
$ return 1
$ log.info('reconciled %d pods', n)
$ return 0
$ if __name__ == '__main__':
$ sys.exit(main())
Return an int from main, and sys.exit it

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.

Reference

CHEATSHEET

Tool / idiomWhat it's for
py-spy top --pid NProfile a running process without restarting it
py-spy dump --pid NStack trace of every thread — for a hung process
python -X tracemalloc=5Allocation tracking with 5 frames of context
python -m cProfile -s cumtime s.pyWhere the time goes, by cumulative cost
python -m venv .venvIsolated environment — no activation needed to use it
uv pip sync requirements.lockFast, exact, reproducible install
pip-compile requirements.inTurn 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.ThreadPoolExecutorI/O parallelism without touching threads directly
concurrent.futures.ProcessPoolExecutorCPU parallelism across real cores
yield from fStream a file instead of loading it
pathlib.PathPath handling that doesn't break on separators
ruff check . && ruff format .Lint and format, fast enough for a pre-commit hook
mypy --strictCatch the type errors that only show up in the 3am code path