◈ python mapVol 2 · Ch 20/30
Volume 2 Python, from the metal up · chapter 20

20Inside import — sys.path, finders, loaders, and .pyc

In Chapter 19 we met import from the outside — four honest verbs, find, compile, exec, cache, named but still half-trusted. In this one we pry the lid off and watch each one run. Here's the plan. We print sys.path, the ordered list of folders import walks. We watch a stray random.py in your own directory beat the entire standard library. Then we follow the winning file through the finder/loader handshake, into the .pyc bytecode cache with its sixteen-byte header, and out the far side as a live module object. The whole way through, we keep asking the one thing that matters — when you type import X, what exactly decides which bytes on disk become the module you get back? By the end, import is no longer a keyword you trust but a machine you can single-step. It is an ordered search you can print, a finder/loader protocol you could implement yourself, and a compile pipeline that turns the text you type into the same bytecode the Volume 1 VM already knows how to run. It is the same story as every boundary in this volume. What crosses from disk into your process is always bytes, and import is the machine that rebuilds live objects on the near side.

iolinked · chapter 20 — the checkpoints6 steps
$ sections covered in Inside import — sys.path, finders, loaders, and .pyc
01sys.path: an ordered search, first match wins
02Finders and loaders: the import protocol
03Source becomes bytecode; __pycache__ caches it
04The compile pipeline: source → AST → code object
05Namespace packages: folders without __init__.py
06Import hooks: why the protocol is pluggable

01sys.path: an ordered search, first match wins

Let's start with the bug everyone hits. You're debugging something fiddly with randomness, so you make a throwaway file to poke at it and name it, reasonably, random.py. Run your real program and watch it detonate: AttributeError: module 'random' has no attribute 'random'. You did not touch the standard library. You did not mistype. And yet import random has handed you your file instead of Python's. The very same code that works on your laptop will explode the moment it lands in a directory that happens not to contain your little file. Every Python programmer meets this bug, stares at it for an hour, and walks away convinced import is haunted. It is not. Import is a deterministic walk down a list you can print, and the instant you can see the list, the haunting stops for good.

Here is the whole secret in one sentence. import X does not search your disk and it does not know where the standard library lives. It walks sys.path, a plain list[str] of directory names, from top to bottom. It asks each location the same blunt question, "do you have X?", and it stops at the first one that says yes. First match wins. That rule is the entire mystery. So print the list and look at the thing that has been deciding your imports all along:

a live sessionpython
>>> import sys
>>> for i, p in enumerate(sys.path):
...     print(i, repr(p))
0 ''                                  # cwd / the script's own folder — checked FIRST
1 'C:\\Python312\\python312.zip'
2 'C:\\Python312\\Lib'                # the standard library lives here
3 'C:\\Python312'
4 'C:\\Python312\\Lib\\site-packages'  # where pip installs land

Read the entries in the order the interpreter built them at startup, because the order is the rule. Index 0 is the special one. Run python foo.py and it becomes the folder that foo.py lives in. Run -c, or -m, or the bare REPL and it becomes '', meaning the current working directory. That single entry, checked before everything else, is exactly why a sibling random.py beats the standard library — your project folder is first in line. After it come the $PYTHONPATH entries you set in the environment. Then come the standard-library directories and the frozen modules baked into the executable. And finally the site-packages directory that the site module appends, where every pip install deposits its wares. Top to bottom, first match wins, no exceptions.

SYNTAX · printing the list that decides every importnothing is "installed for Python" — there is one ordered list of folders, and you can read it, print it, and edit it
import sys sys.path — a plain list[str]. Not a registry, not a database. sys.path[0] — the folder of the SCRIPT you launched '' (meaning the cwd) under -c, -m and the REPL # the order the interpreter builds it, top to bottom # [0] your script's folder, or the cwd # [1] $PYTHONPATH entries, split on os.pathsep (; on Windows, : elsewhere) # ... the standard library, and its .zip # ... site-packages -- where every pip install lands sys.path.insert(0, "lib") — wins the next search. Relative to the CWD, not to you. sys.path.append(str(Path(__file__).resolve().parent / "lib")) — anchored to the FILE. This one survives a cd.
sys.pathA list of directory names, rebuilt at interpreter startup. Import re-reads it on every single call, so an edit takes effect immediately.
sys.path[0]Chapter 19's rule, now visible: python app/play.py puts app first, python -m app.play puts the current directory first. That one row decides what you can import.
$PYTHONPATHHonest, and invisible. It is a value in your shell that your code depends on and your repository does not record, so it works on your laptop and nowhere else.
site-packagesLast in line, which is exactly why a file of yours can beat a package you installed. pip install never moves anything ahead of your folder.
.insert(0, …)Puts your folder ahead of the standard library — the same position that causes shadowing. Prefer .append unless you mean to override.
the quick fixsys.path.append("lib") is a relative entry resolved against the working directory. It works from one folder and dies from another; the anchored form does not.
what it never doesAdding a folder cannot un-add another. If two entries hold the same name, the earlier one wins forever, and no later entry can outrank it.
you type
# ---------- wheres_my_import.py ----------
# wheres_my_import.py -- print the ordered list that decides every import
import os
import sys
import sysconfig

STDLIB = sysconfig.get_paths()["stdlib"]


def label(i, entry):
    if i == 0:
        return "the launched script's folder (or the cwd)"
    if entry.endswith(".zip"):
        return "the standard library, zipped"
    if entry == STDLIB:
        return "the standard library"
    if entry.endswith("site-packages"):
        return "site-packages -- every pip install lands here"
    if entry == sys.prefix:
        return "this environment's own folder"
    if entry == sys.base_prefix or entry.endswith("DLLs"):
        return "the interpreter this one was built from"
    return "an extra entry (PYTHONPATH, or a .pth file)"


print("python", sys.version.split()[0], "| entries:", len(sys.path))
for i, entry in enumerate(sys.path):
    print(f"{i}  {os.path.basename(entry) or '(cwd)':<14} {label(i, entry)}")


# ---------- the terminal ----------
$ python wheres_my_import.py
$ python -c "import beat"
$ set PYTHONPATH=C:\tmp\pathbox\tools
$ python -c "import sys, beat; print('BPM', beat.BPM); print('sys.path[1]', sys.path[1])"
you see
$ python wheres_my_import.py
python 3.12.7 | entries: 7
0  shop           the launched script's folder (or the cwd)
1  python312.zip  the standard library, zipped
2  DLLs           the interpreter this one was built from
3  Lib            the standard library
4  anaconda3      the interpreter this one was built from
5  .venv          this environment's own folder
6  site-packages  site-packages -- every pip install lands here

$ python -c "import beat"
ModuleNotFoundError: No module named 'beat'

$ set PYTHONPATH=C:\tmp\pathbox\tools
$ python -c "import sys, beat; ..."
BPM 128
sys.path[1] C:\tmp\pathbox\tools
where beginners trip
  • Your list will not look like this one. The entries depend on your install; the order never varies.
  • Editing sys.path changes this process only. Nothing is written anywhere, and the next python starts from a clean list.
  • A .pth file in site-packages can append entries you never wrote. If a folder appears from nowhere, that is almost always why.
  • PYTHONPATH is split on os.pathsep — a semicolon on Windows, a colon everywhere else. Getting that wrong silently adds one nonsense entry.
  • sys.path.append("lib") looks harmless and is the entry that follows you around: run the same script from one folder up and the import dies.
  • There is no sys.path.remove ritual to learn. It is a list; del sys.path[2] works, and is just as permanent as everything else here (not at all).
import random1 · check the cachesys.modules — in-memory cache'sys' → module'os' → modulehit = done · miss = search sys.path ↓2 · cache miss — walk sys.path in order, first match wins[0] ./ your project folderholds random.py → MATCH · search STOPS here✓ found[1] PYTHONPATH entries[2] stdlib …/python3.12/the real random.py lives here · never reached[3] site-packages/ (pip installs)shadowedby [0]
Fig — Import resolution checks the sys.modules cache first; on a miss it walks sys.path top-down and stops at the first directory holding the module — so a local file can shadow the standard library.
SYNTAX · the shadow — when your file wins and you did not mean it toevery Python programmer meets this once; the cure is a rename, and it takes ten seconds once you can name it
random.py — a five-minute experiment, saved beside your code import random — YOUR file. sys.path[0] beat the standard library. # the symptom is almost never an import error AttributeError: module 'random' has no attribute 'seed' — the import SUCCEEDED. The wrong module was found. ImportError: cannot import name 'choice' from 'random' — same disease, the `from` form. # the two lines that name the culprit random.__file__ — the file that actually won 'random' in sys.stdlib_module_names — was that name already taken? # the fix: rename YOUR file. Nothing to install, nothing to uninstall.
why AttributeErrorBecause import worked perfectly. It found a file called random.py, ran it, and handed it to you. Your file simply has no seed in it.
random.__file__The single most useful line in this chapter. It ends the argument: it prints the exact path of the module you actually got.
sys.stdlib_module_namesA frozenset of every name the standard library owns. Check a filename against it before you save, and you never hit this bug again.
a folder can shadow tooBut only a folder holding an __init__.py. An empty look-alike directory is recorded as a namespace portion and the scan continues, so the real package still wins.
pip install cannot helpsite-packages sits below your folder on the list. Installing the real thing puts a second copy further down, where it will never be reached.
the rename is the whole fixGive your file a name nobody owns — coinflip.py, my_random.py — and the very next import falls through to the standard library.
you type
# ---------- your folder ----------
jukebox/
    random.py          # the five-minute experiment you meant to delete
    shuffle_demo.py    # the program that suddenly broke

# ---------- random.py (yours) ----------
# random.py -- a five-minute experiment you meant to delete
COIN = ["heads", "tails"]

# ---------- shuffle_demo.py ----------
# shuffle_demo.py -- pick a track at random
import random

TRACKS = ["Blinding Lights", "Titanium", "Levels"]
random.seed(20)
print(random.choice(TRACKS))


# ---------- the terminal ----------
$ python shuffle_demo.py
$ python -c "import random; print(random.__file__)"
$ mv random.py coinflip.py              # ren random.py coinflip.py on Windows cmd
$ python shuffle_demo.py
you see
$ python shuffle_demo.py
Traceback (most recent call last):
  File "C:\tmp\jukebox\shuffle_demo.py", line 5, in <module>
    random.seed(20)
    ^^^^^^^^^^^
AttributeError: module 'random' has no attribute 'seed'

$ python -c "import random; print(random.__file__)"
C:\tmp\jukebox\random.py

$ mv random.py coinflip.py
$ python shuffle_demo.py
Levels
where beginners trip
  • Renaming the .py is enough. The orphaned __pycache__/random.cpython-312.pyc left behind is never importable on its own once its source is gone.
  • The bug travels badly: your teammate's checkout has no random.py, so the code works for her and breaks only for you — or exactly the reverse.
  • types.py, string.py, code.py, copy.py, queue.py, select.py and signal.py are the quiet ones. They all shadow, and none of them sounds dangerous.
  • A shadow can hide for weeks. It only detonates when something — often a library you installed, not your own code — asks for the part your file does not have.
  • The name you must not use is the import name, not the pip name. A file called yaml.py shadows pyyaml, whose distribution name never appears in your code.
  • Renaming a shadowing file inside a running REPL changes nothing. The wrong module is already in sys.modules; restart the process.

The most liberating fact is that sys.path is just data. It is a list. You can sys.path.insert(0, ...), .append(...), or reorder it at runtime, and the very next import obeys the new order. Import re-reads the list on every call. It never snapshots it. But before the walk even begins, import checks a faster cache: sys.modules, the dictionary of everything already imported in this process. A hit there means no search at all, and the module object is returned instantly. That is why the second import json is nearly free, and why a module's top-level code runs exactly once per process no matter how many files import it.

SYNTAX · sys.modules — reading the cache while your program runsan ordinary dict keyed by import name; it answers "did it load?", "which file won?" and "why is my edit being ignored?"
import sys sys.modules — dict[str, module]. An ordinary dict, keyed by import name. "json" in sys.modules — has anything imported it yet, in THIS process? sys.modules["json"].__file__ — which file won. The shadow test, in one line. len(sys.modules) — how tall the pile already is sorted(sys.modules)[:5] — read it like any other dict, because it is one del sys.modules["beat"] — evict: the NEXT import redoes find, compile, exec import beat — ... and rebinds YOUR name to a brand-new object
keyed by dotted name'os' and 'os.path' are two separate keys pointing at two separate module objects. A package and its submodules each get their own row.
__file__ may be missingsys is compiled into the interpreter binary, so it has no file at all. Reach for it with getattr(mod, "__file__", None), never blindly.
the identity claimsys.modules["beat"] is beat is True. There is one module object and every importer holds a reference to that same one.
del forces real workThis is the practical cure for "I edited my module and nothing changed". Evicting the key means the next import must find, compile and exec again.
but del strandsIt unloads nothing. Anything already holding the old module keeps the old functions, and now two live copies exist. In a long session, restarting is the honest move.
importlib.reloadChapter 19's surgical version: it re-execs the source into the same object, so every name anyone already holds quietly sees the new code.
you type
# ---------- beat.py ----------
# beat.py -- a module that announces itself when it executes
print("beat.py: top level running")

BPM = 128


# ---------- cache_probe.py ----------
# cache_probe.py -- the import cache, used as a debugging instrument
import os
import sys

print("loaded before we start :", len(sys.modules), "modules")
print("beat in the cache?     :", "beat" in sys.modules)

import beat                        # MISS -- find, compile, exec; the print fires
import beat                        # HIT  -- one dict lookup, and silence

print("beat in the cache?     :", "beat" in sys.modules)
print("cache holds the object :", sys.modules["beat"] is beat)
print("which file won         :", os.path.basename(sys.modules["beat"].__file__))
print("BPM                    :", beat.BPM)

beat.BPM = 200                     # you patch the live module
del sys.modules["beat"]            # evict it: the next import must do real work
import beat                        # MISS again -- the print fires a second time

print("BPM after re-import    :", beat.BPM)
print("sys itself has no file :", hasattr(sys, "__file__"))
you see
$ python cache_probe.py
loaded before we start : 35 modules
beat in the cache?     : False
beat.py: top level running
beat in the cache?     : True
cache holds the object : True
which file won         : beat.py
BPM                    : 128
beat.py: top level running
BPM after re-import    : 128
sys itself has no file : False
where beginners trip
  • The startup count differs by version and platform. Thirty-five is not a fact to memorise; the scale is the point — you never start from zero.
  • Two import beat lines, one beat.py: top level running. The second import printed nothing because nothing ran.
  • After del plus re-import, your beat.BPM = 200 is gone. You patched an object that is now stranded, and the name points at a fresh one.
  • Assigning into the cache is legal: sys.modules["requests"] = fake is how test frameworks stub modules, and how a bad import hook ambushes you.
  • A module can sit in sys.modules half-executed — that is the mid-load state from section 2, and it is why a circular import sometimes yields a module missing half its names.
  • Deleting a package key while its submodules stay cached leaves the dict inconsistent. Evict pkg and pkg.sub together, or just restart.
shadowing, revealedpython
>>> import random
>>> random.__file__
'C:\\Users\\you\\project\\random.py'   # not the stdlib — YOUR file won the search
>>> del sys.modules['random']        # evict the wrong module from the cache
>>> # now delete your local random.py, then import again → it falls through to the stdlib
NOT EVERYTHING IS A FILE
A handful of modules — sys itself, and the C-level ones listed in sys.builtin_module_names — are compiled into the interpreter binary. A special finder answers for them before sys.path is ever consulted, so they can never be shadowed by a file. Don't let the file-based majority fool you into thinking import is only ever about opening .py files.
THE ONE IDEA TO CARRY FORWARD
Import is one loop: check sys.modules → on a miss, walk sys.path top to bottom → first directory that can produce the name wins → record the result back into sys.modules. Every shadowing bug you will ever hit is that loop finding a nearer match than you meant it to. Print the list; the ghost becomes a line number.

The walk finds a directory that can produce the name — but who decides whether a folder "has" the module, and who turns the winning file into a live object? Those are two different jobs, done by two different kinds of object, and pulling them apart is what turns import from a keyword into a machine. Finders and loaders →

02Finders and loaders: the import protocol

You have watched import pull a module out of a .zip file. You have seen a Jupyter notebook import a sibling notebook that has no .py at all. If import were simply "open a .py and run it," none of that could exist. So there must be a seam — a place where "how do we find this?" is cleanly separated from "how do we build it?". Only a separated, swappable design could serve modules from a zip, a notebook, a database row, or thin air. Finding that seam is what turns import from a magic word into a machine with named parts you could implement yourself.

The real shape of import X is a two-phase protocol driven by importlib. Phase FIND: the machinery iterates sys.meta_path, a list of meta path finders, and asks each one find_spec(name, path, target). A finder returns either None, meaning "not mine, ask the next one," or a ModuleSpec. The three defaults, in order, are BuiltinImporter (the C builtins from the last section), FrozenImporter (modules frozen into the binary), and PathFinder, the one that finally consults sys.path. Print the chain and it is exactly what the mechanism predicts:

the protocol is made of objectspython
>>> import sys, json
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>]
>>> json.__spec__
ModuleSpec(name='json', loader=<...SourceFileLoader...>,
           origin='C:\\Python312\\Lib\\json\\__init__.py',
           submodule_search_locations=['C:\\Python312\\Lib\\json'])

That ModuleSpec is the pivot of the whole system — the blueprint the rest of import consumes. Learn its fields, because they answer every "how did import know…" question you will ever have. .name is the dotted name. .loader is the object that will actually build the module. .origin is usually the source filename (or the literal string '(built-in)'). .submodule_search_locations is the __path__ a package hands to its children. .cached points at the .pyc. A finder's entire job is to fill in that form and hand it back.

SYNTAX · the ModuleNotFoundError checklistfour questions, four one-liners — find_spec turns "it can't find my module" from a mood into a measurement
import importlib.util as u, sys # 1. did anything already import it in this process? sys.modules.get("X") # 2. can the finders locate it at all? u.find_spec("X") — None means NO folder on sys.path answers to X # 3. if yes -- which file exactly would you get? u.find_spec("X").origin — inside your project? you have a shadow. # 4. and is that a name the standard library already owns? "X" in sys.stdlib_module_names sys.executable — the tie-breaker: WHICH python is asking?
find_spec is the finderIt is the same call the import machinery makes, run by you instead of by the import statement. There is no separate debug mode, because there is no need for one.
None vs a specNone means no location on sys.path claims the name. A spec means the name resolves, and .origin tells you to which file.
a spec is not a promiseIt proves the name resolves. A SyntaxError or a raising line inside the module still detonates later, at exec — a different stage, a different fix.
ModuleNotFoundErrorA subclass of ImportError, so except ImportError catches both. The subclass exists so optional-dependency code can catch the missing case only.
dotted names bitefind_spec("pkg.sub") imports pkg as a side effect, and raises ModuleNotFoundError outright if pkg itself is missing. Check the parent first.
sys.executableThe classic false mystery: pip install succeeded and the import still fails. You installed into a different interpreter than the one running your code.
you type
# ---------- your folder ----------
check/
    csv.py             # a quick parser you wrote for one CSV, months ago


# ---------- the terminal ----------
$ python -c "import analytics"
$ python -c "import importlib.util as u; print(u.find_spec('analytics'))"
$ python -c "import importlib.util as u; print(u.find_spec('csv').origin)"
$ python -c "import sys; print('csv' in sys.stdlib_module_names)"
$ python -c "import sys; print(repr(sys.path[0]))"
you see
$ python -c "import analytics"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'analytics'

$ ... print(u.find_spec('analytics'))
None

$ ... print(u.find_spec('csv').origin)
C:\tmp\check\csv.py

$ ... print('csv' in sys.stdlib_module_names)
True

$ ... print(repr(sys.path[0]))
''
where beginners trip
  • Read the two answers together. find_spec returned a real file and the name belongs to the stdlib — that pair, and only that pair, means shadow.
  • repr(sys.path[0]) printed '' because this was python -c. Under python app.py the same line prints the script's own folder.
  • The first check is the cheapest and the one everyone skips: read the name back. datetime, not DateTime; dateutil, not date_util.
  • find_spec on a name already in sys.modules returns the cached spec, so run it in a fresh process when you are hunting a shadow.
  • A missing third-party package and a shadowed stdlib name produce completely different errors. Guessing between them costs an hour; asking find_spec costs a second.
  • pip show X tells you a package is installed somewhere. Only find_spec plus sys.executable tells you whether this interpreter can reach it.
sys.meta_path — finders tried in order; each returns a ModuleSpec or None (pass to next)import XBuiltinImporterNone →FrozenImporterNone →PathFinderinner: sys.path_hooksFileFinder-hookzipimporter-hookemits a spec →ModuleSpecname = 'X'loader = SourceFileLoaderorigin = /pkg/X.pycached = /pkg/X.pycsubmodule_search_locations = Noneloader — runs three steps1 module_from_spec(spec)2 sys.modules['X'] = m (pre-exec)3 loader.exec_module(m)→ fully-initialised module ✓
Fig — Behind import X the sys.meta_path finders are tried in turn; the first to recognise the name returns a ModuleSpec, and the loader builds the module, registers it in sys.modules before executing it, then runs its body.

Phase LOAD is where the spec becomes a living thing. Import calls importlib.util.module_from_spec(spec) to make an empty module object. It inserts that object into sys.modules before running a single line of it. This is the subtle move that lets circular imports partially work: a module that imports back into a half-built one finds the half-built object already registered, rather than triggering an infinite re-import. Only then does import call spec.loader.exec_module(module), which runs the module's code object into the module's __dict__. The division of labour is crisp. A finder answers "where and how," and a loader does "create and exec." Nothing is hidden. You can perform the entire dance by hand:

import_by_hand.pypython
import importlib.util, sys

def manual_import(name):
    spec = importlib.util.find_spec(name)          # PHASE FIND → a ModuleSpec
    module = importlib.util.module_from_spec(spec)  # create the empty module
    sys.modules[name] = module                      # register BEFORE executing
    spec.loader.exec_module(module)                 # PHASE LOAD → run the body
    return module

textwrap = manual_import('textwrap')
print(textwrap.shorten("the whole import protocol, by hand", 20))

One layer remains, and it explains the zip trick. PathFinder does not read files itself. For each entry on sys.path it consults sys.path_hooks to obtain a path entry finder for that specific location. The default hook handles ordinary directories, and another handles zip archives. It memoises the result in sys.path_importer_cache, so it only computes each one once per directory. So there are genuinely two finder layers: meta path finders are global strategies, and path entry finders are per-sys.path-entry workers. That is why a .zip on sys.path "just works": a path hook claims the archive and returns a finder that reads modules out of it.

TYPE THIS · save it, then run itimport_detective.py — one broken project, two different import bugs, diagnosed before either is fixed
# ---------- the broken layout ----------
jukebox/
    app.py                 # imports json and playlist
    json.py                # a helper you wrote last month  -- SHADOW
    lib/
        playlist.py        # the module app.py wants        -- WRONG FOLDER
    import_detective.py

# ---------- app.py ----------
# app.py -- print the playlist as JSON
import json
import playlist

print(json.dumps(playlist.load()))

# ---------- lib/playlist.py ----------
# playlist.py -- the data the app prints
TRACKS = [
    {"title": "Blinding Lights", "seconds": 200},
    {"title": "Titanium", "seconds": 245},
]


def load():
    return TRACKS


# ---------- import_detective.py ----------
# import_detective.py -- answer "why can't Python import that?" in four checks
import importlib.util
import os
import sys

HERE = os.path.realpath(os.getcwd())


def short(path):
    """A readable path: relative to the project when the file is inside it."""
    real = os.path.realpath(path)
    if real.startswith(HERE):
        return os.path.relpath(real, HERE).replace(os.sep, "/")
    return "/".join(real.replace(os.sep, "/").split("/")[-2:])


def files_named(name):
    """Every <name>.py under the project -- on sys.path or not."""
    hits = []
    for root, dirs, files in os.walk(HERE):
        dirs[:] = [d for d in dirs if d not in {"__pycache__", ".git", ".venv"}]
        if name + ".py" in files:
            hits.append(short(os.path.join(root, name + ".py")))
    return hits


def investigate(name):
    print("---", name, "---")
    print("1 in sys.modules :", name in sys.modules)

    spec = importlib.util.find_spec(name)
    if spec is None:
        print("2 find_spec      : None -- no sys.path entry answers to that name")
        hits = files_named(name)
        if hits:
            print("3 file on disk   :", ", ".join(hits))
            print("  VERDICT       : right file, wrong folder -- it is not on sys.path")
        else:
            print("3 file on disk   : none -- check the spelling, or pip install it")
            print("  VERDICT       : the name does not exist here")
        return

    origin = spec.origin or "(built-in)"
    mine = os.path.realpath(origin).startswith(HERE)
    print("2 find_spec      :", short(origin))
    print("3 whose file     :", "yours" if mine else "not yours")
    if mine and name in sys.stdlib_module_names:
        print("  VERDICT       : SHADOW -- your file beats the stdlib module of that name")
    else:
        print("  VERDICT       : import will succeed with that file")


for wanted in sys.argv[1:]:
    investigate(wanted)
$ python app.py
Traceback (most recent call last):
  File "C:\tmp\jukebox\app.py", line 3, in <module>
    import playlist
ModuleNotFoundError: No module named 'playlist'

$ python import_detective.py playlist json
--- playlist ---
1 in sys.modules : False
2 find_spec      : None -- no sys.path entry answers to that name
3 file on disk   : lib/playlist.py
  VERDICT       : right file, wrong folder -- it is not on sys.path
--- json ---
1 in sys.modules : False
2 find_spec      : json.py
3 whose file     : yours
  VERDICT       : SHADOW -- your file beats the stdlib module of that name

$ mv lib/playlist.py playlist.py     &&  rmdir lib
$ mv json.py pairs.py

$ python app.py
[{"title": "Blinding Lights", "seconds": 200}, {"title": "Titanium", "seconds": 245}]

$ python import_detective.py playlist json
--- playlist ---
1 in sys.modules : False
2 find_spec      : playlist.py
3 whose file     : yours
  VERDICT       : import will succeed with that file
--- json ---
1 in sys.modules : False
2 find_spec      : json/__init__.py
3 whose file     : not yours
  VERDICT       : import will succeed with that file
Type the layout as it stands, broken, and run app.py first. The traceback names one problem — playlist — and stops there, which is exactly the trap. Fix that one, re-run, and a second, stranger failure is waiting behind it. So run the detective instead, on both names at once, before touching anything. It reports two entirely different diagnoses. playlist gets find_spec: None, which means no folder on sys.path claims that name — and then the walk finds lib/playlist.py sitting right there on disk. Right file, wrong folder: the file exists, and lib/ is simply not a place import looks. json gets the opposite verdict. It does resolve, to json.py, the file is yours, and json is a name the standard library already owns. That is the shadow, caught before it ever raised anything. Notice what makes the tool honest: short() prints paths relative to your project, so a shadow reads as a bare filename and the real standard library reads as json/__init__.py — one glance tells them apart. Two fixes, both boring: move the file up beside app.py, and rename yours to pairs.py. Now the same two questions come back clean, and app.py prints its JSON. Two things to try. Point the detective at a name that does not exist anywhere — python import_detective.py analytiks — and watch it say so plainly instead of guessing. Then add a print(sys.path[0]) to investigate, run it from lib/, and watch the whole verdict change because the first entry changed.
THE ONE IDEA TO CARRY FORWARD
Import is not a statement, it is three calls: find_specmodule_from_specexec_module. A finder produces a ModuleSpec; a loader turns that spec into a live module executed into its own __dict__. Every step is an ordinary object sitting on an ordinary list — which is exactly why, two sections from now, you will be able to add your own.

The loader ran the module's code object — but where did that code object come from, and why is the second import so much faster than the first? The answer is a folder you have seen appear next to your code and always been slightly afraid to delete. __pycache__

03Source becomes bytecode; __pycache__ caches it

The first time you import a large library there is a perceptible pause. The second time it is instant. And sitting next to your code, unbidden, is a folder called __pycache__ full of .pyc files. You have wondered three things about it and never quite asked out loud: is it safe to delete, why does it exist, and did my source just get "compiled" like C? Answer those and you have demystified startup time, an entire family of "stale bytecode" bugs, and exactly what Python is trading memory for speed on.

SYNTAX · __pycache__ — what is in it, and why deleting it is always safeone .pyc per imported module, stamped with the exact interpreter that wrote it
__pycache__/beat.cpython-312.pyc beat — the module name. One .pyc per IMPORTED module. cpython — the implementation (PyPy writes pypy311 instead) 312 — the exact interpreter. 3.11 never reads 3.12's file. import importlib.util importlib.util.cache_from_source("beat.py") — the .pyc path, computed for you importlib.util.source_from_cache(pyc) — and back the other way python -B script.py — compile in memory, write nothing at all sys.dont_write_bytecode = True — the same switch, thrown from inside
only imported modulesThe file you launch is compiled fresh every single run and never cached. __main__ gets no .pyc, which is why the folder holds your libraries and not your entry point.
the version tagBytecode is not portable across interpreter versions, so the tag keeps them apart. Two versions can share one folder and neither will ever read the other's file.
cache_from_sourceStop hand-building that filename. This is the function the loader itself uses, so it is right on every platform and every implementation.
deleting itCosts one recompile and nothing else. It is never the fix for a logic bug, and it is free enough to try when you suspect a stale cache.
-B and PYTHONDONTWRITEBYTECODEUseful in containers and on read-only mounts. You pay the compile every start, which for a small program is a cost you cannot feel.
it is not protectionA .pyc hides nothing. dis reads it straight back into named opcodes with your variable names attached — shipping bytecode is not shipping a secret.
you type
# ---------- your folder ----------
# beat.py    -- a library module, IMPORTED by player.py
# player.py  -- the program you launch

# ---------- player.py ----------
# player.py -- the program you launch
import beat

print("BPM is", beat.BPM)


# ---------- the terminal ----------
$ ls
$ python player.py
$ ls __pycache__
$ rm -rf __pycache__
$ python -B player.py
$ ls
you see
$ ls
beat.py
player.py

$ python player.py
beat.py: top level running
BPM is 128

$ ls __pycache__
beat.cpython-312.pyc        # beat was IMPORTED. player.py was RUN -- no .pyc.

$ rm -rf __pycache__
$ python -B player.py
beat.py: top level running
BPM is 128

$ ls
beat.py
player.py                   # -B wrote nothing
where beginners trip
  • The missing player.cpython-312.pyc is not a bug. The launched script is __main__, and __main__ is never cached.
  • An orphaned .pyc whose source has been deleted is not importable. Since Python 3.2 a __pycache__ entry alone can never satisfy an import.
  • Add __pycache__/ to .gitignore once. Committing it is harmless and useless, and it produces a merge conflict in a binary file eventually.
  • The top-level print fires on every run, cached or not. The .pyc spares you the compile; it never spares you the execution.
  • A stale .pyc is possible but rare, and it needs a clock going backwards — a restored old file, a skewed build machine. Section 3's hash mode exists for exactly that.
  • Deleting __pycache__ in the middle of a running process changes nothing. The module is already in sys.modules, and nothing re-reads disk.
One import statement, and the avalanche it drags in▸ drag me
one import statement → the transitive pile it really loads YOU TYPED import pandas ≈600 modules whose top-level code just ran cold import ≈ 350 ms you asked for a dataframe; the whole library wing arrived modules loaded · log scale · 1 → 2,000 1 10 100 1,000 One import, hundreds of modules’ top-level code executed — that is the cold-start tax. sys.modules makes the 2nd import a dict lookup; the .pyc spares the recompile — neither shrinks the pile.
import pandas
Slide from import sys to import tensorflow. The number is not files on sys.path — it is modules actually imported, each running its own top-level code exactly once and caching itself in sys.modules. Counts are typical CPython 3.12 order-of-magnitude figures; your exact totals vary by version and platform.
Fig — import X is rarely one file. A heavy library is the root of a dependency tree, and importing it executes the top-level code of every module in that tree — hundreds for pandas. The .pyc cache removes the recompile cost on later runs and sys.modules removes the re-import cost within a run, but the sheer count of modules is why a cold import pandas is felt and a cold import sys is not.

Python is compiled — just not to machine code. Every time a .py is imported, its source is compiled into a code object full of bytecode. (The one exception is the script you run directly as __main__; that one is never cached.) To avoid paying that compile cost on every future import, the loader marshals the code object to bytes and writes them to __pycache__/NAME.cpython-3XX.pyc. The whole trick lives in that file's header, so let us take it apart byte by byte. A modern .pyc begins with sixteen bytes. First comes a 4-byte magic number that encodes the exact CPython version and bytecode format. This is why the filename is tagged cpython-312, and why a 3.11 .pyc is silently ignored by 3.12. Next comes a 4-byte bit-field of flags. Then, in the default timestamp scheme, come the source's 4-byte modification time plus its 4-byte size.

a .pyc file — 16-byte header, then the compiled code objectmagic4 Bflags4 Bsource mtime4 Bsize4 Bmarshalled code object↑ compared to the .py's mtime — the stale-cache trip-wirefirst import — no fresh .pyc: run the whole pipelineread .pytokenizeparsecompilemarshalwrite .pycexec ✓second import — .pyc fresh: header check, then loadread .pyc headermagic ✓ · mtime==stat ✓tokenize · parse ·compile · write — SKIPPEDmarshal.loadexec ✓
Fig — A .pyc caches the compiled code object; when its magic number and stored source-mtime still match the file on disk, the second import skips tokenising, parsing and compiling and just un-marshals the bytecode.

Now the payoff. On the next import the loader reads only that header. It compares the magic number: mismatch means a different interpreter, so recompile. If the magic matches, in timestamp mode it compares the stored mtime and size against a fresh os.stat of the source. If they match, it concludes "the source is unchanged, trust the cache," skips tokenizing, parsing and compiling entirely, and simply marshal.loads the code object straight back into memory to execute. That is why the second import is fast: the expensive front of the pipeline is skipped, and only unmarshal-plus-exec remain. Decode the header yourself and the machine stops being mysterious:

read_pyc_header.pypython
import struct, importlib.util, os

with open("__pycache__/mymod.cpython-312.pyc", "rb") as f:
    magic = f.read(4)
    flags = struct.unpack("<I", f.read(4))[0]
    mtime = struct.unpack("<I", f.read(4))[0]
    size  = struct.unpack("<I", f.read(4))[0]

print(magic == importlib.util.MAGIC_NUMBER)      # True — this interpreter's format
print(mtime, int(os.stat("mymod.py").st_mtime))  # equal → cache is trusted

Be exact about the failure mode, because it bites real builds. Timestamp invalidation trusts the filesystem clock. Restore an older source whose mtime is now newer than the cache, or build on a machine with a skewed clock, and Python can serve you stale bytecode — running code that no longer matches the source you are reading. That is precisely why hash-based .pyc files exist (PEP 552). When the flags bit is set, those mtime and size fields are replaced by a truncated SipHash of the source, and the loader validates by content rather than by clock. You opt in with py_compile using CHECKED_HASH (rehash and compare every time) or UNCHECKED_HASH (trust the hash blindly, for reproducible builds where the toolchain guarantees freshness).

TYPE THIS · save it, then run itpycache_watch.py — three fresh processes, one edit, and the header field that decides recompile or reuse
# pycache_watch.py -- watch the .pyc be trusted, go stale, and be rebuilt
import importlib.util
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path

SRC = Path("beat.py")
PYC = Path(importlib.util.cache_from_source(str(SRC)))   # the exact cache path

SRC.write_text('print("beat.py: top level running")\n\nBPM = 128\n', encoding="utf-8")


def header_mtime():
    """Bytes 8-12 of the .pyc: the source mtime it claims it was built from."""
    magic, flags, mtime, size = struct.unpack("<4sIII", PYC.read_bytes()[:16])
    return mtime


def fresh_import():
    """A brand-new process, so sys.modules starts empty and import really runs."""
    done = subprocess.run([sys.executable, "-c", "import beat; print('BPM =', beat.BPM)"],
                          capture_output=True, text=True)
    for line in done.stdout.splitlines():
        print("        |", line)


shutil.rmtree(PYC.parent, ignore_errors=True)
print("cold    : .pyc exists?", PYC.exists())

print("run 1   : the first import ever")
fresh_import()
m1 = header_mtime()
print("          wrote", PYC.name)
print("          header mtime == source mtime :", m1 == int(SRC.stat().st_mtime))

print("run 2   : same source, brand-new process")
fresh_import()
m2 = header_mtime()
print("          header mtime changed?        :", m2 != m1, "-- COMPILE was skipped")

SRC.write_text(SRC.read_text(encoding="utf-8").replace("128", "174"), encoding="utf-8")
os.utime(SRC, (SRC.stat().st_atime + 2, SRC.stat().st_mtime + 2))
print("edit    : BPM 128 -> 174, saved")
print("          header mtime == source mtime :", m2 == int(SRC.stat().st_mtime), "-- STALE")

print("run 3   : after the edit")
fresh_import()
m3 = header_mtime()
print("          header mtime changed?        :", m3 != m2, "-- it recompiled")
print("          header mtime == source mtime :", m3 == int(SRC.stat().st_mtime))
$ python pycache_watch.py
cold    : .pyc exists? False
run 1   : the first import ever
        | beat.py: top level running
        | BPM = 128
          wrote beat.cpython-312.pyc
          header mtime == source mtime : True
run 2   : same source, brand-new process
        | beat.py: top level running
        | BPM = 128
          header mtime changed?        : False -- COMPILE was skipped
edit    : BPM 128 -> 174, saved
          header mtime == source mtime : False -- STALE
run 3   : after the edit
        | beat.py: top level running
        | BPM = 174
          header mtime changed?        : True -- it recompiled
          header mtime == source mtime : True
Save this one file and run it; it writes beat.py itself, so you can run it as many times as you like. Every import here happens in a separate process, launched by subprocess, because that is the only way to see this cache work. Within one process sys.modules would answer instantly and the .pyc would never be consulted at all. Read the three runs as one sentence. Run 1 finds no cache, compiles, and writes a header stamped with the source's modification time — and header mtime == source mtime is True. Run 2 is a brand-new process with an empty sys.modules, so it must find and execute beat.py again; the header is unchanged, which is the visible proof that compile() never ran. Then we edit and save. The stamp and the file disagree, the .pyc is stale, and run 3 rebuilds it — new stamp, matching again, and BPM = 174. Two details are worth your attention. beat.py: top level running appears three times, once per process: the cache spares you COMPILE and never EXEC. And the os.utime line nudges the modification time forward two seconds, because filesystem timestamps have one-second teeth. A save and a re-import inside the same clock second can leave the stamp looking unchanged — which is the whole honest reason hash-based .pyc files exist. Two things to try. Delete the os.utime line, run it again, and see whether run 3 still recompiles on your machine. Then insert a syntax error into beat.py before run 3 and notice which stage the traceback names.

✗ The myth

"__pycache__ is compiled, protected, or somehow important — deleting it might break my program or leak my source."

✓ The reality

A .pyc is a cache and nothing more — always safe to delete; Python simply regenerates it on the next import at the cost of one recompile. It is not encryption and not protection: the bytecode is trivially disassembled. And marshal is not pickle — it is an internal, version-specific format for code objects only, deliberately not portable across versions.

TURNING THE CACHE OFF
Set sys.dont_write_bytecode = True, or launch with python -B, and Python compiles in memory but never writes a .pyc. Import still works from a read-only directory too — it just recompiles every time rather than caching. The cache is an optimisation, never a requirement.

The cache stores a code object — the compiled unit the loader executes. But where does that object come from? Between the characters you type and the bytecode the VM runs sit three transformations, and the next section freeze-frames every one of them. Text → AST → code object →

04The compile pipeline: source → AST → code object

Volume 1 handed you a virtual machine that eats bytecode and pushes values on a stack. But you do not write bytecode — you write text. Somewhere between the characters your fingers produce and the instructions the VM consumes, three transformations happen. While they stay invisible, the VM feels disconnected from the language you actually speak. This section closes the loop. It shows the exact assembly line that turns total = a + b into the LOAD_FAST/BINARY_OP/STORE_FAST instructions your Volume 1 stack machine already knows how to run. And it hands you two tools, ast and dis, to stop the line at any station and stare.

⚠ MOST BEGINNERS THINK…you are looking for a build step that nobody ever runs
Fine — Python does compile. Volume 1 handed me the bytecode and section 3 just showed me where it is kept. So compiling is the build step: I press run, the interpreter sweeps the project, turns my files into bytecode, and only then starts executing. And that is the reassuring half, because it means if my program got as far as printing anything, every file in it compiled — a typo sitting in some module I have not reached yet cannot still be lying in wait for me.
TYPE THIS — 10 SECONDS
# save BOTH files to disk first, then run the second one

# ---------- halfgood.py ----------
print('the first line ran')
BPM = 128
print('and the third'

# ---------- main.py ----------
print("nothing has complained yet")   # the file with a hole in it is already on disk
import halfgood                       # ...and only NOW does it reach the compiler

$ python main.py
nothing has complained yet
Traceback (most recent call last):
  File "C:\tmp\jukebox\main.py", line 2, in <module>
    import halfgood
  File "C:\tmp\jukebox\halfgood.py", line 3
    print('and the third'
         ^
SyntaxError: '(' was never closed
Both halves of the belief die on the same line of output, and the setup is what makes that airtight: halfgood.py was sitting on disk before this process existed, so a sweep at launch would have had to walk straight into it. Instead the run's first act is nothing has complained yet arriving on your screen — the program printed, and a file in it had not compiled. There was no sweep, because Python compiles one file at a time, at the instant the import line for it runs — a module nobody imports is never compiled and never complains. Now read what the output does not contain, which is worth as much: the first line ran never printed, even though that file finally did reach the compiler. So within the one file that got there it is all-or-nothing — the whole text becomes a code object before its first instruction executes, which is exactly why compile() is a single call in this section rather than a loop over lines. One honest refinement, because a build step is available: python -m compileall . walks a tree and compiles every file ahead of time, and it would have caught halfgood.py this morning. Nothing runs it for you. Every import statement you write is a compile you have deferred until that line executes — and, once a matching .pyc exists, a compile you paid once and never pay again.

Station 1 — TOKENIZE. The tokenizer sweeps the raw source string into a stream of typed tokens: NAME, OP, NUMBER, NEWLINE, and — the detail that explains a whole category of beginner pain — INDENT and DEDENT. Python's significant indentation is not a special case buried in the parser. It is literal tokens the lexer synthesises when the leading whitespace changes. That is why mixing tabs and spaces is a tokenizer-level error, thrown before the grammar is ever consulted. Watch the stream directly:

tokenize_demo.pypython
import tokenize, io
src = "total = a + b\n"
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
    print(tokenize.tok_name[tok.type], repr(tok.string))
# NAME 'total' · OP '=' · NAME 'a' · OP '+' · NAME 'b' · NEWLINE '\n' · ENDMARKER ''

Station 2 — PARSE to an AST. Since 3.9 CPython uses a PEG parser that assembles those tokens into an Abstract Syntax Tree. That is a tree of typed nodes where precedence is baked into the shape and all formatting is gone. total = a + b becomes an Assign whose target is Name('total') and whose value is a BinOp(Name('a'), Add(), Name('b')). This is the same tree that decorators, linters, and this book's own tracer inspect — structure without a single stray space or comment:

ast_demo.pypython
import ast
print(ast.dump(ast.parse("total = a + b"), indent=2))
# Module(body=[
#   Assign(targets=[Name(id='total', ctx=Store())],
#          value=BinOp(left=Name(id='a'), op=Add(), right=Name(id='b')))])

Station 3 — COMPILE to a code object. The compiler walks the AST, builds a control-flow graph, and emits bytecode into a code object (types.CodeType). This is the real unit of executable Python — the thing a function has (func.__code__) and a module is. Its fields are the accumulated payload of everything upstream: co_code (the raw bytecode bytes), co_consts (literals and nested code objects), co_names (global and attribute names), co_varnames (locals), co_argcount, co_flags, and a line-number table mapping bytecode offsets back to source lines for tracebacks. Then dis.dis disassembles co_code into readable opcodes. And here the whole book snaps together, because these are the exact stack instructions from Volume 1:

disassemble.pypython
import dis
def f(a, b):
    total = a + b
    return total
dis.dis(f)
# LOAD_FAST    a       push local a onto the value stack        stack: [a]
# LOAD_FAST    b       push local b                             stack: [a, b]
# BINARY_OP    +       pop two, push their sum                  stack: [445]
# STORE_FAST   total   pop into the local `total`               stack: []
# LOAD_FAST    total   push it again to return                  stack: [445]
# RETURN_VALUE         pop and hand it back to the caller
1 · SOURCEtotal = a + b2 · TOKENSNAMEtotalOP=NAMEaOP+NAMEb3 · ASTAssignName 'total'BinOp (Add)aAddb4 · CODE OBJECTco_varnames = ('total','a','b')co_consts = ()co_code:LOAD_FAST aLOAD_FAST bBINARY_OP +STORE_FAST totalVolume-1 VM — a stack machine executes co_code (a=3, b=4)34← topLOAD_FAST a → push 3LOAD_FAST b → push 4BINARY_OP + → pop 4,3 · push 7STORE_FAST total → pop 7 → total=7
Fig — Compilation is an assembly line — source to tokens to AST to code object — and the code object's co_code is the exact opcode sequence the Volume-1 stack VM later runs.

And now the tie that binds the chapter: compile(src, name, 'exec') is literally stations 1 through 3 in a single call, returning the code object. Import's compile step is this pipeline. The .pyc from the last section caches its station-3 output, and the loader from section two runs that output into a module's __dict__. One unbroken arc: text → tokens → AST → code object → (marshalled into a .pyc) → executed by the VM. compile even hands you a code object you can eval directly:

the whole pipeline in one callpython
code = compile("a + b", "<demo>", "eval")   # tokenize + parse + compile
print(code.co_names)                       # ('a', 'b')
print(eval(code, {"a": 200, "b": 245}))    # 445 — Titanium meets Blinding Lights again
THE ONE IDEA TO CARRY FORWARD
There is a compiler in the room. Text → tokens → AST → code object → bytecode, and the bytecode is the very stack machine you built in Volume 1. ast freezes the middle stage, dis freezes the last, and compile runs all three — the language you type and the machine that runs it are the same object seen at different stations.

You now know how one file becomes a module. But a real codebase splits one logical package across many folders — and the rule you were taught, "a package needs an __init__.py," turns out to be only half true. Namespace packages →

05Namespace packages: folders without __init__.py

Someone taught you a firm rule: a package needs an __init__.py. Then you clone a large monorepo where import company.billing and import company.auth resolve to two completely different directories. And there is no __init__.py in sight, yet every import succeeds. Either the rule you learned is wrong, or something deeper is running underneath it. Something deeper is running underneath it, and it is exactly how big organisations split one logical package across many folders, repositories, and separately installed distributions.

There are two kinds of package, and the difference is one file. A regular package is a directory containing __init__.py. Importing it runs that file and sets the package's __path__ to a single-element list — the one directory it came from. A namespace package (PEP 420, since 3.3) is what you get when the import system finds directories matching the name across sys.path but none of them contains __init__.py. Here is the precise algorithm PathFinder runs while scanning for company. If an entry has company/__init__.py, that is a regular package — stop. If an entry has a plain directory company/ with no __init__.py, record that directory as a portion and keep scanning the rest of sys.path, collecting every matching directory. If the scan ends without ever finding a regular package or a company.py module, import synthesises a namespace package. Its __path__ is not a plain list but a live _NamespacePath object holding all the collected portions.

one name, two directoriestext
dirA/company/billing.py      # no __init__.py anywhere under company/
dirB/company/auth.py         # a second, separate portion of the same name
the merged path is the magicpython
>>> import sys
>>> sys.path[:0] = ['dirA', 'dirB']
>>> import company.billing, company.auth   # both succeed
>>> company.__file__                        # None — no single file backs it
>>> type(company.__path__)
<class '_frozen_importlib_external._NamespacePath'>
>>> list(company.__path__)
['dirA/company', 'dirB/company']                 # both portions, merged

That merged __path__ is the entire mechanism. import company from dirA and import company.auth where auth lives in dirB both work, because company.__path__ spans both directories, and submodule search walks every portion. Contrast the two sharply. A regular package is one directory that runs __init__.py — a real place to put initialisation code, an __all__, side effects, a version string. A namespace package is zero-or-many directories, runs no code, has __file__ set to None, and carries a live multi-location __path__ that is recomputed lazily as sys.path changes.

namespace package — no __init__.py, fused from many folderscompany__path__ = [ dirA/company , dirB/company ](no __init__.py in either portion)__path__ portions ↓dirA/ (sys.path[0])company/— no __init__.pybilling/dirB/ (sys.path[3])company/— no __init__.pyauth/regular package — contrastshop/__init__.pycart.pyorders.py__path__ = ['…/shop']one folder · one portionsubmodule search (import company.billing) walks EVERY portion in __path__
Fig — A namespace package has no __init__.py: Python fuses same-named folders found on several sys.path entries into one module whose __path__ lists every contributing portion.

The design tradeoff is worth naming, because it is why the feature exists at all. Namespace packages let separately installed distributions contribute subpackages under one shared top-level name. The classic case is pip install azure-storage and pip install azure-identity landing in different site-packages folders, yet both importable as azure.storage and azure.identity. You gain that composability. You lose a place to put package-init code. And you inherit a subtler hazard. An accidental empty folder named like a package cannot beat a real installed one — a regular package with __init__.py still wins the scan. What the stray folder does is worse in a quieter way: when the real package is missing (a typo'd install, a broken checkout), the import that should fail loudly now "succeeds" as an empty namespace package. The tell is always the same pair of fields:

how to tell them apart at runtimepython
>>> import json                 # a regular package
>>> json.__file__                # '.../Lib/json/__init__.py' — a real file
>>> json.__path__                # ['.../Lib/json'] — a plain list, one entry
>>> # namespace package: __file__ is None, type(__path__) is _NamespacePath
AN EMPTY FOLDER CAN ANSWER FOR A MISSING PACKAGE
If import mypkg works but fails deep inside with a missing submodule, check mypkg.__file__. If it is None, you imported a namespace package — and if you expected a regular one, a stray directory named mypkg/ on sys.path is answering for a package that is not actually installed. The loud ModuleNotFoundError you should have seen was swallowed. Install the real package, or delete the impostor folder.

You have now seen finders on a list, a spec protocol, and a default filesystem implementation with two flavours of package. The obvious, powerful question remains: if finders are just objects on a list — can you add one? Import hooks →

06Import hooks: why the protocol is pluggable

You know the shape now: import is find_spec → module_from_spec → exec_module, and finders live on a plain Python list. Which raises the question this whole chapter has been walking toward — if it is just objects on a list, can I add my own? Yes. And that yes is the entire difference between a language feature you use and a mechanism you own. Import hooks are how tools import notebooks as modules, how packages get lazily loaded, how encrypted or remote code gets fetched and run. And, more to the point, they are how you would build any of those yourself, in pure Python, at runtime.

There are two extension points, and choosing between them is choosing your blast radius. Meta path hooks are the sledgehammer. Put a finder object on sys.meta_path, and because that list is consulted first and for every single import, your finder can intercept any name from any source — a database row, a URL, an in-memory dictionary, a decrypted blob. It needs one method, find_spec(name, path, target), returning a ModuleSpec or None to politely decline. Here is the minimal shape: a finder that serves modules whose source is just a string in memory, with no file behind them at all:

memory_importer.pypython
import sys, importlib.util, importlib.abc

SOURCES = {"greet": "def hello():\n    return 'hi from memory'\n"}

class DictLoader(importlib.abc.Loader):
    def __init__(self, src): self.src = src
    def create_module(self, spec): return None          # use the default empty module
    def exec_module(self, module):
        code = compile(self.src, module.__name__, "exec")  # the section-4 pipeline!
        exec(code, module.__dict__)                     # run into the module namespace

class DictFinder(importlib.abc.MetaPathFinder):
    def find_spec(self, name, path, target=None):
        if name in SOURCES:
            return importlib.util.spec_from_loader(name, DictLoader(SOURCES[name]))
        return None                                    # decline → the next finder is asked

sys.meta_path.insert(0, DictFinder())
import greet                                        # no file on disk exists
print(greet.hello())                                 # hi from memory

Notice what you just did. Import fetched source from a Python dict, ran it through compile — the exact pipeline from section four — and executed the resulting code object into a module's __dict__, the exact load phase from section two. Every piece of this chapter reappears, now under your control.

sys.modules— cache · hit? return it, donechecked firstmisssys.meta_path — consulted first, on every importCustomFinderyours ▸ tried firstBuiltinImporterFrozenImporterPathFinderexpands ↓sys.path_importer_cache (memo)'/proj' → FileFinder'app.zip' → zipimporter'https://…' → YourFindersys.path_hooks — per sys.path entryFileFinderzipimporteryourhookCustomFinder emits ▾ModuleSpecname = 'mod'loader = URLLoaderloaderexec_module()runs the fetched sourceremoteGET …/mod.pysource →
Fig — The whole import machinery top to bottom: sys.modules, then the sys.meta_path finders (one of them PathFinder, delegating to per-entry sys.path_hooks) — any of which can be your own finder that loads a module straight from a URL.
NOW WRITE IT YOURSELFread the traceback, name the stage, then fix a shadow in one command
First, reproduce it exactly. Make a folder report/ holding two files. json.py is yours, from months ago, and contains one function: as_rows(pairs) returning [{"name": k, "n": v} for k, v in pairs]. save_report.py is today's work: it does import json, builds ROWS = [{"track": "Titanium", "plays": 12}], opens report.json for writing with encoding="utf-8", calls json.dump(ROWS, f), and prints a confirmation. Run it and copy the traceback down. Second, answer three questions before you touch a single file. Which of the four import stages produced this — find, compile, exec, or cache? Why is it an AttributeError and not a ModuleNotFoundError, given that the module you wanted was never loaded? And which single line, run in the same folder, proves the diagnosis rather than suggesting it? Write those three answers out; the fix is worthless if you cannot name the cause. Third, fix it, then check the corner nobody checks. Rename your file, re-run, and confirm report.json is written. Then list __pycache__ — the compiled copy of your old json.py is still sitting there. Decide, with a reason, whether it can still be imported, and prove your answer with importlib.util.find_spec. One hint and no more: sys.stdlib_module_names would have stopped this on the day you named the file.
show the solution
# ---------- report/json.py  (YOURS -- the shadow) ----------
# json.py -- a helper you wrote last week and forgot about
def as_rows(pairs):
    return [{"name": k, "n": v} for k, v in pairs]


# ---------- report/save_report.py ----------
# save_report.py -- write the play counts to disk
import json

ROWS = [{"track": "Titanium", "plays": 12}]

with open("report.json", "w", encoding="utf-8") as f:
    json.dump(ROWS, f)

print("wrote report.json")


# ------------- $ python save_report.py -------------
Traceback (most recent call last):
  File "C:\tmp\report\save_report.py", line 7, in <module>
    json.dump(ROWS, f)
    ^^^^^^^^^
AttributeError: module 'json' has no attribute 'dump'


# The three answers.
#
# 1. FIND. Stage one walked sys.path, and sys.path[0] -- your own folder --
#    answered "yes, I have json" before the standard library was ever asked.
#
# 2. Because the import SUCCEEDED. Nothing was missing, so nothing raised at
#    import time. Python found a module named json, ran it, and bound it. The
#    failure came later, on attribute lookup, when the module it handed you
#    turned out to have as_rows and no dump. ModuleNotFoundError means "no file
#    answered"; AttributeError here means "the wrong file answered".
#
# 3. One line, and it ends the argument:


# ------------- $ python -c "import json; print(json.__file__)" -------------
C:\tmp\report\json.py


# ------------- the fix: rename YOUR file -------------
$ mv json.py rows.py                  # ren json.py rows.py on Windows

$ python save_report.py
wrote report.json

$ cat report.json
[{"track": "Titanium", "plays": 12}]

$ python -c "import os, json; print('/'.join(json.__file__.replace(os.sep,'/').split('/')[-2:]))"
json/__init__.py                      # the real one, at last


# ------------- the corner nobody checks -------------
$ ls __pycache__
json.cpython-312.pyc                  # your old file's bytecode, still there

# Can it still be imported? No -- and the proof is one call:

$ python -c "import importlib.util as u, os; print(os.path.basename(u.find_spec('json').origin))"
__init__.py

# Since Python 3.2 a .pyc inside __pycache__ is only ever consulted as the cache
# of a source file sitting beside it. Delete the .py and the .pyc is inert: the
# finder is looking for json.py, not for json.cpython-312.pyc. So the leftover is
# harmless clutter -- delete the folder if it bothers you, and nothing changes.


# The one honest sentence to take away:
#   sys.path[0] is your folder, so naming a file is choosing which import wins.
#   Check the name against sys.stdlib_module_names BEFORE you save, not after.

The second extension point, path hooks, is the scalpel. Put a callable on sys.path_hooks; each hook is handed a sys.path entry string and either returns a path entry finder for it or raises ImportError to decline. This only affects things reachable through sys.path entries — which is precisely how zipimport works. The zipimporter is a path hook that claims .zip entries, and the sys.path_importer_cache memoises the mapping so the archive is opened once. That is the full explanation of a trick that has always looked like sorcery:

importing out of a zippython
>>> import sys
>>> sys.path.insert(0, "libs.zip")   # a path to an ARCHIVE, not a folder
>>> import packed_module            # read straight out of the zip
>>> sys.path_importer_cache["libs.zip"]
<zipimporter object "libs.zip">      # the path hook that claimed it

Hold the precedence in your head as one ordered pipeline. The sys.modules cache comes first. Then come the sys.meta_path finders in order — yours can go at the front to override, or at the back to be a fallback. PathFinder is itself just the last meta path finder, and it fans out to sys.path_hooks per entry. For correctness a well-behaved loader sets module.__spec__, __name__, __loader__, and __file__ (or leaves __file__ unset for non-file sources), and executes into module.__dict__ via exec_module. Meanwhile the machinery, not your loader, handles inserting into sys.modules and unwinding on error.

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "C:\tmp\mono\app.py", line 1, in <module>
    from company.auth import login
ModuleNotFoundError: No module named 'company.auth'; 'company' is not a package
Read the clause after the semicolon first, because it names the half that actually failed. from company.auth import login is not one import, it is two: import company, then look for auth inside it — and the first one succeeded. Section 1's walk reached sys.path[0], your project folder, found a company.py that somebody added last quarter for one report, and first match wins, so it stopped. Now import needs to look inside that result, and a module has no inside: __path__ — the list of portions from section 5 — exists only on packages, and importlib.util.find_spec('company').submodule_search_locations comes back None. That None is this message. What makes it sting is that the real company/ directory is sitting right beside the file and the walk never weighed it, because within a single sys.path entry a directory is only checked ahead of a same-named .py when it holds an __init__.py. Put one in and the package wins outright; leave it out and the directory is merely a namespace portion, which a sibling company.py outranks. Same folder, same two names, and one empty file decides which of them import can see.
read find_spec('company').origin before you read anything else — if it names a .py, rename that file; the parent of a dotted import has to be a directory
Traceback (most recent call last):
  File "C:\tmp\shop\checkout.py", line 1, in <module>
    import cart
  File "C:\tmp\shop\cart.py", line 2, in <module>
    import pricing
  File "C:\tmp\shop\pricing.py", line 4, in <module>
    RATE = cart.TAX
           ^^^^^^^^
AttributeError: partially initialized module 'cart' has no attribute 'TAX' (most likely due to a circular import)
This is section 2's quietest line seen from the inside — the module goes into sys.modules before a single statement of it runs — and the traceback shows you both what that buys and what it costs. Read the frames downward: checkout asks for cart; cart, two lines into its own body, asks for pricing; pricing, four lines into its body, reaches back for cart.TAX. That second request for cart did not recurse forever, and that is the payoff: the cache already held a cart object, so import handed it straight over. But cart is still suspended on its line 2, and TAX is defined on its line 4. You were given the real module, correctly bound, genuinely missing the attribute. Two words are worth reading slowly. Partially initialized is the interpreter stating the exact truth rather than guessing — not a missing module, not a typo, a module caught mid-exec. And AttributeError, not ImportError: nothing failed to import here. The import system did everything right; the two files simply need each other's finished state at a moment when neither one has it.
move the import inside the function that needs the value — an import cart in the body of a with_tax() in pricing.py runs long after both module bodies have finished; or lift the shared constant into a third module that imports nobody
Traceback (most recent call last):
  File "C:\tmp\pycbox\player.py", line 2, in <module>
    import beat
ImportError: bad magic number in 'beat': b'\xa7\r\r\n'
Four bytes out of section 3's sixteen-byte header, quoted straight back at you. b'\xa7\r\r\n' is the magic CPython 3.11 stamps on everything it compiles; this interpreter is 3.12, whose magic is b'\xcb\r\r\n', and the loader compares those four bytes before it reads a single one beyond them. (To reproduce it in one line we wrote 3.11's magic onto a 3.12 file; a real build does it by copying .pyc files between images.) Now the part worth keeping, because it refines a rule section 3 states exactly. A .pyc living in __pycache__ can never satisfy an import on its own — and with the source still present, a wrong magic raises nothing at all: the loader silently recompiles and overwrites the stale file, which is the whole point of the version tag in the filename. Getting this traceback takes the older, flatter layout: a bare beat.pyc sitting beside your code with no beat.py anywhere. In that legacy position a SourcelessFileLoader claims it and the .pyc has to answer for itself, with no source to fall back on. So the message is not really about corruption. It is bytecode telling you it was never portable, and that the only reason you normally never hear about it is that a .py is usually standing right behind it.
never ship or copy .pyc files between interpreters — delete the stray one, keep the .py, and let this Python write its own into __pycache__
NOW WRITE IT YOURSELFone import line, two commands, two outcomes — explain it with sys.path, then fix it three ways
First, build the split. Make studio/ with three files. metrics.py at the top level defines mean(values) returning sum(values) / len(values). run_report.py, also at the top level, does import metrics and prints the mean of [200, 245, 320]. Then notebooks/explore.py, one folder down, contains the identical two lines — and before them, a probe: print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)"). No __init__.py anywhere. Second, run three commands from inside studio/ and predict each before you press Enter. Try python run_report.py, then python notebooks/explore.py, then python -m notebooks.explore. One of the three fails. Explain, in one sentence that mentions no folder you did not print, exactly why the same import metrics line succeeds under one command and raises under another — and say why running cd notebooks first changes nothing. Third, fix it three ways and rank them. Fix A: change nothing in the code and use the launch form that already works. Fix B: append the project root to sys.path from inside explore.py, anchored to __file__ rather than to the working directory. Fix C: append the bare string ".." instead, then run the file twice — once from inside notebooks/, once from studio/ — and watch it work and then die. Say which of the three you would ship, and why. One hint and no more: the third command works even though notebooks/ has no __init__.py, and section 5 explains that.
show the solution
# ---------- studio/metrics.py ----------
# metrics.py -- the shared maths
def mean(values):
    return sum(values) / len(values)


# ---------- studio/run_report.py ----------
# run_report.py -- lives beside metrics.py
import metrics

print("mean:", metrics.mean([200, 245, 320]))


# ---------- studio/notebooks/explore.py ----------
# notebooks/explore.py -- the SAME import line, one folder down
import os
import sys

print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)")

import metrics

print("mean        :", metrics.mean([200, 245, 320]))


# ------------- all three, run from inside studio/ -------------
$ python run_report.py
mean: 255.0

$ python notebooks/explore.py
sys.path[0] : notebooks
Traceback (most recent call last):
  File "C:\tmp\studio\notebooks\explore.py", line 7, in <module>
    import metrics
ModuleNotFoundError: No module named 'metrics'

$ python -m notebooks.explore
sys.path[0] : studio
mean        : 255.0


# The explanation, in one sentence:
#   sys.path[0] is the folder of the SCRIPT you launched -- notebooks -- and
#   metrics.py is not in notebooks, so the search never reaches it.
#
# Which is also why `cd notebooks` first changes nothing: the script form does
# not care where you are standing, only where the file is. Only -m is different,
# because -m puts the CURRENT DIRECTORY at index 0 instead -- studio, where
# metrics.py actually lives. Same file, same import line, different first entry.
#
# And -m works with no __init__.py because notebooks/ resolves as a namespace
# package (section 5) -- enough to be imported through, which is all -m needs.


# ------------- fix B: anchored to the FILE -------------
# in notebooks/explore.py, above `import metrics`:
from pathlib import Path

sys.path.append(str(Path(__file__).resolve().parent.parent))   # studio/

$ python notebooks/explore.py                    # run from studio
sys.path[0] : notebooks
mean        : 255.0
$ cd .. && python studio/notebooks/explore.py   # run from one folder up
sys.path[0] : notebooks
mean        : 255.0                              # still fine: anchored to __file__


# ------------- fix C: anchored to nothing -------------
# ---------- notebooks/explore.py, fix C ----------
# notebooks/explore.py -- the SAME import line, one folder down
import os
import sys

print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)")

sys.path.append("..")            # relative to the WORKING DIRECTORY
import metrics

print("mean        :", metrics.mean([200, 245, 320]))


$ cd notebooks
$ python explore.py
sys.path[0] : notebooks
mean        : 255.0                        # works: ".." happens to BE studio

$ cd ..
$ python notebooks/explore.py
sys.path[0] : notebooks
Traceback (most recent call last):
  File "C:\tmp\studio\notebooks\explore.py", line 8, in <module>
    import metrics
ModuleNotFoundError: No module named 'metrics'

# Same file. Same line. The only thing that changed is where you were standing
# when you pressed Enter -- because ".." is resolved against the CWD, and the
# CWD is not a property of your project.


# Ranked, with reasons:
#   A (python -m from the project root) -- ship this. Zero code, and it is the
#     same command your test runner and your CI already use.
#   B (append Path(__file__)...) -- acceptable when a file must be runnable on
#     its own. It survives a cd, but it hides a real layout problem in a
#     script that now edits interpreter state before it does any work.
#   C (append "..") -- never. It depends on where the human was standing, which
#     is the one thing you cannot put in a repository.
#
# The one honest sentence to take away:
#   "It works on my machine" is usually "sys.path[0] was different on my machine."
A HOOK SEES EVERY IMPORT — SO IT IS AN ATTACK SURFACE
Because a meta path finder is consulted for every import and can return arbitrary code, tampering with sys.meta_path is a genuine supply-chain risk. The same power that lets you import from a URL lets a malicious dependency intercept import requests and hand you something else entirely. Extensibility and trust are the same lever pulled in opposite directions.
THE ONE IDEA TO CARRY FORWARD
Import looked like a keyword; it is a documented protocol with a default filesystem implementation you can inspect, extend, or replace — no C required. A finder answers where-and-how, a loader creates-and-execs, and both are objects on lists you are free to edit. From the file descriptor to the pickle pipe, this whole volume is machines that rebuild live objects from bytes — and import, it turns out, is one of them, visible all the way from the metal up.

You can now see, extend, and even hijack the machine that turns disk bytes into live modules. Next: once a module is live, what happens when your program needs to send its objects to another process — where nothing but bytes can cross? Serialization and the pickle pipe →

SAY IT BACKthe chapter in five breaths
  1. Import is not a search of your computer, it is a deterministic walk down a list you can print: sys.modules first, then sys.path top to bottom, and the first directory that can produce the name wins — which is the entire reason a random.py of yours, sitting at index 0 because that is where your script lives, beats a standard library that never even gets asked.
  2. sys.modules is an ordinary dict, so the second import json is one lookup and no work at all — there is one module object per name per process, its top-level code runs exactly once no matter how many files ask for it, and everyone who imported it holds a reference to that same object, which is why evicting the key with del sys.modules[name] is what forces the next import to find, compile and exec again — importlib.reload being the surgical alternative, which re-runs the search but re-execs into the same object.
  3. Behind the keyword sit three ordinary calls — find_specmodule_from_specexec_module: a finder answers where and how by filling in a ModuleSpec, a loader answers create and exec, and the still-empty module is registered in the cache before one line of it runs — which is precisely what turns a circular import from an infinite loop into a half-built module you can see in a traceback.
  4. Compiling is a stage, not a build step: text → tokens → AST → code object, performed per file at the moment that file's import line executes, and the .pyc caches only that last output behind a sixteen-byte header — magic, flags, and then either a source timestamp the clock can lie about or a hash of the source that it cannot.
  5. Every part of the machine is an object on a list you are allowed to editsys.path, sys.path_hooks, sys.meta_path — which is why one logical package can span two separately installed folders with no __init__.py between them, why a .zip on the path simply works, and why a finder of your own can hand back a module whose source never touched a disk.
You already owned the pieces: chapter 0 gave you the stack machine that this chapter's assembly line has been feeding all along, so LOAD_FAST at the end of compile() is a homecoming rather than a new fact; chapter 2 gave you "everything is an object", which is why a module turns out to be one more object with a __dict__ that exec_module simply fills; chapter 3 gave you the binding, so sys.modules['beat'] is beat reads as a fact about names rather than a coincidence; chapter 7 gave you the hash table that the cache lookup literally is, which is why a repeat import costs what a dict costs; chapter 13 gave you the except ImportError that turns an optional dependency into a fallback instead of a stopped program; chapter 15 gave you the read that copies bytes across the ring boundary, which is all a .pyc is until marshal touches it; chapter 18 gave you this chapter's only real claim in its first form — what crosses into your process is bytes, and something on the near side has to rebuild the live thing; and chapter 19 gave you the four verbs, find, compile, exec, cache, named honestly and still half-trusted. Chapter 20 added no fifth verb. What it added is that every one of the four is a call you can make yourself, on a list you can print — so the next time an import does something you did not expect, you are not guessing at a keyword's mood. You are reading a machine, and it will tell you.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 20, in working code

Chapter 20 pops the hood on the import machine and watches every stage run: the search down sys.path, the sys.modules cache that runs a module body exactly once, the ModuleSpec receipt with its finder and loader, the 16-byte .pyc header, and the tokenize -> ast -> compile pipeline that turns your source text into a runnable code object. Every program writes its own files into a scratch directory and prints only facts you can predict, so the output is the same on every run.

The search and the cache — sys.path and sys.modules
Import is FIND then CACHE. These three show that the path order decides which file wins, and the cache guarantees a single module object whose body runs exactly once.
The spec machinery — finders, loaders, ModuleSpec
Behind every import is a spec (the receipt), a finder (locates it), and a loader (runs it). Here we read a real spec, redo import by hand in three calls, and finally serve a module straight from a Python string with no file at all.
The compiled cache — inside a .pyc
A .pyc is a 16-byte header in front of marshalled bytecode. We watch import write one, then decode magic / flags / mtime by hand, and contrast timestamp validation against hash validation.
Source to code object — tokenize, ast, compile
Before a line runs, text becomes tokens, tokens become a syntax tree, and the tree compiles to a code object. Each stage is an ordinary Python object you can read — and even rewrite.
end of chapter 20 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked