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:
- 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. - Find. Ask each finder in
sys.meta_pathin turn: "do you know a module calledjson?". The finder that does returns a spec (ModuleSpec) describing where the module is and which loader can load it. - Create the module object from the spec, and put it in
sys.modulesright away, before running its code. - Run. The loader gets the code object (from a
.pycfile, or by compiling the.py) and runs it in the module's__dict__. - Bind the name. Bind
jsonin the namespace that did theimport.from json import loadsdoes all of the above, then also takes theloadsattribute 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 includescpython-312for the same reason. - Flags:
0means 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.pycis 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.mainruns all of its module-level code: it createsapp = FastAPI(...), callsinclude_routerfor 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.pyhas the linePOSTS = 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.modulesand 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. --reloadrestarts the whole process when a file changes, instead of re-importing.sys.modulesholds 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.