All posts

Inside CPython: everything is an object, from numbers to modules, and what import really does

What an object looks like in memory, how types and functions are objects too, then import step by step: sys.modules, finders, loaders, packages, .pyc files, up to the moment uvicorn loads a FastAPI app.

How Python runs concurrent code stopped at the event loop and the kernel. This post goes inside the CPython interpreter itself, around a sentence you hear a lot: in Python, everything is an object. Numbers are objects, functions are objects, classes are objects, and so are modules and packages. Once that sentence really sinks in, the import system, often treated as magic, turns out to be quite simple.

Every snippet in this post was run on CPython 3.12.

Everything is an object

import sys
import types

import json


def greet(name):
    return f"Hello {name}"


class User:
    pass


for thing in (42, "hi", None, greet, User, User(), int, type, json, sys):
    print(f"{type(thing).__name__:<9} isinstance(..., object) = {isinstance(thing, object)}")
print("type(type) is type:", type(type) is type)
print("type(json) is types.ModuleType:", type(json) is types.ModuleType)
int       isinstance(..., object) = True
str       isinstance(..., object) = True
NoneType  isinstance(..., object) = True
function  isinstance(..., object) = True
type      isinstance(..., object) = True
User      isinstance(..., object) = True
type      isinstance(..., object) = True
type      isinstance(..., object) = True
module    isinstance(..., object) = True
module    isinstance(..., object) = True
type(type) is type: True
type(json) is types.ModuleType: True

The number 42, a string, None, a function, a class, even int and type, and the modules json and sys: all objects. The last two lines are the most interesting. A class's type is type, the type of type is itself, and a module is just an object of type ModuleType.

What an object looks like in memory

In CPython's C code, every object starts with the same header (PyObject), with two fields:

  • ob_refcnt: how many places point at this object.
  • ob_type: a pointer to its type object.

The rest depends on the type: an integer holds its digits, a list holds an array of pointers to its items. Because the header is the same, C code can treat every object alike: read ob_type to know what to do with it. In CPython, id() is the object's memory address, so we can read that header directly with ctypes:

import ctypes


class PyObjectHeader(ctypes.Structure):
    # The first two fields of every object in CPython (Include/object.h)
    _fields_ = [("ob_refcnt", ctypes.c_ssize_t), ("ob_type", ctypes.c_void_p)]


def header(obj):
    return PyObjectHeader.from_address(id(obj))  # in CPython, id() is the object's memory address


items = [1, 2, 3]
h = header(items)
print("refcount:", h.ob_refcnt)
alias = items
print("refcount after 'alias = items':", h.ob_refcnt)
del alias
print("refcount after 'del alias':", h.ob_refcnt)
print("ob_type points at list:", h.ob_type == id(list))
print("None's refcount:", header(None).ob_refcnt)  # immortal since Python 3.12
refcount: 1
refcount after 'alias = items': 2
refcount after 'del alias': 1
ob_type points at list: True
None's refcount: 4294967295

Binding another name raises the count; deleting it lowers it. When the count reaches 0, CPython frees the object immediately, without waiting for any garbage collection pass. That reference count is exactly why the GIL from the previous post exists: if two threads changed ob_refcnt at the same moment, the number would be wrong.

Reference counting alone can't clean up cycles, like two objects pointing at each other. So CPython also has a cycle collector (the gc module) that runs from time to time to find groups of objects that only point at each other.

The 4294967295 for None isn't a bug. Since Python 3.12, objects shared for the program's whole life, like None, True and small integers, are immortal: their count is set to a special value and they are never freed.

Speaking of small integers: CPython creates the numbers -5 to 256 at startup and shares them.

a, b = int("256"), int("256")
c, d = int("257"), int("257")
print("256 is 256:", a is b)  # -5..256 are created once at startup and shared
print("257 is 257:", c is d)  # bigger ints are new objects each time
256 is 256: True
257 is 257: False

That's why you should never compare numbers with is: the result depends on implementation details. Use ==.

Classes are objects too, and how attributes are looked up

A class is an object of type type. Its attributes live in a dict, __dict__, and each instance has its own __dict__ too. When you write an.role, Python looks in order: the instance first, then each class in MRO (method resolution order).

class User:
    role = "member"  # stored in the class's __dict__

    def __init__(self, name):
        self.name = name  # stored in the instance's __dict__

    def greet(self):
        return f"Hi, I'm {self.name}"


class Admin(User):
    role = "admin"


an = Admin("An")
print("instance dict:", an.__dict__)
print("class dict has:", [k for k in Admin.__dict__ if not k.startswith("__")])
print("MRO:", [c.__name__ for c in Admin.__mro__])
print("an.role:", an.role, "(found in Admin, before User)")

method = an.greet  # a function found on the class becomes a bound method
print(type(User.__dict__["greet"]).__name__, "->", type(method).__name__, "| self is an:", method.__self__ is an)
print("the same thing by hand:", User.__dict__["greet"].__get__(an, Admin)())
instance dict: {'name': 'An'}
class dict has: ['role']
MRO: ['Admin', 'User', 'object']
an.role: admin (found in Admin, before User)
function -> method | self is an: True
the same thing by hand: Hi, I'm An

The last line is the most interesting. In the class's __dict__, greet is just a plain function. It becomes a bound method, a function with self already attached, through the descriptor mechanism: function objects have a __get__ method, and when one is looked up through an instance, Python calls __get__ to attach that instance. property, classmethod and staticmethod are all descriptors, working through exactly this mechanism.

Functions, code objects and bytecode

A function is an object holding a code object: the compiled bytecode, its constants, its variable names.

import dis


def greet(name):
    return "Hello " + name


code = greet.__code__  # the function object holds a code object
print(type(greet).__name__, "->", type(code).__name__)
print("constants:", code.co_consts, " variables:", code.co_varnames)
print("raw bytecode:", code.co_code.hex(" "))
dis.dis(greet)
function -> code
constants: (None, 'Hello ')  variables: ('name',)
raw bytecode: 97 00 64 01 7c 00 7a 00 00 00 53 00
  4           0 RESUME                   0

  5           2 LOAD_CONST               1 ('Hello ')
              4 LOAD_FAST                0 (name)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

When you call a function, CPython creates a frame, a space holding the local variables and the position of the current instruction, and the interpreter loop written in C runs the bytecode one instruction at a time: load the constant 'Hello ', load the variable name, add, return. The two 00 00 bytes after BINARY_OP are a cache slot: since Python 3.11, the interpreter "specializes" instructions that run many times, for example replacing a generic addition with string addition if it always sees two strings, and that slot holds the information for it.

The frame is also the key to coroutines from the previous post. A normal function's frame is dropped when it returns. A coroutine keeps its frame when it pauses at await, along with its local variables and instruction position, so send() can resume it exactly where it left off.

Modules are objects

If a module is just an object, import can be done by hand: create a module object, run code in its __dict__, and store it somewhere to fetch next time.

import sys
import types

# What `import hello` does, by hand: make a module object, run its code in the module's namespace, cache it
hello = types.ModuleType("hello")
exec("GREETING = 'xin chào'\ndef greet(name):\n    return f'{GREETING}, {name}'", hello.__dict__)
sys.modules["hello"] = hello

import hello as again  # noqa: E402  (found in sys.modules, so no file is looked for)

print(again is hello, again.greet("An"))
print(sorted(k for k in hello.__dict__ if not k.startswith("__")))
True xin chào, An
['GREETING', 'greet']

A module's global variables are really just keys in that module object's __dict__. The second import hello doesn't look for any file, because hello is already in sys.modules, the dict Python uses as the cache for every loaded module.

What import does, step by step

import json is really five steps:

  1. Check sys.modules. If "json" is there, use it, done. That's why module-level code runs once per process, however many places import it.
  2. Find. Ask each finder in sys.meta_path in turn: "do you know a module called json?". The finder that does returns a spec (ModuleSpec) describing where the module is and which loader can load it.
  3. Create the module object from the spec, and put it in sys.modules right away, before running its code.
  4. Run. The loader gets the code object (from a .pyc file, or by compiling the .py) and runs it in the module's __dict__.
  5. Bind the name. Bind json in the namespace that did the import. from json import loads does all of the above, then also takes the loads attribute out.

The finders, and the specs of a few modules:

import importlib.util
import sys

for finder in sys.meta_path:
    print("meta_path:", getattr(finder, "__name__", finder))

for name in ("sys", "json", "json.decoder"):
    spec = importlib.util.find_spec(name)
    loader = getattr(spec.loader, "__name__", type(spec.loader).__name__)  # BuiltinImporter is used as a class
    print(f"{name:<13} loader={loader:<17} origin={spec.origin}  package_path={spec.submodule_search_locations}")
meta_path: <_distutils_hack.DistutilsMetaFinder object at 0x7f1b72b02a80>
meta_path: BuiltinImporter
meta_path: FrozenImporter
meta_path: PathFinder
sys           loader=BuiltinImporter   origin=built-in  package_path=None
json          loader=SourceFileLoader  origin=<python>/lib/python3.12/json/__init__.py  package_path=['<python>/lib/python3.12/json']
json.decoder  loader=SourceFileLoader  origin=<python>/lib/python3.12/json/decoder.py  package_path=None

There are three finders by default: BuiltinImporter for modules compiled into the interpreter like sys, FrozenImporter for modules "frozen" into the executable, and PathFinder, which searches the folders on sys.path. The first line is a live example of sys.meta_path being extensible: setuptools has inserted a finder of its own into this Python environment.

A package is a module with __path__

A package is just a module with an extra __path__ attribute: the list of folders to search for submodules. For a regular package, the module itself is the __init__.py file.

print("running shop/__init__.py")
VERSION = "1.0"
print("running shop/cart.py")


def total(prices):
    return sum(prices)
import sys

import shop.cart

print("shop is a package:", hasattr(shop, "__path__"), shop.__path__)
print("cart became an attribute of shop:", shop.cart is sys.modules["shop.cart"])
print("total:", shop.cart.total([10, 20]))

import shop.cart  # noqa: E402,F811  (already in sys.modules: nothing runs again)

print("cached modules:", [name for name in sys.modules if name.startswith("shop")])
running shop/__init__.py
running shop/cart.py
shop is a package: True ['.../demo/shop']
cart became an attribute of shop: True
total: 30
cached modules: ['shop', 'shop.cart']

import shop.cart runs shop/__init__.py first, since the parent package has to exist first, then shop/cart.py. The submodule is attached as an attribute of the parent package, and both sit in sys.modules. The second import prints nothing: no file runs again.

If a folder has no __init__.py, Python creates a namespace package: a package that can be spread across several folders. That's useful for plugin systems, where each plugin is installed into its own folder.

import sys

sys.path[:0] = ["plugins_a", "plugins_b"]  # two folders, both with plugins/ and no __init__.py

import plugins.csv  # noqa: E402
import plugins.pdf  # noqa: E402

print("__file__:", plugins.__file__)
print("__path__:", list(plugins.__path__))
print(plugins.pdf.NAME, plugins.csv.NAME)
__file__: None
__path__: ['.../demo/plugins_a/plugins', '.../demo/plugins_b/plugins']
pdf csv

A namespace package has no __file__, since no file is its main module, and its __path__ covers both folders.

__pycache__ and .pyc files

Compiling .py to bytecode takes time, so on the first import CPython writes the compiled code object to __pycache__/<name>.cpython-312.pyc. Next time, it checks whether that file still matches the source and uses it directly.

import dis
import importlib.util
import marshal
import struct
from pathlib import Path

path = Path(importlib.util.cache_from_source("shop/cart.py"))
data = path.read_bytes()
magic, flags, mtime, size = data[:4], *struct.unpack("<3I", data[4:16])  # 16-byte header (PEP 552)
print("file:", path)
print("magic matches this Python:", magic == importlib.util.MAGIC_NUMBER, magic.hex(" "))
print("flags:", flags, " source mtime:", mtime, " source size:", size, "bytes")
print("source mtime on disk:", int(Path("shop/cart.py").stat().st_mtime))
code = marshal.loads(data[16:])  # the rest is the module's code object, serialized
print(type(code).__name__, code.co_names)
file: shop/__pycache__/cart.cpython-312.pyc
magic matches this Python: True cb 0d 0d 0a
flags: 0  source mtime: 1792140599  source size: 74 bytes
source mtime on disk: 1792140599
code ('print', 'total')

The .pyc header is 16 bytes:

  • Magic number: changes with the bytecode version, so Python 3.13 won't mistakenly use a 3.12 .pyc. The file name includes cpython-312 for the same reason.
  • Flags: 0 means check by modification time. There's another mode that checks a hash of the contents, for builds that must produce identical output every time.
  • The source file's modification time and size: if they differ from the current .py, the .pyc is considered stale and recompiled.

The rest is the code object, serialized with the marshal module.

How import touches the disk

Tracing the system calls of import json.decoder with strace, as in the previous post (trimmed):

stat(".../json/__init__.cpython-312-x86_64-linux-gnu.so") = -1 ENOENT
stat(".../json/__init__.abi3.so")                      = -1 ENOENT
stat(".../json/__init__.so")                           = -1 ENOENT
stat(".../json/__init__.py")                           = 0
openat(".../json/__pycache__/__init__.cpython-312.pyc") = 3
openat(".../json", O_DIRECTORY)                        = 3
stat(".../json/decoder.py")                            = 0
openat(".../json/__pycache__/decoder.cpython-312.pyc") = 3

For each module, PathFinder tries the possible forms in turn: compiled C extensions (.so) first, then .py. Once it finds the .py, it opens the matching .pyc in __pycache__. The openat(".../json", O_DIRECTORY) line is where it reads the package folder's file list once and keeps it, so the submodules after it don't have to ask the disk again and again.

To find out which imports are slow, Python has -X importtime built in:

import time: self [us] | cumulative | imported package
import time:       370 |       4812 |     re
import time:        35 |         35 |       _json
import time:       284 |        318 |     json.scanner
import time:       336 |       5465 |   json.decoder
import time:       276 |        276 |   json.encoder
import time:       354 |       6094 | json

The middle column is the total time in microseconds, submodules included. Importing json takes about 6 ms, most of it loading re. In a large app, imports can be a real share of startup time, and this tells you exactly which part.

Circular imports

Step 3 above, putting the module into sys.modules before running its code, is what keeps circular imports from looping forever. But it also produces a familiar error:

import b

LIMIT = 10


def check(n):
    return n <= b.limit()
from a import LIMIT  # a is only half done when this runs


def limit():
    return LIMIT
ImportError: cannot import name 'LIMIT' from partially initialized module 'a' (most likely due to a circular import)

On import a: a is created and put in sys.modules, then a's first line imports b. b needs LIMIT from a, but at that point a has run exactly one line, so LIMIT doesn't exist yet. The error message says it precisely: a partially initialized module.

The fix: take the whole module object, and only read the attribute when it's actually needed.

import a  # take the module object, not a name from inside it


def limit():
    return a.LIMIT  # looked up when called, by which time a has finished running

By the time limit() is called, a finished long ago. Still, the better fix is usually to move the shared part into a third module so there's no cycle at all.

Writing your own finder

Since import goes through sys.meta_path, you can insert your own finder there. Here's one that loads modules from a dict, no files needed:

import importlib.abc
import importlib.util
import sys

SOURCES = {"greetings": "def hello(name):\n    return f'Xin chào {name}'\n"}  # code that lives in no file


class DictFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
    def find_spec(self, name, path, target=None):
        if name in SOURCES:
            return importlib.util.spec_from_loader(name, self)
        return None  # not ours: let the next finder try

    def exec_module(self, module):
        exec(SOURCES[module.__name__], module.__dict__)


sys.meta_path.insert(0, DictFinder())

import greetings  # noqa: E402

print(greetings.hello("An"), "| loaded by", type(greetings.__spec__.loader).__name__)
Xin chào An | loaded by DictFinder

pytest uses exactly this mechanism to rewrite the assert statements in test files at import time, which is how it can print the value of each side when a test fails.

Back to FastAPI

uvicorn src.main:app uses everything above. In uvicorn's source, import_from_string splits the string at :, calls importlib.import_module("src.main"), then gets the app attribute with getattr. From there:

  • Importing src.main runs all of its module-level code: it creates app = FastAPI(...), calls include_router for each router, and to do that imports every router module, and everything those modules import.
  • Module-level code runs once per process. This website relies on that: src/blog/service.py has the line POSTS = load_posts(...), so every post, including the one you're reading, is read from disk and turned into HTML exactly once at startup, then stays in memory.
  • Each uvicorn worker is its own process, so each worker has its own sys.modules and imports everything again from scratch. That's another reason, at the import level, why state can't be shared between workers, as the previous post said.
  • --reload restarts the whole process when a file changes, instead of re-importing. sys.modules holds the old modules, and every object created from the old code still points at the old functions and classes; refreshing modules one by one safely is close to impossible.

In short

Concept What it really is
Object A piece of memory starting with a reference count and a pointer to its type
Class An object of type type, with attributes in __dict__
Method A function, with self attached by the __get__ descriptor
Function An object holding a code object (bytecode); each call creates a frame
Module A ModuleType object; global variables are keys in its __dict__
Package A module with __path__
import Check sys.modules → ask the finders → create the module → run its code → bind the name
.pyc A serialized code object, with a magic number and a fingerprint of the source

Once "everything is an object" clicks, many questions answer themselves: why module-level code runs only once, why circular imports say "partially initialized", why is isn't for comparing numbers, and why uvicorn needs a module:attribute string to find your app.