DEV Community

Cover image for The Secret Life of Python: The Import System
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Python: The Import System

How Python finds your code (and why it sometimes gets lost)


Timothy slumped in his chair, staring at his terminal with the exhausted frustration of someone who'd been debugging the same problem for three hours.

"I don't understand," he muttered, running the script for what felt like the hundredth time. "I deleted the file. I physically removed old_utils.py from my project directory. I even restarted my terminal. But when I run my code..."

import utils

print(utils.get_version())
# Output: "Version 1.0 - DEPRECATED"
Enter fullscreen mode Exit fullscreen mode

"It's still loading the old version! The file doesn't even exist anymore!" He gestured at his file browser, showing only new_utils.py in the directory.

Margaret walked over, a knowing smile on her face. "Timothy, your Python isn't haunted. It just has a very good memory. You're fighting the import system—and losing because you don't understand the rules."

"Rules? I just want to import a file! How hard can that be?"

"Ah," Margaret said, settling into the chair beside him. "That's what everyone thinks. import utils—such a simple statement. But behind those two words lies one of Python's most sophisticated systems: a cache that never forgets, a search path that determines what you find, and machinery that can load code from places you'd never expect."

She opened her notebook. "Let me show you why your Python remembers what you've tried to forget."


The Cache: sys.modules

"When you type import utils," Margaret began, "what do you think Python does first?"

"It looks for utils.py in my directory?" Timothy guessed.

"Wrong," Margaret said gently. "Python is lazy—it avoids work whenever possible. Before it even thinks about searching your file system, it checks its memory. Specifically, it looks in a dictionary called sys.modules."

She typed:

import sys

print(type(sys.modules))
# <class 'dict'>

print(len(sys.modules))
# 347  (the number varies)

# Let's see what's in there
for name in list(sys.modules.keys())[:10]:
    print(name)

# sys
# builtins
# _frozen_importlib
# _imp
# _thread
# _warnings
# _weakref
# os
# _collections_abc
# posixpath
Enter fullscreen mode Exit fullscreen mode

"This dictionary," Margaret explained, "maps module names to module objects. Every module Python has loaded during your session is in here. When you write import utils, Python's first action is to check if 'utils' is already a key in sys.modules. If it is, Python returns that cached module object immediately—without looking at your file system at all."

Timothy's eyes widened. "So even though I deleted the file..."

"The module object is still in memory," Margaret confirmed. "Once a module is imported, it stays in sys.modules until the Python process ends. Let me show you:"

import sys

# Import a module
import json

# It's in the cache
print('json' in sys.modules)  # True
print(sys.modules['json'])
# <module 'json' from '/usr/lib/python3.11/json/__init__.py'>

# The cache stores the actual module object
print(json.dumps({'test': 'data'}))
# '{"test": "data"}'

# Let's look at what the module object contains
print(dir(sys.modules['json'])[:5])
# ['JSONDecodeError', 'JSONDecoder', 'JSONEncoder', 'dump', 'dumps']
Enter fullscreen mode Exit fullscreen mode

"This cache is why Python is fast," Margaret continued. "Imagine if every time you called a function that did import os, Python had to search your file system, open the file, parse it, compile it, and execute it. That would be horribly slow. Instead, it does all that work once, caches the result, and reuses it."

"But it also means," Timothy said slowly, "that if I change a file during development..."

"Python won't notice," Margaret finished. "You're running a Jupyter notebook or an interactive session, you import a module, then you edit the file and save it. Python still has the old version in sys.modules. When you import again, it just returns the cached version."

She demonstrated the problem:

# Create a simple module file
with open('example.py', 'w') as f:
    f.write('''
def greet():
    return "Hello, version 1"
''')

# Import it
import example
print(example.greet())
# Hello, version 1

# Now change the file
with open('example.py', 'w') as f:
    f.write('''
def greet():
    return "Hello, version 2"
''')

# Try to import again
import example
print(example.greet())
# Hello, version 1  (Still the old version!)

# The module is cached
print('example' in sys.modules)  # True
Enter fullscreen mode Exit fullscreen mode

"So how do I force Python to reload?" Timothy asked.

"You have two options," Margaret said. "The dangerous one and the proper one."

The Dangerous Way: Delete from Cache

import sys

# Delete the cached module
del sys.modules['example']

# Now import again - Python will reload from disk
import example
print(example.greet())
# Hello, version 2
Enter fullscreen mode Exit fullscreen mode

"Why is this dangerous?" Timothy asked.

"Because," Margaret said seriously, "other parts of your code might already have references to the old module. Look:"

# Start fresh
import sys
if 'example' in sys.modules:
    del sys.modules['example']

# File contains version 1
with open('example.py', 'w') as f:
    f.write('def greet(): return "Version 1"')

# Two different modules import it
import example as ex1
from example import greet as greet1

# Now delete from cache and change the file
del sys.modules['example']
with open('example.py', 'w') as f:
    f.write('def greet(): return "Version 2"')

# Import again
import example as ex2
from example import greet as greet2

# Now we have TWO different module objects in memory!
print(greet1())  # Version 1 (old function object)
print(greet2())  # Version 2 (new function object)

print(ex1 is ex2)  # False! Two different module objects!
Enter fullscreen mode Exit fullscreen mode

"You've created a confusing situation," Margaret said. "The old function object still exists in memory because greet1 holds a reference to it. The new function is a completely different object. This isn't a Python bug—it's normal reference semantics—but it creates chaos because different parts of your code are using different versions of what appears to be the 'same' function."

"Part of your program thinks greet() returns 'Version 1', and part thinks it returns 'Version 2'. Debugging this is a nightmare because both versions are 'correct' from their perspective."

The Proper Way: importlib.reload()

import importlib

# Import the module
import example

# Edit the file (however you do it)
with open('example.py', 'w') as f:
    f.write('def greet(): return "Version 2"')

# Reload it properly
importlib.reload(example)

print(example.greet())
# Version 2

# The module object is the SAME object, just updated
print(id(example))  # Same memory address
Enter fullscreen mode Exit fullscreen mode

"reload() updates the existing module object in place," Margaret explained. "So all references to it see the new code. But even this has limitations—if someone copied a function reference before the reload, they'll keep the old function. And critically, if other modules did from example import greet, those references won't update either—they're still bound to the old function object, even after the reload."

"The real lesson," she concluded, "is that sys.modules is Python's memory. It's fast, it's efficient, but it never forgets unless you make it. When debugging import issues, always check: is my module already cached?"


The Map: sys.path

"Okay," Timothy said, "so if a module isn't in sys.modules, then Python looks for it on disk?"

"Yes," Margaret nodded. "But Python doesn't search your entire computer. That would take forever. Instead, it follows a map—a list of directories where it knows to look. That list is called sys.path."

import sys

print(type(sys.path))
# <class 'list'>

for i, path in enumerate(sys.path):
    print(f"{i}: {path}")

# Output (example):
# 0: /home/timothy/projects/myapp
# 1: /usr/lib/python311.zip
# 2: /usr/lib/python3.11
# 3: /usr/lib/python3.11/lib-dynload
# 4: /home/timothy/.local/lib/python3.11/site-packages
# 5: /usr/local/lib/python3.11/dist-packages
# 6: /usr/lib/python3/dist-packages
Enter fullscreen mode Exit fullscreen mode

"This list," Margaret explained, "defines where Python looks for modules. And critically, the order matters. Python searches from top to bottom and stops at the first match."

"The first entry is usually the directory of the script you're running, or the current working directory if you're in an interactive session. That's followed by the Python standard library, then any third-party packages you've installed."

Timothy examined the list. "So when I do import utils, Python looks in my project directory first, then the standard library, then installed packages?"

"Exactly. Let's see it in action:"

# Let's trace an import
import sys

def find_module(name):
    """Show where Python would find a module"""
    for path in sys.path:
        potential = f"{path}/{name}.py"
        print(f"Checking: {potential}")
        # In reality, Python also checks for packages, .pyc files, etc.

# Try to find 'os'
find_module('os')
# Checking: /home/timothy/projects/myapp/os.py
# Checking: /usr/lib/python311.zip/os.py
# Checking: /usr/lib/python3.11/os.py  <-- Found here!
Enter fullscreen mode Exit fullscreen mode

"Now," Margaret said, her tone growing serious, "let me show you the most common mistake beginners make."

She pointed to Timothy's project directory listing on the screen:

myapp/
├── main.py
├── utils.py
├── config.py
└── email.py  # !!!
Enter fullscreen mode Exit fullscreen mode

"Timothy, you named a file email.py."

"Yes, it handles sending emails for my application. What's wrong with that?"

Margaret pulled up the Python documentation. "Because email is also a standard library module. When your code—or any third-party library you use—tries to import email, what happens?"

Timothy thought about sys.path. "Python looks at index 0 first, which is my project directory. It finds my email.py and imports that instead of the standard library!"

"Exactly. You've shadowed the standard library. Let me show you the disaster:"

# In your main.py
import email  # Trying to use the standard library

# Trying to use standard library functions
msg = email.message.EmailMessage()
# AttributeError: module 'email' has no attribute 'message'

# Because you imported YOUR email.py, not the standard library!
Enter fullscreen mode Exit fullscreen mode

"This is why," Margaret said firmly, "you should never name your files the same as standard library modules. Never email.py, never random.py, never string.py, never test.py, never json.py."

"But how do I know all the standard library names?" Timothy asked.

"When in doubt, check. Or use more specific names: email_sender.py, my_utils.py, app_config.py. The extra specificity prevents collisions."

Margaret pulled up another example:

# Let's see what happens with shadowing
import sys
import os

# Create a file that shadows a standard library module
with open('random.py', 'w') as f:
    f.write('print("I am a fake random module")')

# Now remove it from cache if it's there
if 'random' in sys.modules:
    del sys.modules['random']

# Try to import
import random
# I am a fake random module

# Try to use it
try:
    print(random.randint(1, 10))
except AttributeError as e:
    print(f"Error: {e}")
    # Error: module 'random' has no attribute 'randint'

# Check where it came from
print(random.__file__)
# /home/timothy/projects/myapp/random.py  (Not the standard library!)
Enter fullscreen mode Exit fullscreen mode

"The shadowing problem is so common," Margaret said, "that experienced developers always check sys.path when mysterious import errors occur."

She showed him how to diagnose it:

import sys

def diagnose_import(module_name):
    """Help find import problems"""
    print(f"Diagnosing import for: {module_name}")
    print(f"\n1. Is it cached in sys.modules?")
    print(f"   {module_name in sys.modules}")

    if module_name in sys.modules:
        mod = sys.modules[module_name]
        print(f"   Location: {getattr(mod, '__file__', 'built-in')}")

    print(f"\n2. Where would Python look?")
    for i, path in enumerate(sys.path[:5]):  # First 5 paths
        print(f"   {i}: {path}")

    print(f"\n3. Check for shadowing:")
    import os
    for path in sys.path[:3]:
        potential = os.path.join(path, f"{module_name}.py")
        if os.path.exists(potential):
            print(f"   Found: {potential}")

diagnose_import('email')
Enter fullscreen mode Exit fullscreen mode

"When debugging import problems," Margaret concluded, "remember: sys.path is Python's map, and the first match wins. Always."


The Machinery: Finders and Loaders

"So Python checks the cache, then searches sys.path for a .py file?" Timothy summarized.

"Not quite," Margaret corrected. "Python doesn't just look for .py files. It uses a more sophisticated system called the import protocol, involving Finders and Loaders."

She drew a diagram in her notebook:

import utils
     ↓
Check sys.modules (cache)
     ↓ (not found)
Ask each Finder in sys.meta_path:
  "Can you find 'utils'?"
     ↓ (Finder returns a ModuleSpec)
Ask the Loader from the ModuleSpec:
  "Load this module"
     ↓ (Loader returns module object)
Store in sys.modules
Return to caller
Enter fullscreen mode Exit fullscreen mode

"Let me show you the machinery," Margaret said.

import sys

# The finders
print("Finders in sys.meta_path:")
for finder in sys.meta_path:
    print(f"  {finder}")

# Output (Python 3.11):
# <class '_frozen_importlib.BuiltinImporter'>
# <class '_frozen_importlib.FrozenImporter'>
# <class '_frozen_importlib_external.PathFinder'>
Enter fullscreen mode Exit fullscreen mode

"There are three standard finders," Margaret explained:

1. BuiltinImporter - Handles built-in modules (written in C, compiled into Python itself)

import sys

# sys is a built-in module
print(sys.__file__)
# None (or 'built-in')

import _thread
print(_thread.__file__)
# None - it's built into the Python interpreter
Enter fullscreen mode Exit fullscreen mode

2. FrozenImporter - Handles frozen modules (Python code compiled and embedded in the interpreter)

import importlib._bootstrap

print(importlib._bootstrap.__file__)
# 'frozen' - embedded in the interpreter
Enter fullscreen mode Exit fullscreen mode

3. PathFinder - The one you interact with most. It searches sys.path for modules

"PathFinder is what actually uses sys.path," Margaret emphasized. "When PathFinder is asked to find a module, it walks through each directory in sys.path, looking for a matching .py file, a package directory, or other module formats. This is why modifying sys.path affects what you can import—you're telling PathFinder where to look."

"PathFinder is what uses sys.path," Margaret explained. "When it searches, it looks for several things in each directory:

  1. A file with the module name and .py extension
  2. A directory with that name containing __init__.py (a package)
  3. Other formats like .pyc (compiled bytecode), .pyd (Windows DLL), .so (Linux shared library)"

She demonstrated:

import sys
from importlib.util import find_spec

# Let's find where a module is
spec = find_spec('json')
print(f"Name: {spec.name}")
print(f"Origin: {spec.origin}")
print(f"Loader: {spec.loader}")

# Output:
# Name: json
# Origin: /usr/lib/python3.11/json/__init__.py
# Loader: <_frozen_importlib_external.SourceFileLoader object>
Enter fullscreen mode Exit fullscreen mode

"The find_spec() function," Margaret said, "does what the finders do. It returns a ModuleSpec—a description of where the module is and how to load it."

"Once a finder returns a spec, the Loader takes over. The loader's job is to actually load the code—read the file, compile it if necessary, execute it, and create the module object."

from importlib.util import spec_from_file_location, module_from_spec

# Create a simple module file
with open('example_module.py', 'w') as f:
    f.write('''
print("Module is being loaded!")

def hello():
    return "Hello from example_module"

CONSTANT = 42
''')

# Find it
spec = spec_from_file_location("example_module", "example_module.py")
print(f"Spec: {spec}")

# Create the module object (doesn't execute yet)
module = module_from_spec(spec)
print(f"Module before execution: {module}")
print(f"Has hello()? {hasattr(module, 'hello')}")  # False

# Now execute it (this runs the code)
spec.loader.exec_module(module)
# Output: Module is being loaded!

print(f"Has hello() now? {hasattr(module, 'hello')}")  # True
print(module.hello())  # Hello from example_module
print(module.CONSTANT)  # 42
Enter fullscreen mode Exit fullscreen mode

"This separation of finding and loading," Margaret explained, "is what makes Python's import system so powerful. You can write custom finders and loaders to import code from anywhere."

She showed an example:

import sys
from importlib.abc import MetaPathFinder, Loader
from importlib.util import spec_from_loader

class DatabaseModuleFinder(MetaPathFinder):
    """A finder that loads modules from a database"""

    def find_spec(self, fullname, path, target=None):
        # Pretend we check a database
        if fullname.startswith('db_module_'):
            return spec_from_loader(
                fullname,
                DatabaseModuleLoader(),
                origin='database'
            )
        return None

class DatabaseModuleLoader(Loader):
    """A loader that creates modules from database data"""

    def create_module(self, spec):
        return None  # Use default module creation

    def exec_module(self, module):
        # Pretend we fetch code from a database
        code = '''
def from_database():
    return "This code came from a database!"
'''
        exec(code, module.__dict__)

# Install our custom finder
sys.meta_path.insert(0, DatabaseModuleFinder())

# Now we can import from the "database"
import db_module_example

print(db_module_example.from_database())
# This code came from a database!
Enter fullscreen mode Exit fullscreen mode

"Custom finders and loaders," Margaret said, "are how frameworks like Django's plugin systems work, how tools like PyInstaller bundle Python apps, and how cloud systems can load code dynamically from remote sources."

Timothy sat back. "So when I write import utils, Python:

  1. Checks sys.modules (the cache)
  2. If not found, asks each finder in sys.meta_path if they can find it
  3. The finder searches using sys.path and returns a ModuleSpec
  4. The loader reads, compiles, and executes the code
  5. The result is stored in sys.modules and returned"

"Perfect," Margaret smiled. "You understand the machinery."


The Paradox: Circular Imports

"Now," Margaret said, turning to a new page in her notebook, "let me show you the import problem that breaks every large Python project eventually: the circular import."

Timothy groaned. "I've seen that error. ImportError: cannot import name 'X' from partially initialized module 'Y'. I never understood what 'partially initialized' meant."

"Let me show you with a concrete example," Margaret said.

app.py:

import models

def get_app_name():
    return "SuperApp"

def run():
    user = models.User("Alice")
    print(f"Welcome to {user}")
Enter fullscreen mode Exit fullscreen mode

models.py:

import app

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"User(name={self.name}, app={app.get_app_name()})"
Enter fullscreen mode Exit fullscreen mode

"Walk me through what happens when I run python app.py," Margaret challenged.

Timothy thought carefully. "Python starts executing app.py. It hits import models, so it needs to load models.py. It starts executing models.py. It hits import app..."

He paused. "But app.py is already being loaded! What happens?"

"This is the key moment," Margaret said. "When Python started executing app.py, it created an entry in sys.modules for app—an empty module object. This prevents infinite recursion. So when models.py asks for app, Python says, 'Here you go, I have an app module!' and returns that empty object."

"But the empty object doesn't have get_app_name() yet," Timothy realized, "because Python hasn't finished executing app.py!"

"Exactly. Let me show you the execution order with print statements:"

app.py:

print("app.py: Starting execution")

import models
print("app.py: Finished importing models")

def get_app_name():
    return "SuperApp"

print("app.py: Defined get_app_name()")

def run():
    user = models.User("Alice")
    print(f"Welcome to {user}")

print("app.py: Defined run()")
Enter fullscreen mode Exit fullscreen mode

models.py:

print("models.py: Starting execution")

import app
print("models.py: Finished importing app")

print(f"models.py: Can I see app.get_app_name? {hasattr(app, 'get_app_name')}")

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"User(name={self.name}, app={app.get_app_name()})"

print("models.py: Defined User class")
Enter fullscreen mode Exit fullscreen mode

"When you run this, you get:"

# python app.py
app.py: Starting execution
models.py: Starting execution
models.py: Finished importing app
models.py: Can I see app.get_app_name? False
models.py: Defined User class
app.py: Finished importing models
app.py: Defined get_app_name()
app.py: Defined run()
Enter fullscreen mode Exit fullscreen mode

"Notice," Margaret emphasized, "that when models.py tries to check for get_app_name(), it's not there yet! The function isn't defined until after models.py has finished loading."

"If you try to use it immediately at module level, you crash:"

models.py (crashes):

import app

# This crashes because get_app_name() doesn't exist yet!
APP_NAME = app.get_app_name()
# AttributeError: module 'app' has no attribute 'get_app_name'
Enter fullscreen mode Exit fullscreen mode

"But if you only use it inside functions that are called later, it works:"

models.py (works):

import app

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        # This is fine! By the time __repr__ is called,
        # app.py will have finished loading
        return f"User(name={self.name}, app={app.get_app_name()})"
Enter fullscreen mode Exit fullscreen mode

"The circular import," Margaret concluded, "fails when you try to use attributes from a module that's still being initialized. Python can't finish initializing module A because it's waiting for module B, which is waiting for module A."


Solving Circular Imports

"How do I fix it?" Timothy asked.

"Three strategies," Margaret said, holding up three fingers.

Strategy 1: Import Inside Functions

"Move the import statement inside the function where you need it. By the time the function is called, both modules will be fully initialized."

models.py:

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        # Import here instead of at module level
        import app
        return f"User(name={self.name}, app={app.get_app_name()})"
Enter fullscreen mode Exit fullscreen mode

"This works but has a cost—Python has to look up the import every time the function is called. For hot paths, this can be slow."

Strategy 2: Import Only What You Need (and late)

"Sometimes you don't need the whole module, just one function or class. Import it late, after both modules are defined."

app.py:

def get_app_name():
    return "SuperApp"

def run():
    # Import here, after get_app_name is defined
    from models import User
    user = User("Alice")
    print(f"Welcome to {user}")
Enter fullscreen mode Exit fullscreen mode

models.py:

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        # Import here, after User is defined
        from app import get_app_name
        return f"User(name={self.name}, app={get_app_name()})"
Enter fullscreen mode Exit fullscreen mode

Strategy 3: Refactor (The Best Way)

"If app needs models and models needs app, they're too tightly coupled. The solution is to extract the shared dependency."

Margaret restructured the code:

config.py:

def get_app_name():
    return "SuperApp"
Enter fullscreen mode Exit fullscreen mode

app.py:

import models
import config

def run():
    user = models.User("Alice")
    print(f"Welcome to {user}")
Enter fullscreen mode Exit fullscreen mode

models.py:

import config

class User:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"User(name={self.name}, app={config.get_app_name()})"
Enter fullscreen mode Exit fullscreen mode

"Now there's no circular dependency. Both app and models depend on config, but config doesn't depend on either of them. This is a directed acyclic graph—the healthy shape for dependencies."

Timothy nodded. "So circular imports are a code smell. They tell me my modules are too coupled."

"Exactly," Margaret agreed. "The import system enforces good architecture by making circular dependencies painful."


Dynamic Imports: importlib

"One last piece of the puzzle," Margaret said. "Sometimes you don't know which module you need until runtime. Maybe you're loading plugins, or the module name comes from a configuration file."

"You can't write import variable," Timothy noted.

"No, but you can use importlib."

import importlib

# The module name comes from somewhere else
module_name = "json"  # Could come from config, database, user input, etc.

# Import it dynamically
module = importlib.import_module(module_name)

# Now use it like any other module
data = {"name": "Alice", "age": 30}
print(module.dumps(data))
# {"name": "Alice", "age": 30}
Enter fullscreen mode Exit fullscreen mode

"This is how plugin systems work," Margaret explained. "Let's build a simple one:"

import importlib
import sys

# Create some plugin modules
with open('plugin_email.py', 'w') as f:
    f.write('''
def run():
    return "Sending email..."
''')

with open('plugin_sms.py', 'w') as f:
    f.write('''
def run():
    return "Sending SMS..."
''')

# Plugin loader
class PluginSystem:
    def __init__(self):
        self.plugins = {}

    def load_plugin(self, plugin_name):
        """Load a plugin by name"""
        # Import it dynamically
        module = importlib.import_module(plugin_name)
        self.plugins[plugin_name] = module
        return module

    def run_plugin(self, plugin_name):
        """Run a plugin"""
        if plugin_name not in self.plugins:
            self.load_plugin(plugin_name)

        return self.plugins[plugin_name].run()

# Use it
system = PluginSystem()

# Load and run plugins
print(system.run_plugin('plugin_email'))  # Sending email...
print(system.run_plugin('plugin_sms'))    # Sending SMS...

# Plugins are cached in the system
print(system.plugins.keys())
# dict_keys(['plugin_email', 'plugin_sms'])
Enter fullscreen mode Exit fullscreen mode

"This is how Flask finds your application, how pytest discovers your tests, and how many frameworks implement extensibility."

Margaret showed another pattern:

import importlib

def import_from_string(path):
    """Import a function/class from a string like 'module.submodule.ClassName'"""
    module_path, _, name = path.rpartition('.')

    # Import the module
    module = importlib.import_module(module_path)

    # Get the attribute
    return getattr(module, name)

# Example usage
json_dumps = import_from_string('json.dumps')
print(json_dumps({"test": "data"}))
# {"test": "data"}

# This is how Django settings work:
# MIDDLEWARE = [
#     'django.middleware.security.SecurityMiddleware',
#     'myapp.middleware.CustomMiddleware',
# ]
# Django uses import_from_string to load each middleware class
Enter fullscreen mode Exit fullscreen mode

Packages and init.py

"Before we finish," Margaret said, "you need to understand packages—directories that Python can import."

She created a directory structure:

myproject/
├── main.py
└── utils/
    ├── __init__.py
    ├── strings.py
    └── numbers.py
Enter fullscreen mode Exit fullscreen mode

utils/init.py:

print("Initializing utils package")

from .strings import uppercase
from .numbers import add

__all__ = ['uppercase', 'add']
Enter fullscreen mode Exit fullscreen mode

utils/strings.py:

def uppercase(text):
    return text.upper()
Enter fullscreen mode Exit fullscreen mode

utils/numbers.py:

def add(a, b):
    return a + b
Enter fullscreen mode Exit fullscreen mode

main.py:

import utils

print(utils.uppercase("hello"))  # HELLO
print(utils.add(2, 3))           # 5
Enter fullscreen mode Exit fullscreen mode

"When you import a package," Margaret explained, "Python runs the __init__.py file. That file can:

  1. Import submodules to make them available
  2. Define what gets exported with __all__
  3. Set up package-level state
  4. Execute initialization code"

"The __init__.py file used to be required," she added. "But since Python 3.3, you can have packages without it—these are called namespace packages. They're particularly useful for splitting a package across multiple directories—like plugin systems where different plugins can contribute modules to the same namespace, or large applications where parts of a package live in different locations."

# With __init__.py, you can do:
from utils import uppercase

# Without __init__.py, you must do:
from utils.strings import uppercase
Enter fullscreen mode Exit fullscreen mode

Conclusion: Master the Import System

Timothy closed his laptop, understanding dawning on his face. "So when I couldn't get rid of that old module, it was because..."

"It was cached in sys.modules," Margaret finished. "Python remembered it even though the file was gone. The solution was to either restart your Python process or explicitly delete it from the cache."

"But there's one more thing," she added. "Check your __pycache__ directory."

Timothy looked puzzled. "What's that?"

"Since Python 3.2, compiled bytecode files—the .pyc files Python creates to speed up imports—are stored in a __pycache__ subdirectory instead of sitting alongside your source files. When you deleted old_utils.py, you probably left behind __pycache__/old_utils.cpython-311.pyc. Python can load from that cached bytecode even without the source file."

She showed him:

import os
import sys

# Check for stale bytecode
def find_pycache():
    for root, dirs, files in os.walk('.'):
        if '__pycache__' in dirs:
            pycache_path = os.path.join(root, '__pycache__')
            print(f"Found __pycache__ at: {pycache_path}")
            for f in os.listdir(pycache_path):
                print(f"  {f}")

find_pycache()
Enter fullscreen mode Exit fullscreen mode

"If you see unexpected .pyc files, delete the entire __pycache__ directory. Python will regenerate it from source on the next import."

Timothy nodded, enlightened. "So my 'haunted' Python was just stale bytecode."

"Exactly. Your ghost was very much alive—just hiding in the cache."

"And the import system is really:"

"Three layers," Margaret said. "First, the cache—sys.modules. Second, the map—sys.path. Third, the machinery—finders and loaders that can get code from anywhere. And don't forget the compiled bytecode in __pycache__—that can trip you up during development."

Timothy nodded. "And when imports fail or behave strangely, I should check:

  1. Is the module cached in sys.modules?
  2. What's in my sys.path and is the order correct?
  3. Am I shadowing a standard library module?
  4. Do I have a circular import?"

"Exactly," Margaret smiled. "The import system seems simple from the outside—just import module. But now you understand the machinery beneath. You know where Python looks, how it caches, and why it sometimes can't find what you're looking for."

She stood up, gathering her books. "Python's import system is designed to be fast, flexible, and extensible. The cache makes it fast. The path makes it predictable. The finder/loader protocol makes it extensible. And importlib makes it controllable."

"What's next?" Timothy asked.

"Well," Margaret said with a smile, "now that you understand how Python finds your code, perhaps you'd like to learn about how Python manages its memory? The garbage collector has secrets of its own..."

Timothy grinned. "The secret life of garbage collection?"

"Indeed," Margaret laughed. "But that's a story for another day."


Key Takeaways

  1. sys.modules is the cache - Python checks here first before searching the filesystem
  2. sys.path is the search map - Python searches these directories in order, stopping at the first match
  3. Order matters in sys.path - Your project directory is usually first, which can shadow standard libraries
  4. Never shadow standard libraries - Don't name files email.py, random.py, json.py, etc.
  5. Circular imports fail at initialization - Module A can't finish loading because it needs module B, which needs module A
  6. Fix circular imports by refactoring - Extract shared code to a third module, or import inside functions
  7. The import protocol uses finders and loaders - Custom finders/loaders can import from anywhere (databases, networks, etc.)
  8. PathFinder uses sys.path - When you modify sys.path, you're telling PathFinder where to look
  9. Use importlib for dynamic imports - When the module name is determined at runtime
  10. Packages use __init__.py - This file controls what a package exports and runs initialization code
  11. importlib.reload() updates existing modules - Better than del sys.modules[name] for development
  12. Check __pycache__ for stale bytecode - Compiled .pyc files can persist after source files are deleted

Common Import Patterns

Check if a module is loaded:

import sys
if 'mymodule' in sys.modules:
    print("Already loaded")
Enter fullscreen mode Exit fullscreen mode

Find where a module is loaded from:

import mymodule
print(mymodule.__file__)
Enter fullscreen mode Exit fullscreen mode

Dynamically import a module:

import importlib
module = importlib.import_module('package.module')
Enter fullscreen mode Exit fullscreen mode

Import a function from a string:

import importlib

def import_from_string(path):
    module_path, _, attr = path.rpartition('.')
    module = importlib.import_module(module_path)
    return getattr(module, attr)

func = import_from_string('json.dumps')
Enter fullscreen mode Exit fullscreen mode

Reload a module during development:

import importlib
import mymodule

# After editing mymodule.py
importlib.reload(mymodule)
Enter fullscreen mode Exit fullscreen mode

Next in The Secret Life of Python: "Garbage Collection and Memory Management"


Discussion Questions

  1. Have you ever accidentally shadowed a standard library module? How long did it take to figure out?
  2. What's the most creative use of dynamic imports you've seen or implemented?
  3. How do you handle circular imports in large projects—local imports, refactoring, or something else?
  4. Should __init__.py be empty or contain package-level code? What's your philosophy?
  5. Have you ever written a custom finder or loader? What was the use case?

Share your import system adventures (and misadventures!) in the comments below!


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)