Modules and Import System

Why Python has no import tariffs

Author

Karsten Naert

Published

October 20, 2026

Introduction: The Import Landscape

Python’s import system is surprisingly complex. Part of the confusion stems from three distinct concepts that often get mixed up:

  1. File structure - How Python code is organized on your hard drive
  2. Runtime objects - The module objects that exist during a running Python session
  3. Distributions - Bundled Python code shared with others (e.g., via PyPI)

The term “package” adds to the confusion because it’s used both colloquially (as a synonym for “distribution”) and technically (as a specific type of module). We’ll clarify this as we go.

Today we focus on (1) and (2): understanding how Python transforms files on disk into module objects at runtime.

Imports: The Quick Version

Let’s start with the practical side - how to use imports in everyday code.

Basic syntax and behavior

An import statement creates a module object:

import datetime
datetime
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>
type(datetime)
module

Think of an import as a two-step operation:

  1. Execute the module’s code and create a module object
  2. Assign that object to a variable

Python uses __import__() internally to perform step 1. You can see this separation yourself:

sys = __import__('sys')
sys
<module 'sys' (built-in)>
Warning

Don’t use __import__() in your own code - it’s a low-level function meant for the import machinery itself. We’re only using it here to illustrate the concept.

Import variations

Python offers several import syntaxes:

Aliases with as:

import sys as system
import datetime as dt

system, dt
(<module 'sys' (built-in)>,
 <module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>)

Selective imports with from:

from datetime import datetime as d1, date as d2

d1, d2
(datetime.datetime, datetime.date)

Dotted imports create nested module structures:

import json.tool

json.tool
<module 'json.tool' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/tool.py'>

The module object json now has an attribute tool which is itself a module:

json
<module 'json' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/__init__.py'>

You can combine from with dotted paths:

from json.tool import main as json_main

json_main
<function json.tool.main()>
Note

import x.y always imports x first, then y. You can’t skip importing x to save time.

Critical: Modules are cached

A scratch folder for the rest of this lecture

From here on we create a lot of small modules and packages. Rather than scattering them through the folder that holds these notes, everything happens in a throwaway directory. We change into it so that plain filenames land there, and we put it on sys.path so that Python can import from it — which is itself a preview of what the second half of this lecture is about. At the very end of the lecture we delete it again.

As you follow along you would simply make these files yourself, in a folder of your own.

import os
import shutil
import sys
import tempfile
from pathlib import Path

original_cwd = Path.cwd()
scratch = Path(tempfile.mkdtemp(prefix='import-demo-'))

os.chdir(scratch)                   # relative filenames now land here
sys.path.insert(0, str(scratch))    # ... and Python can import from here

scratch
PosixPath('/tmp/import-demo-kpfbg5wy')

Here’s a crucial behavior you’ll encounter immediately: modules are only executed once.

import demo_module
Module is being imported!

Import it again:

import demo_module

The print statement only ran once! That’s because Python caches modules in sys.modules:

import sys
'demo_module' in sys.modules
True

This means:

  • Imports are fast after the first time
  • Side effects (like print statements) only happen once
  • All parts of your program see the same module object

We’ll explore sys.modules in detail shortly, but remember: import once, use everywhere.

Import style guide

As your codebase grows, you’ll accumulate many imports. Here’s the conventional ordering:

  1. Special imports from __future__ (see the note below)
  2. Standard library imports (Python’s built-in modules)
  3. Third-party imports (installed packages)
  4. Local imports (your own code)

Within each category, sort alphabetically.

Example:

from __future__ import annotations

from dataclasses import dataclass
from functools import cached_property, partial
from typing import Callable

import flet as ft

from data_connector import DataConnector
from containerize import ft_col, ft_row, ft_tab

Tools like ruff can automatically organize your imports.

About that from __future__ import annotations

You will meet this line at the top of a great deal of existing Python, so it is worth knowing what it does. The __future__ module is how Python ships a change before making it the default: importing from it switches the new behaviour on for that one file.

This particular one comes from PEP 563. It stops Python from evaluating type annotations when a function or class is defined, storing them as plain strings instead. People used it for two reasons: to refer to a class inside its own definition without quoting it, and to avoid paying the runtime cost of imports that only annotations needed.

It was originally scheduled to become the default in Python 3.10. That was postponed, and then cancelled outright. Instead, Python 3.14 solved the same problem a better way (PEP 649 and PEP 749): annotations are now evaluated lazily — not at definition time, but only if something actually asks for them. You get the benefits without the annotations being reduced to strings.

So: in new code targeting Python 3.14 or later you almost certainly don’t need this line. It is not removed and not formally deprecated — the documentation says it will never become mandatory, and will eventually be deprecated and removed — but it has no future. Recognise it in old code; don’t reach for it in new code.

Module Objects Deep Dive

Now let’s understand what modules actually are at runtime.

Modules as runtime objects

Modules are objects, just like functions, classes, or lists. Most objects in Python are created with syntax like x = ..., but some have special syntax:

  • for x in L: defines x
  • def f(): defines f
  • class K: defines K
  • import os defines os

The import statement creates a module object:

import os
print(type(os))
<class 'module'>

If you need the module type itself:

import types
types.ModuleType
module

Like any object, modules have attributes - these are the functions, classes, and variables the module provides:

os.getcwd
<function posix.getcwd()>
os.getcwd()
'/tmp/import-demo-kpfbg5wy'

You can also access attributes with getattr():

getattr(os, 'getcwd')
<function posix.getcwd()>
Tip

Modules play an important role when considering how to structure code in Python. They form a level of structure above the classes, and effectively play the role of a singleton class with static methods. This can be useful for instance to store a configuration that is unique throughout the program. For programmers used to other languages it’s important to suppress the urge to wrap things in a class when a module would be more appropriate.

The __name__ attribute and __main__

Every module has a __name__ attribute containing its name:

import pandas as pd
os.__name__, pd.__name__
('os', 'pandas')

But when you run a Python file directly (e.g., python script.py), that file gets a special name:

__name__
'__main__'

This leads to one of Python’s most common idioms:

def main():
    print("Running the script!")

if __name__ == '__main__':
    main()

This pattern lets you write code that:

  • Can be imported as a library (the if block is skipped)
  • Can be run as a script (the if block executes)

When imported:

import runnable_module
runnable_module.add(5, 7)
12
Tip

Always use if __name__ == '__main__': for code you want to run only when the file is executed directly, not when imported.

Running a module with python -m

There are two ways to run Python code from the command line, and the difference matters:

python path/to/script.py     # run a FILE
python -m some_module        # run a MODULE, found via sys.path

With -m, Python looks the name up through the import system instead of treating it as a path. You have almost certainly used this already:

python -m pip install requests
python -m http.server 8000
python -m json.tool data.json

The practical differences:

  • python script.py puts the script’s own folder at the front of sys.path. python -m puts the current working directory there instead. This is the usual reason a program runs one way and not the other.
  • -m finds the module wherever it is installed, so it works no matter which folder you are standing in.
  • With -m, using python -m pip rather than plain pip guarantees you get the pip belonging to that interpreter — a genuinely useful habit when several Pythons are installed.

In both cases the module being run is given the name __main__, which is exactly why the if __name__ == '__main__': idiom works for either.

__main__.py: making a package runnable

A package can be run with -m too, if it contains a file called __main__.py. That file is what gets executed:

os.makedirs('runnable_package', exist_ok=True)

with open('runnable_package/__init__.py', 'w') as f:
    f.write('VERSION = "1.0"')

with open('runnable_package/__main__.py', 'w') as f:
    f.write('''
from runnable_package import VERSION

print(f"Running runnable_package version {VERSION}")
''')

Now python -m runnable_package runs that file. Let’s actually do it, by launching a second Python process:

import subprocess

result = subprocess.run(
    [sys.executable, '-m', 'runnable_package'],
    capture_output=True,
    text=True,
    cwd=scratch,
)
print(result.stdout, end='')
Running runnable_package version 1.0

This is how tools ship a command line entry point that works before anything is installed, and it is the mechanism behind python -m http.server.

Types of modules

Not all modules are created equal. Let’s examine a few:

import os
os
<module 'os' (frozen)>
import sys
sys
<module 'sys' (built-in)>
import json
json
<module 'json' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/__init__.py'>
import datetime
datetime
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>

Notice the differences in their representations! Python has several types of modules:

Built-in modules like sys are compiled into Python itself:

hasattr(sys, '__file__')
False

Frozen modules like os are also compiled in, but differently:

hasattr(os, '__file__')
True

File-based modules like datetime come from .py files:

datetime.__file__
'/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'

Packages are modules with a __path__ attribute:

json.__path__
['/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json']

According to the official documentation:

All packages are modules, but not all modules are packages. Packages are a special kind of module - specifically, any module that contains a __path__ attribute.

The hierarchy:

  • Built-in modules (e.g., sys)
  • Frozen modules (e.g., os)
  • Regular modules
    • File-based modules (e.g., datetime)
    • Packages (modules with __path__)
      • Regular packages (with __init__.py)
      • Namespace packages (advanced topic)

The module cache: sys.modules

All imported modules live in sys.modules:

import sys
len(sys.modules)
1351

This is why repeated imports are fast - Python just returns the cached module:

sys.modules['datetime']
<module 'datetime' from '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/datetime.py'>

The cache ensures consistency: every part of your program sees the same module object, with the same state.

Creating Your Own Modules

Creating modules is straightforward, though different from creating functions or classes because the code goes in a separate file.

From files: The basics

Let’s create a simple module. First, we need to create the file:

code = '''
print("Hello from my_module!")
print(f"My name is '{__name__}'")

MAGIC_NUMBER = 42

def double(x):
    return 2 * x

class Calculator:
    def add(self, a, b):
        return a + b

import os
'''

with open('my_module.py', 'w') as f:
    f.write(code)
Warning

Quarto-specific note: Because I want this documentation to render when I build the Quarto document, I use Python itself to write these files, into the scratch folder we set up earlier. As you are following along, it would make more sense to create these files yourself manually, in a folder of your own.

Now import it:

import my_module
Hello from my_module!
My name is 'my_module'

The code executed immediately! This includes the print statements. This is a security consideration: importing untrusted code is inherently risky because arbitrary code runs.

All definitions in the module become attributes:

my_module.MAGIC_NUMBER
42
my_module.double(5)
10
my_module.Calculator().add(10, 20)
30

Even the imports inside the module are accessible:

my_module.os
<module 'os' (frozen)>

The module knows its name and file location:

my_module.__name__, my_module.__file__
('my_module', '/tmp/import-demo-kpfbg5wy/my_module.py')

Module caching in practice

Remember that modules are cached. If we modify the file, the changes won’t appear automatically:

with open('my_module.py', 'a') as f:
    f.write('\n\ndef triple(x):\n    return 3 * x\n')
import my_module
hasattr(my_module, 'triple')
False

The triple function isn’t there! The module was already cached. To see changes during development, use importlib.reload():

import importlib
importlib.reload(my_module)
Hello from my_module!
My name is 'my_module'
<module 'my_module' from '/tmp/import-demo-kpfbg5wy/my_module.py'>
my_module.triple(4)
12

Module caching exists for two reasons:

  1. Performance - Loading modules can be expensive
  2. Consistency - All code sees the same module state

From folders: Creating packages

To create a package, make a folder with an __init__.py file:

import os
os.makedirs('my_package', exist_ok=True)

with open('my_package/__init__.py', 'w') as f:
    f.write('''
print("my_package is being imported!")

VERSION = "1.0.0"

def greet(name):
    return f"Hello from my_package, {name}!"
''')

Import it like any module:

import my_package
my_package is being imported!

The result is a package (note the __path__ attribute):

my_package.__path__
['/tmp/import-demo-kpfbg5wy/my_package']
my_package.VERSION
'1.0.0'
my_package.greet("Alice")
'Hello from my_package, Alice!'
Note

The __init__.py file is executed when the package is imported, just like a regular .py file. Everything defined in __init__.py becomes an attribute of the package.

Working with Packages

Packages become interesting when they contain multiple modules.

Submodules and dotted imports

Let’s add a submodule to our package:

with open('my_package/math_utils.py', 'w') as f:
    f.write('''
print("math_utils submodule loaded!")

def add(a, b):
    return a + b

def multiply(a, b):
    return a * b
''')

Now import it:

import my_package.math_utils

my_package.math_utils.add(5, 7)
math_utils submodule loaded!
12

Important insights:

  1. The submodule is not automatically available just by importing my_package:
import importlib
importlib.reload(my_package)  # Fresh import
hasattr(my_package, 'math_utils')
my_package is being imported!
True
  1. When you do import my_package.math_utils, Python always imports my_package first:
import my_package.math_utils
# Both are now in sys.modules:
'my_package' in sys.modules, 'my_package.math_utils' in sys.modules
my_package is being imported!
math_utils submodule loaded!
(True, True)
  1. The submodule becomes an attribute of the parent:
my_package.math_utils
<module 'my_package.math_utils' from '/tmp/import-demo-kpfbg5wy/my_package/math_utils.py'>

Structuring a project

You can control what users see by importing into __init__.py:

import os
os.makedirs('organized_package', exist_ok=True)

# Create submodules
with open('organized_package/core.py', 'w') as f:
    f.write('''
def process(data):
    return f"Processing: {data}"
''')

with open('organized_package/utils.py', 'w') as f:
    f.write('''
def helper(x):
    return x * 2
''')

# Import them in __init__.py
with open('organized_package/__init__.py', 'w') as f:
    f.write('''
from .core import process
from .utils import helper

__all__ = ['process', 'helper']
''')

Now users can access functions directly:

import organized_package

organized_package.process("data")
'Processing: data'
organized_package.helper(21)
42

This is how popular packages like numpy work - you import numpy and get access to many functions, even though they’re defined in various submodules.

Relative imports

In the example above, we used relative imports: from .core import process. The . means “current package”.

Relative import syntax:

  • from . import foo - Import foo from the current package
  • from .module import function - Import function from module in the current package
  • from ..parent import something - Go up one level
  • from ...grandparent import other - Go up two levels

Advantages:

  • Refactoring-friendly: Rename your package without changing imports
  • Relocatable: Move subpackages around more easily

Limitations:

  • Only work with from syntax (not import .foo)
  • Only work inside packages (not in standalone scripts)

Example with nested packages:

os.makedirs('nested_package/sub', exist_ok=True)

with open('nested_package/__init__.py', 'w') as f:
    f.write('TOP_LEVEL = "main package"')

with open('nested_package/sub/__init__.py', 'w') as f:
    f.write('''
from .. import TOP_LEVEL

def show_parent():
    return f"Parent defines: {TOP_LEVEL}"
''')
from nested_package.sub import show_parent
show_parent()
'Parent defines: main package'
Tip

Relative imports are perfectly fine in modern Python. The old advice against them was about “implicit relative imports” (a Python 2 feature that’s long gone). For stylistic reasons it’s preferable to have a consistent style throughout a project, so either choose relative imports or absolute imports, but not both.

Shipping data files: importlib.resources

Sooner or later a package needs a file that isn’t code: a default configuration, a template, a small lookup table. The tempting thing is to locate it relative to __file__:

# DON'T do this
config_path = os.path.join(os.path.dirname(__file__), 'default.toml')

It works right up until it doesn’t. If your package is installed from a zip file, or bundled by a tool that doesn’t unpack it onto disk, there is no __file__ to be relative to.

importlib.resources is the supported way. It asks the package for its data, and lets the import system worry about where that actually lives:

with open('organized_package/default.toml', 'w', encoding='utf-8') as f:
    f.write('greeting = "hello"\n')
from importlib.resources import files

content = files('organized_package').joinpath('default.toml').read_text(encoding='utf-8')
print(content)
greeting = "hello"

files(package) gives you a Traversable — an object that behaves much like a Path, supporting / and joinpath, read_text, read_bytes and open. If you need a genuine filesystem path (because you are handing it to code that insists on one), there is importlib.resources.as_file, which will unpack the resource to a temporary file if it has to.

Note

For this to work in a distributed package, the data file also has to be included in the distribution. That is a packaging question, and we return to it in the lectures on Architecture.

Import wildcards and __all__

The from module import * syntax imports “everything” from a module:

with open('star_module.py', 'w') as f:
    f.write('''
__all__ = ['public_func', 'PUBLIC_VAR']

def public_func():
    return "I'm public"

def _private_func():
    return "I'm private"

PUBLIC_VAR = 42
_PRIVATE_VAR = 99
''')
from star_module import *

Only items in __all__ are imported:

public_func(), PUBLIC_VAR
("I'm public", 42)
_private_func()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[71], line 1
----> 1 _private_func()

NameError: name '_private_func' is not defined
Warning

Use import * sparingly - it makes code harder to understand because you can’t easily see where names come from. You sometimes see it in older documentation for specific libraries, such as from tkinter import *, but I consider it to be old-fashioned.

The __all__ attribute also serves as documentation, explicitly declaring your module’s public API.

Circular Imports: Just Don’t

Circular imports occur when module A imports module B, and module B imports module A.

Here’s why they’re problematic:

  1. User runs import A
  2. Python creates an empty module object for A in sys.modules
  3. Python starts executing A’s code
  4. A tries to import B
  5. Python creates an empty module object for B in sys.modules
  6. Python starts executing B’s code
  7. B tries to import A
  8. Python finds A in sys.modules and returns it… but it’s only partially initialized!

The result depends on execution order and is extremely fragile:

with open('circular_a.py', 'w') as f:
    f.write('''
print("A: Starting import")
from circular_b import b_func

def a_func():
    return "From A"

print("A: Finished import")
''')

with open('circular_b.py', 'w') as f:
    f.write('''
print("B: Starting import")
from circular_a import a_func

def b_func():
    return "From B"

print("B: Finished import")
''')
import circular_a
A: Starting import
B: Starting import
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Cell In[74], line 1
----> 1 import circular_a

File /tmp/import-demo-kpfbg5wy/circular_a.py:3
      2 print("A: Starting import")
----> 3 from circular_b import b_func
      5 def a_func():
      6     return "From A"

File /tmp/import-demo-kpfbg5wy/circular_b.py:3
      2 print("B: Starting import")
----> 3 from circular_a import a_func
      5 def b_func():
      6     return "From B"

ImportError: cannot import name 'a_func' from partially initialized module 'circular_a' (most likely due to a circular import) (/tmp/import-demo-kpfbg5wy/circular_a.py)

Module B tries to import a_func, but A hasn’t defined it yet!

Solution: Don’t create circular imports. Restructure your code:

  • Extract shared code to a third module
  • Move imports inside functions (if you must)
  • Rethink your module boundaries

The one legitimate exception: TYPE_CHECKING

There is a case where the cycle is not a design flaw. Sometimes module A needs a name from module B only to write a type annotation, and B genuinely needs A at runtime. Nothing is wrong with the design; the import is simply not needed while the program runs.

typing.TYPE_CHECKING is a constant that is False at runtime but which every type checker pretends is True:

from typing import TYPE_CHECKING
TYPE_CHECKING
False

So an import placed under it never executes, and the cycle never happens — but the type checker still sees the name:

# invoice.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from customer import Customer     # not imported at runtime

class Invoice:
    def send_to(self, customer: "Customer") -> None:
        ...

Note the quotes around "Customer". Because the import didn’t happen, the name does not exist at runtime, so the annotation has to be a string that Python never evaluates. Since Python 3.14 annotations are evaluated lazily anyway, so in new code the quotes are no longer required — see the lecture on type checkers.

Use this sparingly, and only for annotations. If you find yourself wanting a TYPE_CHECKING import so that some runtime code can work, you are back to having a real circular dependency and should restructure instead.

The Import System Under the Hood

Now let’s understand how imports actually work.

The import process

When you run import some_module, Python follows these steps:

  1. Check the cache: Is some_module in sys.modules? If yes, return it immediately.
  2. Find the module: Use finders in sys.meta_path to locate the module and get a “module spec”.
  3. Load the module: Use the spec’s loader to create and execute the module object.

Let’s explore each step.

Meta path finders

The sys.meta_path list contains objects that know how to find modules:

import sys
sys.meta_path
[<_distutils_hack.DistutilsMetaFinder at 0x777ab4936ba0>,
 <_virtualenv._Finder at 0x777ab4936a50>,
 _frozen_importlib.BuiltinImporter,
 _frozen_importlib.FrozenImporter,
 _frozen_importlib_external.PathFinder,
 <six._SixMetaPathImporter at 0x777ab387cd70>]

Each finder has a find_spec() method that tries to locate a module:

Do not index into this list by position. What you see above is whatever your environment happens to contain: a virtual environment, setuptools or a debugger may all insert finders of their own at the front. The three finders Python itself installs are available by name in importlib.machinery, so grab them from there:

import importlib.machinery as machinery

builtin_finder = machinery.BuiltinImporter
frozen_finder = machinery.FrozenImporter
pathfinder = machinery.PathFinder

# these are the very same objects that appear in sys.meta_path
all(f in sys.meta_path for f in (builtin_finder, frozen_finder, pathfinder))
True
for name in ['sys', 'os', 'datetime', 'json']:
    print(f"{name:10} -> builtin: {builtin_finder.find_spec(name) is not None}, "
          f"frozen: {frozen_finder.find_spec(name) is not None}, "
          f"path: {pathfinder.find_spec(name) is not None}")
sys        -> builtin: True, frozen: False, path: False
os         -> builtin: False, frozen: True, path: True
datetime   -> builtin: False, frozen: False, path: True
json       -> builtin: False, frozen: False, path: True

The first finder to return a spec wins.

The path-based finder

The path-based finder is the most important for your code. It searches locations in sys.path:

sys.path[:5]  # First few entries
['/tmp/import-demo-kpfbg5wy',
 '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python313.zip',
 '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13',
 '/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/lib-dynload',
 '']

sys.path typically contains:

  • The directory of the script being run (or current directory)
  • PYTHONPATH environment variable contents
  • Standard library directories
  • Site-packages (third-party packages)

The path-based finder uses sys.path_hooks to create specialized finders for each location:

sys.path_hooks
[zipimport.zipimporter,
 <function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>]

For file-based searching, there’s FileFinder. Again we pick it out by name rather than by position — the other hook in the list handles zip archives:

file_finder_factory = next(
    hook for hook in sys.path_hooks
    if 'FileFinder' in getattr(hook, '__qualname__', '')
)
file_finder_factory
<function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>

This creates a finder for a specific directory:

import os
cwd = os.getcwd()
finder = file_finder_factory(cwd)
finder
FileFinder('/tmp/import-demo-kpfbg5wy')

This finder can locate modules:

spec = finder.find_spec('my_module')
spec
ModuleSpec(name='my_module', loader=<_frozen_importlib_external.SourceFileLoader object at 0x777a840df830>, origin='/tmp/import-demo-kpfbg5wy/my_module.py')

The spec contains all the information needed to load the module:

spec.name, spec.origin, spec.loader
('my_module',
 '/tmp/import-demo-kpfbg5wy/my_module.py',
 <_frozen_importlib_external.SourceFileLoader at 0x777a840df830>)

Loading a module manually

We can manually load a module using the import machinery:

with open('manual_demo.py', 'w') as f:
    f.write('''
print("Manual demo loaded!")
VALUE = 100
''')

Step by step:

# Find the spec
spec = pathfinder.find_spec('manual_demo')
print("Spec:", spec)
Spec: ModuleSpec(name='manual_demo', loader=<_frozen_importlib_external.SourceFileLoader object at 0x777a840dfa70>, origin='/tmp/import-demo-kpfbg5wy/manual_demo.py')
# Create an empty module
import types
module = types.ModuleType(spec.name)
print("Empty module:", module)
Empty module: <module 'manual_demo'>
# Add to sys.modules (important!)
sys.modules[spec.name] = module
# Execute the module code
spec.loader.exec_module(module)
Manual demo loaded!
# Now the module is populated
module.VALUE
100

This is essentially what Python does when you write import manual_demo.

Submodules use parent’s __path__

For submodules like import package.submodule, Python doesn’t search sys.path. Instead, it searches package.__path__:

import json
json.__path__
['/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json']
spec = pathfinder.find_spec('tool', path=json.__path__)
spec
ModuleSpec(name='tool', loader=<_frozen_importlib_external.SourceFileLoader object at 0x777a840dfcb0>, origin='/home/knaert/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/lib/python3.13/json/tool.py')

This is how packages organize their submodules - the parent package’s __path__ determines where to look for children.

Controlling the import system

You can influence how Python finds modules:

1. Modify sys.path (most common):

import sys
sys.path.insert(0, '/path/to/my/modules')

2. Set PYTHONPATH environment variable:

export PYTHONPATH=/path/to/my/modules
python script.py

3. Use site-packages: Installing packages with pip puts them in site-packages, which is automatically in sys.path.

4. Custom finders (advanced): Add your own finder to sys.meta_path to implement custom import logic (e.g., loading modules from a database, over the network, etc.).

Cleaning up

Finally, we undo the scratch folder we created at the start: change back to where we were, take the folder off sys.path again, and delete it.

os.chdir(original_cwd)
sys.path.remove(str(scratch))
shutil.rmtree(scratch, ignore_errors=True)

print(f"Scratch folder still there? {scratch.exists()}")
Scratch folder still there? False

Note that the modules we imported from it are still sitting in sys.modules. Deleting the files does not unimport anything — once a module object exists, it lives on independently of the file it came from.

'my_module' in sys.modules
True

Additional Resources