How Python Imports Modules: A Deep Dive
Introduction
You type import pandas and pandas appears instantly. But where did Python find it? How did it know which file to load? And what exactly happens between that one line and the moment the module is ready to use?
Python’s import system is more sophisticated than most developers realize. Behind every import statement, Python performs multiple steps: searching for the module, loading and compiling code, caching the result, and binding it to a variable name.
Understanding how imports work explains many common Python problems:
ModuleNotFoundError- Circular imports
- Shadowing bugs
- Relative import confusion
- Slow startup times
All examples are tested on Python 3.12.
What Happens When You Write import?
When Python sees import json, it does not simply include a file. Instead, it performs three major phases: Search, Load, and Bind.
A quick experiment shows the process in action:
import sys
print("json" in sys.modules)
import json
print("json" in sys.modules)
print(type(sys.modules["json"]))
Expected output:
False
True
<class 'module'>
Before importing, json does not exist in sys.modules. After importing, Python stores the loaded module there. This cache is one of the most important parts of the import system.
Phase 1: Finding the Module
Before Python can load a module, it must locate it. Python searches directories listed in sys.path:
import sys
for path in sys.path:
print(path)
Example output:
''
/usr/lib/python312.zip
/usr/lib/python3.12
/usr/lib/python3.12/lib-dynload
/usr/local/lib/python3.12/dist-packages
Search Order
Python checks these locations in order:
- Current directory (
'') - Directories in the
PYTHONPATHenvironment variable - Standard library directories
site-packages(third-party libraries installed via pip)
The first matching module wins. This is why files in your project directory can be imported directly — the current directory is always first in sys.path.
The Shadowing Problem
The search order also causes a common beginner mistake. If you create a file named random.py in your project, then write import random, Python imports your file instead of the standard library module. This is called shadowing.
Filenames to avoid:
os.pysys.pyjson.pyrandom.py- Any other name that matches a built-in or installed module
How Third-Party Packages Are Found
When you install a package with pip, it goes into the site-packages directory, which is included in sys.path. That is why import requests works after pip install requests — Python finds it by searching site-packages. If the package is not installed in the active environment, Python raises ModuleNotFoundError.
Phase 2: The Module Cache
Python avoids loading the same module twice. Every loaded module is stored in sys.modules, a dictionary mapping module names to module objects:
import sys
import json
print(type(sys.modules))
print("json" in sys.modules)
print("numpy" in sys.modules)
Expected output:
<class 'dict'>
True
False
Once a module is cached, subsequent imports reuse the same object rather than reloading the file. You can verify this with id():
import sys
print(id(sys))
import sys
print(id(sys))
Expected output:
139742358729872
139742358729872
Both calls return the same ID — Python returned the cached object immediately.
Shared Module State
Because modules are cached globally, any changes to a module object are visible to all code that imported it:
# config.py
debug = False
import config
config.debug = True
# Any other module that imports config now sees debug = True
This shared state can be useful for configuration, but it also means accidental mutations can cause hard-to-debug side effects across your program.
Phase 3: Loading and Executing the Module
After locating the file, Python reads the source, compiles it to bytecode (as described in the Python execution deep dive), executes the top-level code, and stores the resulting module object in sys.modules.
Module Code Executes at Import Time
This surprises many developers: any code at the top level of a module runs immediately when it is imported.
# greet.py
print("Module greet is being loaded!")
def hello():
print("Hello!")
# main.py
import greet
import greet # second import — no output this time
greet.hello()
Expected output:
Module greet is being loaded!
Hello!
The message prints only once. The first import executes the module and caches it. The second import finds it in sys.modules and returns immediately.
Keep Top-Level Code Lightweight
Because top-level code runs at import time, expensive operations at the module level make every import slow:
# bad practice
print("Starting expensive computation...")
huge_data = load_massive_dataset() # runs every time anyone imports this module
Good modules do their work inside functions or classes, not at the top level. This keeps imports fast and side-effect-free.
Packages and __init__.py
A package is a directory containing Python modules. A typical package structure looks like this:
mypackage/
__init__.py
utils.py
data/
__init__.py
loader.py
What __init__.py Does
The __init__.py file serves several purposes:
- It marks the directory as a Python package
- It runs initialization code when the package is first imported
- It can re-export names to simplify the public API
Example:
# mypackage/__init__.py
from .utils import helper
This lets users write:
from mypackage import helper
instead of the longer:
from mypackage.utils import helper
The __init__.py runs when the package is first imported, so keep it lightweight for the same reasons as any other module.
Namespace Packages (Python 3.3+)
Python 3.3 introduced namespace packages — directories without __init__.py that can still be imported. Their behavior differs in subtle ways and can be confusing. Most projects still include __init__.py explicitly for clarity and compatibility.
Absolute vs Relative Imports
Python supports two import styles.
Absolute Imports (Recommended)
Absolute imports specify the full path from the project root:
from mypackage.utils import helper
import mypackage.data.loader
They are explicit, easy to read, and unambiguous. PEP 8 recommends absolute imports for most situations.
Relative Imports
Relative imports reference modules relative to the current package:
from . import utils # same package
from .. import something # parent package
from .utils import helper # specific name from same package
Relative imports are mainly useful inside packages where modules reference each other. They make package renaming easier since internal references do not need updating.
Common Relative Import Error
This error appears frequently:
ImportError: attempted relative import with no known parent package
It almost always means you ran a module file directly with python utils.py instead of as part of a package. Relative imports only work when a module is executed as part of a package structure.
Common Import Errors Explained
ModuleNotFoundError
import nonexistent
Expected output:
ModuleNotFoundError: No module named 'nonexistent'
Common causes: the package is not installed, the wrong virtual environment is active, or the module name has a typo. Run pip list to check installed packages and which python (or where python on Windows) to verify the active environment.
ImportError: cannot import name
from json import nonexistent_function
Expected output:
ImportError: cannot import name 'nonexistent_function' from 'json'
The module exists but the requested name does not. Check the module’s documentation or run dir(json) to see available names.
Circular Imports
# a.py
import b
# b.py
import a
When a imports b and b imports a, Python ends up with partially initialized modules. The result is usually an AttributeError or ImportError for names that do not exist yet. Circular imports are one of the hardest Python bugs to debug. The fix is usually to restructure the code — move shared code to a third module, or move the import inside a function.
Shadowing
Creating a file with the same name as a standard library or installed module silently breaks imports. If import random is behaving strangely, check whether your project contains a random.py file.
Practical Tips
Find where a module is loaded from:
import json
print(json.__file__)
Expected output:
/usr/lib/python3.12/json/__init__.py
This is invaluable for debugging import confusion — it tells you exactly which file Python loaded.
Import modules dynamically:
import importlib
mod = importlib.import_module("json")
Useful for plugin systems, configuration-driven loading, or any situation where the module name is known only at runtime.
Force a module to reload (during debugging):
import importlib
import json
importlib.reload(json)
This bypasses the sys.modules cache and re-executes the module file. Useful when iterating on a module in an interactive session.
Wrap-Up
Python’s import system revolves around three core ideas:
sys.pathdetermines where Python searches for modules — order matters, and your current directory comes first.sys.modulescaches every loaded module so imports only run once per process.- Top-level module code executes at import time — keep it lightweight.
Understanding these three points explains most real-world import problems: why ModuleNotFoundError appears, why circular imports are tricky, why shadowing causes silent bugs, and why large frameworks can feel slow to import.
For more background on what happens during the load phase, see the earlier article on what happens when you run python script.py. The GIL deep dive is also relevant if you are importing modules across threads. For questions or future topic suggestions, get in touch via the Contact page.