Free lesson · Python Basic to Advance
The Import System
The Import System
What import Actually Does
import looks simple - import json and now json is available. Under the surface, several systems coordinate to find, load, compile, and bind modules. Understanding the pieces matters when imports fail mysteriously, when reorganizing code, when working with packages, or when building tools that interact with the module system.
import json # finds json, executes it, binds 'json' in current module
from os import path # finds os, executes it, binds 'path' as a local name
import json as j # finds json, binds it as 'j'
This code shows the three common import forms. Plain import binds the top-level module. From-import binds a specific name from the module. As-import binds under a different name. All three trigger the same underlying mechanism: find the module, execute it once, register it, then bind names according to the import statement.
The mental model: import is a name-binding statement that triggers a search-and-load process. The first time a module is imported, it's executed top-to-bottom. Subsequent imports are nearly free - they just look up the already-loaded module.
The Import Process
When import foo runs, Python goes through these steps:
- Check
sys.modules- already imported? Use the cached module - Find the module - use registered finders to locate the source
- Load the module - use the appropriate loader to read and compile it
- Execute the module - run its code; populate its namespace
- Cache in
sys.modules- under its full name - Bind the name - in the importer's namespace
import sys
# sys.modules - the cache of loaded modules
print(len(sys.modules)) # hundreds, even in a small script
print("json" in sys.modules) # True if json has been imported
# Remove a module to force reload
import json
del sys.modules["json"]
import json # now re-runs the module
This code inspects sys modules. Every loaded module is cached here, keyed by its full dotted name. Deleting an entry forces re-execution on the next import - useful for some debugging scenarios but rarely a good idea in real code. The cache is what makes import nearly free after the first one.
The cache is what enables the "modules execute once" rule. Re-importing the same module returns the existing object; the file is not re-read or re-executed.
sys.path - Where Python Looks
Python searches a list of directories for modules:
import sys
for p in sys.path:
print(p)
# Roughly:
# '' - the script's directory
# /usr/lib/python313.zip
# /usr/lib/python3.13
# /usr/lib/python3.13/lib-dynload
# /usr/lib/python3.13/site-packages - third-party installs
This code shows the search path. Python checks each directory in order for the requested module. The first matching one wins. Site packages contains pip-installed third-party libraries. The script's directory is searched first by default - useful but can shadow built-in modules if you name a file the same as a stdlib module.
Naming your file random.py is a classic mistake - your file shadows the standard library's random, and unrelated imports break in confusing ways. Don't reuse stdlib names.
Modifying the Path
sys.path is just a list. You can modify it:
import sys
sys.path.append("/path/to/my/code")
import my_module # now findable
# Environment variable PYTHONPATH adds to the path before Python starts
# $ PYTHONPATH=/path/to/code python script.py
This code modifies the import path. Appending to sys path adds a search location. The PYTHONPATH environment variable does the same from outside. Both are useful in specific situations - non-standard project layouts, plugin systems - but for normal projects, proper package installation is cleaner.
For application code, prefer proper packaging (Topic 29.7) over sys.path manipulation. Path tweaking should be a last resort.
Packages and __init__.py
A package is a directory containing an __init__.py file. Packages can contain modules, sub-packages, and code:
myapp/
__init__.py
config.py
database/
__init__.py
connection.py
queries.py
api/
__init__.py
routes.py
myapp/
__init__.py
config.py
database/
__init__.py
connection.py
queries.py
api/
__init__.py
routes.py
This text shows a typical package layout. Each directory with init dot py is a package. Importing the package executes init dot py. Sub-directories with their own init form sub-packages. The dotted name reflects the directory structure - myapp dot database dot connection refers to connection dot py inside the database directory inside myapp.
__init__.py can be empty (just marking the directory as a package) or can contain code that runs when the package is imported. A common pattern: re-export names from sub-modules to simplify the package's public API:
# myapp/__init__.py
from .config import settings
from .database.connection import get_connection
# Now: from myapp import settings, get_connection
# Instead of: from myapp.config import settings; from myapp.database.connection import get_connection
This code is a typical init dot py. The from-import statements pull names from sub-modules into the package's top level. Users of the package see a clean public API instead of having to navigate the internal structure. Useful for libraries - internal organization can change without breaking the import paths users rely on.
For large packages, this re-export pattern keeps user-facing imports stable as internal structure evolves.
Relative Imports
Inside a package, you can import from other modules in the same package using dots:
# Inside myapp/database/queries.py:
# Absolute import - full dotted path from the project root
from myapp.database.connection import get_connection
# Relative import - relative to this module's location
from .connection import get_connection # same package
from ..config import settings # parent package
from ..api.routes import router # sibling package
This code shows relative imports. A single dot means the current package; two dots mean the parent. Useful inside larger packages - internal references don't need to repeat the full path. Relative imports also make a package portable - renaming the package doesn't require updating all internal imports. They only work inside packages, not in top-level scripts.
For internal references within a package, relative imports are usually clearer. For external imports - from third-party libraries, from other packages in your project - use absolute imports.
The if __name__ == "__main__": Pattern
Every module has a __name__ attribute. When a module is imported, __name__ is the module's dotted name. When a module is run as a script, __name__ is "__main__":
# my_script.py
def main():
print("Running as a script")
def helper():
print("Reusable helper")
if __name__ == "__main__":
main()
This code uses the standard script-or-import idiom. When the file is run directly, name equals main, so the main function is called. When the file is imported, name equals the module's dotted name, and main is not called - the helper function is just made available for import. This pattern lets the same file serve as both a script and a library.
The pattern is so universal that linters expect it for any module that can be executed directly.
from package import *
A wildcard import pulls all public names from a module:
# What gets imported with *
# my_module.py
public_thing = 1
_private = 2 # leading underscore - not exported by *
def helper(): pass
__all__ = ["public_thing", "helper"] # explicit export list
# Other code
from my_module import *
# Now: public_thing and helper are available
# _private is NOT
This code shows wildcard imports. Names starting with underscore are not exported by default. The all variable explicitly lists what to export - preferred when present, since it's explicit. Wildcard imports are usually a bad idea in application code - they make the source of names unclear and can shadow existing names silently. Use specific imports.
Avoid from x import * in application code. For library code, define __all__ explicitly to control what wildcard imports actually pull.
Circular Imports
When module A imports B and B imports A, things get tricky:
# a.py
from b import b_func
def a_func(): pass
# b.py
from a import a_func # may fail if a.py is still being loaded
def b_func(): pass
This code creates a circular import. When a dot py runs and imports b, b's module starts to load, which tries to import a - but a is still in the middle of its first execution. Depending on what each module needs from the other, the import may succeed, partially succeed, or fail. Common symptoms: ImportError on names that exist, or attributes that are missing during import but appear later.
Fixes for circular imports, in order of preference:
- Restructure the code - move shared code to a third module
- Import inside functions - defer the import until it's actually needed
- Use
import binstead offrom b import b_func- gives the modules a chance to finish loading before names are accessed - Re-architect - circular dependencies usually indicate a design issue worth fixing
# Workaround: import inside the function
def a_func():
from b import b_func # imported only when this function is called
return b_func()
This code defers the import. Putting it inside the function means it runs only when the function is called - by which point both modules have finished loading. Useful as a workaround but the underlying design issue often remains. Better to restructure if possible.
Namespace Packages
A package without __init__.py is a namespace package (PEP 420). It can span multiple directories, useful for plugin systems:
# In one location
plugins/
app_plugins/
plugin1.py
# In another location
extras/
app_plugins/
plugin2.py
# Both directories on sys.path → app_plugins.plugin1 and app_plugins.plugin2 both work
# Location 1
plugins/app_plugins/plugin1.py
# Location 2
extras/app_plugins/plugin2.py
# Both available as app_plugins.plugin1 / app_plugins.plugin2
This text shows a namespace package. Without init dot py, the package can be assembled from multiple directories. Useful for plugin systems where third parties contribute modules to a shared package. For most application code, regular packages with init dot py are clearer.
For typical application code, regular packages are clearer. Namespace packages exist for specific use cases (plugin systems, large distributed codebases).
Reloading Modules
Once a module is loaded, re-importing returns the cached version. Forcing a reload:
import importlib
import my_module
# Modify my_module.py externally...
importlib.reload(my_module) # re-executes the module file
This code reloads a module. Importlib reload re-runs the module's top-level code. The existing module object is updated in place - other code holding the old object sees the updates. Useful in interactive sessions and notebooks while developing. Risky in production - references to the old code may persist in unexpected places.
For notebooks, IPython's %load_ext autoreload automates this - modules reload on every cell execution. Convenient for iterative development.
The Import System's Pluggability
Python's import system is itself extensible. You can install custom finders and loaders to handle non-standard sources:
import sys
# A finder is an object with find_spec(name, path, target)
# A loader can read and execute the module
# Examples in the wild:
# - Loading modules from zip files
# - Loading modules over the network
# - Lazy-loading expensive modules
# - Mocking modules in tests
This code mentions the finder-loader extensibility. Sys meta path holds the chain of finders consulted on each import. Custom finders can intercept imports for arbitrary sources. Useful for advanced use cases - testing, plugin systems, lazy loading - but rare in normal application code.
For most code, this machinery is invisible. It matters for tools like pytest (which uses custom imports for test discovery), IPython (autoreload), and packaging tools.
Compiled vs Source
Python imports can find either source files (.py) or compiled bytecode (.pyc):
mymodule/
__init__.py
utils.py
__pycache__/
__init__.cpython-313.pyc
utils.cpython-313.pyc
mymodule/
__init__.py
utils.py
__pycache__/
__init__.cpython-313.pyc
utils.cpython-313.pyc
This text shows the cache layout. Compiled bytecode lives under pycache. On import, Python compares the source file's modification time to the cached bytecode; if the source is newer, it recompiles. If a py file is missing but the pyc is present and a "source-less" import is configured, the pyc alone can be used - sometimes useful for distribution.
For most code, this is invisible - the first import compiles, subsequent imports use the cache.
Lazy Imports
For modules that are slow to import or rarely used, importing inside functions defers the cost:
def maybe_use_pandas(data):
if isinstance(data, dict):
# Only import if we actually need it
import pandas as pd
return pd.DataFrame(data)
return data
This code defers an expensive import. Pandas is only imported when actually needed. Useful for command-line tools where startup time matters or for libraries that don't always use every dependency. The cost is paid once per Python session - subsequent calls find the module in sys modules and are nearly free.
For libraries with optional dependencies, lazy imports avoid forcing users to install everything. For CLI tools, lazy imports can reduce startup time substantially.
Debugging Imports
When imports go wrong:
import sys
# What's been imported?
for name in sorted(sys.modules):
print(name)
# Where did it come from?
import mymodule
print(mymodule.__file__)
# Verbose import tracing - at startup
# $ python -v script.py
# Prints every import attempt and result
# Find spec for a name
import importlib.util
spec = importlib.util.find_spec("json")
print(spec.origin) # path to the source file
This code introspects imports. Module file shows where a loaded module came from. Verbose mode at startup traces every import - useful for diagnosing missing modules, wrong versions, or unexpected files being picked up. Find spec answers "what would import use" without actually importing.
For "why is the wrong version being imported" debugging, python -v is essential.
Performance
Import costs:
- First import of a small module: tens of milliseconds (read, parse, compile, execute)
- Cached import (already in
sys.modules): microseconds - Bytecode-cached import: milliseconds (skip parse+compile, execute only)
- Heavy modules (pandas, scipy): seconds for the first import
For startup-sensitive code, profiling imports with python -X importtime shows where the time goes.
Interview Relevance
Interview: The import system appears in mid-to-senior Python interviews:
sys.modulesas the import cachesys.pathas the search path- Packages,
__init__.py, sub-packages- Absolute vs relative imports
if __name__ == "__main__":pattern- Circular imports and how to resolve them
- The
__file__,__name__attributes- Why
from x import *is discouragedA common interview moment: "what does
import fooactually do?" The answer involves cache check, find, load, execute, bind.Another: "how would you fix a circular import?" The right answer involves restructuring first, deferred imports as a workaround.
Another: "why is
importso much faster the second time?" The cache.Module 30 covers Python interview preparation.
Common Pitfalls
Warning: Shadowing stdlib modules. Don't name your file
random.py,email.py, etc.Circular imports from
from x import y. Restructure or use plainimport xand referencex.y.
sys.pathmanipulation in application code. Use proper packaging instead.Wildcard imports.
from x import *makes the source of names unclear.Mutable state in
__init__.py. Loaded once; surprises if you expect per-import state.Believing modules are isolated. Module-level mutable state is shared across all importers.
Relying on import order side effects. Code that requires modules to be imported in a specific order is fragile.
Missing
__init__.pywhen intended as a regular package. Becomes a namespace package, which behaves differently.
Try It
Exercise: Practice with imports:
- Inspect
sys.modulesbefore and after an import. Note what changes.- Build a small package with sub-packages. Practice both absolute and relative imports.
- Trigger a circular import deliberately. Fix it by restructuring.
- Use
python -X importtime script.pyto profile imports of a small script.- Add a lazy import inside a function. Verify the cost is paid only when the function is called.
- Find a module's
__file__and inspect the__pycache__directory next to it.