Modules and Import System

Why Python has no import tariffs

Author

Karsten Naert

Published

November 15, 2025

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

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 like from __future__ import annotations
  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.

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()
'/var/www/pydev2/advanced-python'

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. The effectively form a level of structure above the classes. They 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 need 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.

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. As you are following along, it would make more sense to create these files yourself manually.

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', '/var/www/pydev2/advanced-python/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 '/var/www/pydev2/advanced-python/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__
['/var/www/pydev2/advanced-python/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 '/var/www/pydev2/advanced-python/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.

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[66], 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[69], line 1
----> 1 import circular_a

File /var/www/pydev2/advanced-python/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 /var/www/pydev2/advanced-python/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 'circular_a' (consider renaming '/var/www/pydev2/advanced-python/circular_a.py' if it has the same name as a library you intended to import)

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 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 0x7aedf4536ba0>,
 <_virtualenv._Finder at 0x7aedf4536a50>,
 _frozen_importlib.BuiltinImporter,
 _frozen_importlib.FrozenImporter,
 _frozen_importlib_external.PathFinder,
 <six._SixMetaPathImporter at 0x7aedf33dcd70>]

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

builtin_finder = sys.meta_path[2]
frozen_finder = sys.meta_path[3]
pathfinder = sys.meta_path[4]

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
['/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',
 '',
 '/var/www/pydev2/.venv/lib/python3.13/site-packages']

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:

file_finder_factory = sys.path_hooks[1]
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('/var/www/pydev2/advanced-python')

This finder can locate modules:

spec = finder.find_spec('my_module')
spec
ModuleSpec(name='my_module', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7aedc3bba750>, origin='/var/www/pydev2/advanced-python/my_module.py')

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

spec.name, spec.origin, spec.loader
('my_module',
 '/var/www/pydev2/advanced-python/my_module.py',
 <_frozen_importlib_external.SourceFileLoader at 0x7aedc3bba750>)

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 0x7aedc3bba8d0>, origin='/var/www/pydev2/advanced-python/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 0x7aedc3bbab10>, 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.).

Additional Resources