Runtime#

Python is more than a language; it’s a stack pick when they run their code. An implementation (CPython, PyPy, GraalPy, MicroPython), a memory model (reference counting plus a cyclic garbage collector), a bytecode the source compiles to, and an import system that loads, caches, and resolves modules at startup and on demand.

This page is the day-to-day reference for what happens between the operator’s source and the machine. For language semantics see Syntax and Types.

Memory model#

CPython is reference-counted. Every assignment, function call, and container insertion increments a counter on the target object; every binding’s death decrements it. When the counter hits zero the object is freed immediately. A separate cyclic garbage collector (gc module) sweeps reference cycles periodically; otherwise refcounting alone misses them.

import sys, gc
sys.getrefcount(x)        # how many references hold x (+1 for the call)
gc.collect()              # force a cycle sweep
gc.get_count()            # gen0 / gen1 / gen2 counters
gc.disable(); gc.enable() # control the cycle collector

The operator’s mental model. Objects with names live until the last name is rebound or goes out of scope. Cycles (a.ref = b; b.ref = a) live until the cycle collector runs. __del__ is unreliable for cleanup; use __enter__ / __exit__ or contextlib instead.

Object identity vs equality.

a = [1, 2]; b = a
a is b                       # True; same object
a == b                       # True; equal contents
a is [1, 2]                  # False; different object
id(a)                        # CPython: the object's address

Integers up to 256 and short strings are interned (one shared object), so a is b may be True for small ints. Operator never relies on this.

Implementations#

Python is a spec; “Python” usually means CPython, but the operator should recognise the alternatives.

Runtime

Strategy

Notes

CPython

bytecode interpreter

The reference implementation. What every distro ships. Refcount + cycle GC.

PyPy

tracing JIT

3-10× faster on long-running pure-Python loops. Slower startup; some C-extension friction.

GraalPy

GraalVM

Interoperates with JVM languages; cross-language embedding.

MicroPython

bytecode interpreter for microcontrollers

Subset of the language. ESP32, RP2040, embedded.

CircuitPython

fork of MicroPython

Adafruit’s variant; same target class with extra hardware libraries.

CPython interpreter flags worth knowing: -O strips assert; -OO also strips docstrings; -X dev enables development-mode checks; -X faulthandler dumps a traceback on SIGSEGV.

Bytecode and dis#

Python source compiles to bytecode (.pyc files under __pycache__/). The dis module shows what the interpreter will execute.

>>> import dis
>>> dis.dis(lambda x: x + 1)
  1           RESUME                   0
              LOAD_FAST                0 (x)
              LOAD_CONST               1 (1)
              BINARY_OP                0 (+)
              RETURN_VALUE

.pyc files are cached in __pycache__/<name>.<tag>.pyc keyed by interpreter version and source mtime. Stale cache plus clock skew is the classic “I edited the file but the change didn’t take” trap. python -B disables writing them; PYTHONDONTWRITEBYTECODE=1 does the same.

Import system#

import evaluates a module top-to-bottom on first import and caches the result in sys.modules. Subsequent imports return the cached module without re-executing it. Circular imports surface as ImportError or AttributeError on a half- initialised module.

import sys
sys.path                    # where the interpreter searches
sys.modules.keys()          # what is already loaded

import importlib
importlib.reload(my_module) # re-evaluate (REPL only; avoid in prod)
importlib.import_module("plugins.osint_collector")  # by string

# find a module's source location
import mypkg
mypkg.__file__              # filesystem path
mypkg.__path__              # package search path

Search-path order, top to bottom: the directory of the script; PYTHONPATH; the standard library; the installed site-packages. The active virtualenv shadows the system site-packages; sys.executable says which Python is running.

For the package manager that installs into site-packages, see Tooling.

Modules and packages#

Every Python file is a module. A directory of modules is a package. The operator organises a project as a tree of packages plus a few top-level scripts; the import system above walks sys.path to find them and caches each one in sys.modules.

Module#

A module is a single .py file. Its top-level definitions become attributes on the module object after import.

# mypkg/util.py
import re

_EMAIL = re.compile(r"^[\w.-]+@[\w.-]+$")

def is_email(s: str) -> bool:
    return bool(_EMAIL.match(s))

def slugify(s: str) -> str:
    return re.sub(r"[^\w]+", "-", s.lower()).strip("-")

After import mypkg.util as U, the operator reaches U.slugify(...) and U.is_email(...). Names starting with _ are conventional “private” but not enforced; from X import * skips them unless the module declares __all__.

Package#

A package is a directory of modules. Two flavours.

  • Regular package, directory with an __init__.py (which can be empty). The init runs when the package is first imported.

  • Namespace package (PEP 420), directory without __init__.py. Multiple distributions can contribute sub-modules to the same namespace.

Standard layout for an operator’s CLI tool.

myproject/
├── pyproject.toml                 # build metadata + deps (PEP 621)
├── README.md
├── src/
│   └── mypkg/
│       ├── __init__.py            # package init (often empty)
│       ├── __main__.py            # `python -m mypkg` entrypoint
│       ├── cli.py
│       ├── collectors/
│       │   ├── __init__.py
│       │   ├── osint.py
│       │   └── shodan.py
│       └── util.py
└── tests/
    └── test_cli.py

The src/ layout keeps the package out of the project root so tests can’t accidentally import from the working directory and bypass the installed copy.

Import forms#

import mypkg                          # bind name `mypkg`
import mypkg.util                     # bind `mypkg`, populate .util
import mypkg.util as U                # alias
from mypkg.util import slugify        # name binds directly
from mypkg.util import slugify as s   # alias single name
from . import sibling                 # relative import (inside a package)
from ..parent_pkg import thing        # relative, walk up

Style. Top of file, grouped: stdlib, third-party, local; each group alphabetised, blank line between groups. ruff / isort enforce this.

Entry points#

Two patterns for “what runs when type python ...”.

The __name__ == "__main__" guard distinguishes import from direct execution.

# mypkg/cli.py
def main():
    ...

if __name__ == "__main__":
    main()

python -m mypkg runs mypkg/__main__.py which usually delegates to a cli.main function. The preferred way to ship a CLI in a package; works in any environment where the package is importable.

Console scripts declared in pyproject.toml install as named binaries.

[project.scripts]
myop = "mypkg.cli:main"

After pip install -e . (or uv sync), myop is on PATH.

Circular imports#

The most common module bug. A imports B, B imports A; whichever runs second sees a half-initialised module and fails with ImportError or AttributeError.

Three working fixes, in order of preference.

  1. Extract the shared names into a third module both import from. The cycle goes away.

  2. Defer the import inside the function that needs it. The top-level import becomes a call-time import.

    def encode(payload):
        from .crypto import sign        # inside function, not at top
        return sign(payload)
    
  3. Type-only imports for annotation-only uses. The TYPE_CHECKING constant is False at runtime, so the import only fires for the type checker.

    from typing import TYPE_CHECKING
    if TYPE_CHECKING:
        from .other import OtherType
    
    def f(x: "OtherType") -> None:
        ...
    

Where packages land#

  • Standard library, inside the interpreter, no install step.

  • Third-party with venv, <venv>/lib/python3.X/site-packages/.

  • Third-party without venv, system site-packages (root install) or user site-packages (pip install --user, ~/.local/lib/...).

$ python -c "import sys; print(sys.prefix, sys.exec_prefix)"
$ python -m site

For the install commands and resolver (pip, uv, poetry), see Tooling.

GIL and free-threaded Python#

CPython traditionally runs one Python bytecode at a time per interpreter, regardless of how many OS threads exist. The Global Interpreter Lock (GIL) is the mutex that enforces this. Pure-Python CPU-bound work doesn’t scale across threads; I/O-bound work does (the GIL releases around blocking I/O).

Python 3.13 ships an optional free-threaded build (PEP 703) that removes the GIL. Faster scaling for CPU-bound threaded work; some C-extension friction; not the default in 3.13. Check sys._is_gil_enabled() on 3.13+.

The operator’s working rules.

  • I/O-bound across cores → threads or asyncio (GIL or not, fine).

  • CPU-bound across cores → multiprocessing, free-threaded Python, or drop the hot loop into C / Rust / Cython / NumPy.

See Concurrency for the thread / process / asyncio deep dive.

References#

  • Syntax for the source the interpreter parses.

  • Types for the object model the runtime manages.

  • Tooling for the toolchain on top of the runtime (pip, uv, ruff, mypy, pytest).

  • Concurrency for threads, processes, asyncio, and the GIL.

  • Libraries for sys, gc, dis, tracemalloc, importlib.

  • PEP 328, relative imports.

  • PEP 420, namespace packages.

  • PEP 621, project metadata in pyproject.toml.

  • PEP 703, making the GIL optional.