- Import-time side effects (env var reads, DB connections, plugin registration) cause tests to pass locally but fail in CI when execution order changes.
- Defer initialization with lazy functions, lru_cache wrappers, and explicit setup calls instead of module-level execution.
- Use pytest-randomly to shuffle test order and catch import dependency bugs before they reach production.
- Python 3.13 per-interpreter parallelism makes import side effects more likely to surface in test suites.
The Test That Passed on My Laptop But Failed in CI
Your test suite runs green locally. You push to CI. The build fails with AttributeError: module 'app.config' has no attribute 'DATABASE_URL'. You run the exact same test locally again — still passes. This isn’t a flaky test or a race condition. It’s an import side effect, and it only shows up when test execution order changes.
Import side effects are code that runs at module import time and modifies global state. In development, you usually import modules in the same order every time. Your IDE loads them, your manual test runs load them, and everything works. But test runners like pytest shuffle test collection order, run tests in parallel, or isolate imports per test. Suddenly, the code that worked fine during development explodes.
I’m going to walk through four real patterns I’ve debugged where import side effects caused test failures that were invisible in local development. Each one has a specific symptom, a root cause, and a fix.

Pattern 1: Environment Variable Reading at Import Time
This is the most common one. You have a config.py that reads environment variables when the module loads:
# config.py
import os
DATABASE_URL = os.environ['DATABASE_URL']
API_KEY = os.environ['API_KEY']
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
And your application code imports it:
# app.py
from config import DATABASE_URL, API_KEY
def connect_db():
return psycopg2.connect(DATABASE_URL)
In development, you run your app with environment variables already set. You might have a .env file loaded by your IDE, or you manually exported them in your shell. Every time you run the app or a test, DATABASE_URL is already in the environment when config.py gets imported.
But in CI, or when a teammate runs tests in a fresh environment, config.py gets imported before the test setup runs. The test might have a fixture that sets environment variables:
# test_app.py
import pytest
import os
def test_connect_db():
os.environ['DATABASE_URL'] = 'postgresql://localhost/testdb'
os.environ['API_KEY'] = 'test-key'
from app import connect_db # Import happens AFTER setting env vars
conn = connect_db()
assert conn is not None
This test passes if you run it in isolation. But if another test imports app first, before this test sets the environment variables, the import of config.py happens too early and raises KeyError: 'DATABASE_URL'.
The fix is to defer the environment variable reading until it’s actually needed:
# config.py
import os
from functools import lru_cache
@lru_cache(maxsize=None)
def get_database_url():
return os.environ['DATABASE_URL']
@lru_cache(maxsize=None)
def get_api_key():
return os.environ['API_KEY']
def is_debug():
return os.environ.get('DEBUG', 'False') == 'True'
Now app.py calls these functions instead of importing module-level constants:
# app.py
from config import get_database_url
def connect_db():
return psycopg2.connect(get_database_url())
The @lru_cache ensures you still only read the environment variable once, but the read happens the first time the function is called, not at import time. Tests can now set environment variables in setup, and as long as they call get_database_url() after setup, it works.
Pattern 2: Module-Level Database Connections
Another classic: you open a database connection at module level because it’s convenient.
# db.py
import psycopg2
from config import DATABASE_URL
connection = psycopg2.connect(DATABASE_URL) # Runs at import time
cursor = connection.cursor()
def query_user(user_id):
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
In development, you start your app, it imports db.py, opens a connection, and everything works. You manually test some endpoints, they all use the same connection object, no problem.
In tests, especially if you’re using fixtures that mock the database or spin up a test database, this breaks. The import happens before the fixture runs, so connection points to the wrong database or fails entirely if the real database isn’t available.
Even worse: pytest’s test isolation might reload modules between tests. If db.py is imported multiple times, you might open multiple connections and leak file descriptors. I’ve seen test suites that passed for the first 20 tests, then failed with psycopg2.OperationalError: FATAL: sorry, too many clients already because they opened 100+ connections without closing them.
The fix is to make the connection lazy and reusable:
# db.py
import psycopg2
from config import get_database_url
_connection = None
def get_connection():
global _connection
if _connection is None or _connection.closed:
_connection = psycopg2.connect(get_database_url())
return _connection
def query_user(user_id):
conn = get_connection()
with conn.cursor() as cursor:
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
Now tests can inject a mock or test database before calling query_user(), and the connection won’t open until it’s actually needed. You can also add a close_connection() helper for test teardown.

Pattern 3: Registering Plugins or Handlers at Import Time
This one is subtle. You have a plugin system where modules register themselves:
# plugin_registry.py
PLUGINS = []
def register_plugin(plugin):
PLUGINS.append(plugin)
# plugins/image_processor.py
from plugin_registry import register_plugin
class ImageProcessor:
def process(self, data):
# ...
pass
register_plugin(ImageProcessor()) # Runs at import time
# plugins/video_processor.py
from plugin_registry import register_plugin
class VideoProcessor:
def process(self, data):
# ...
pass
register_plugin(VideoProcessor()) # Runs at import time
Your main app imports all plugins:
# app.py
import plugin_registry
import plugins.image_processor
import plugins.video_processor
def process_all(data):
for plugin in plugin_registry.PLUGINS:
plugin.process(data)
In development, you start the app, all plugins get imported and registered, everything works.
In tests, the problem is twofold:
- If one test imports
plugins.image_processorbut notplugins.video_processor, onlyImageProcessoris registered. Another test that expects both plugins will fail. - If tests run in parallel, they might import plugins in different orders, leading to non-deterministic failures.
I debugged a case where test A imported plugins.image_processor first, test B imported plugins.video_processor first, and they both modified the same global PLUGINS list. In sequential runs, test A always ran first so PLUGINS was always [ImageProcessor, VideoProcessor]. In parallel runs, the order was random and tests failed intermittently.
The fix is to make plugin registration explicit and idempotent:
# plugin_registry.py
PLUGINS = {}
def register_plugin(name, plugin_class):
if name not in PLUGINS:
PLUGINS[name] = plugin_class
def get_plugin(name):
return PLUGINS[name]
def clear_plugins(): # For test teardown
PLUGINS.clear()
# plugins/image_processor.py
class ImageProcessor:
def process(self, data):
pass
# Don't register here — let the app do it explicitly
# app.py
import plugin_registry
from plugins.image_processor import ImageProcessor
from plugins.video_processor import VideoProcessor
def initialize_plugins():
plugin_registry.register_plugin('image', ImageProcessor())
plugin_registry.register_plugin('video', VideoProcessor())
def process_all(data):
for plugin in plugin_registry.PLUGINS.values():
plugin.process(data)
Now tests can call initialize_plugins() in setup and clear_plugins() in teardown, ensuring a clean state every time.
Pattern 4: Monkey-Patching Third-Party Libraries at Import Time
Sometimes you need to patch a third-party library to fix a bug or add a feature. You might do it at module level:
# patches.py
import requests
original_get = requests.get
def patched_get(url, **kwargs):
# Add custom retry logic
for attempt in range(3):
try:
return original_get(url, **kwargs)
except requests.ConnectionError:
if attempt == 2:
raise
return None
requests.get = patched_get # Monkey-patch at import time
# app.py
import patches # Must be imported first
import requests
def fetch_data(url):
return requests.get(url).json()
In development, you always import patches before anything else, so requests.get is always the patched version.
In tests, if a test imports requests directly without importing patches first, it gets the original requests.get and the retry logic doesn’t run. Or worse, if one test imports patches and another doesn’t, they’re testing different behavior.
I’ve also seen this break when pytest’s import reloading causes requests to be reimported, resetting the monkey-patch. The first test runs with the patch, the second test runs without it, and you get a requests.ConnectionError in CI that you can’t reproduce locally.
The fix is to use unittest.mock.patch or a similar context manager that applies the patch only for the duration of the test:
# test_app.py
import pytest
from unittest.mock import patch, MagicMock
from app import fetch_data
def test_fetch_data_with_retry():
with patch('requests.get') as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {'key': 'value'}
mock_get.return_value = mock_response
result = fetch_data('https://example.com')
assert result == {'key': 'value'}
If you genuinely need the patch in production code, not just tests, consider using a wrapper function instead of monkey-patching:
# http_client.py
import requests
def get_with_retry(url, **kwargs):
for attempt in range(3):
try:
return requests.get(url, **kwargs)
except requests.ConnectionError:
if attempt == 2:
raise
return None
# app.py
from http_client import get_with_retry
def fetch_data(url):
return get_with_retry(url).json()
Now there’s no global state, no import order dependency, and tests can mock http_client.get_with_retry cleanly.
How to Detect Import Side Effects Before They Break Tests
You can catch most of these issues by running your tests with pytest-randomly, which shuffles test order every run:
pip install pytest-randomly
pytest --randomly-seed=12345
If your tests pass consistently with a fixed seed but fail with random seeds, you have import side effects or global state leaks.
Another approach: run tests with importlib.reload() to force module reimports between tests. This is more aggressive and can catch issues that pytest-randomly misses:
# conftest.py
import sys
import importlib
import pytest
@pytest.fixture(autouse=True)
def reload_modules():
modules_before = set(sys.modules.keys())
yield
modules_after = set(sys.modules.keys())
new_modules = modules_after - modules_before
for module_name in new_modules:
if module_name.startswith('app.'): # Only reload your own modules
importlib.reload(sys.modules[module_name])
This is overkill for most projects, but if you’re debugging a particularly nasty import issue, it helps isolate the problem.
Why This Matters More in 2026
Python 3.13 introduced per-interpreter GIL (PEP 703), which means test runners can now truly parallelize test execution across interpreters without hitting the GIL bottleneck. Tools like pytest-xdist are already using this to run tests faster.
But that also means import side effects are more likely to surface, because different interpreters might import modules in different orders. If your test suite passes today but fails after upgrading to Python 3.13 with parallel test execution, import side effects are the first thing to check.
I’d also recommend checking out .pyi vs beartype vs typeguard: 43% Runtime Overhead if you’re using runtime type checking — those tools can add their own import-time overhead that interacts weirdly with test isolation.
FAQ
Q: Can I just set environment variables at the top of conftest.py to avoid import-time issues?
Yes, but only if conftest.py is imported before any application code. Pytest loads conftest.py early, but if another test file imports your app before pytest finishes setup, you’re back to the same race condition. The safer approach is lazy initialization — defer reading environment variables until they’re actually needed.
Q: How do I know if a third-party library has import side effects?
Check the library’s __init__.py for any code that runs at import time: database connections, logging configuration, environment variable reads, monkey-patching. Libraries like Django and Flask have well-known import side effects (Django’s setup(), Flask’s app context), so read their testing docs carefully. When in doubt, wrap the import in a function and delay it.
Q: Does this affect type checkers like mypy or pyright?
Not directly — type checkers do static analysis and don’t execute import-time code. But if you’re using runtime type checking with beartype or typeguard, those do run at import time and can trigger side effects. I’ve seen cases where a module with @beartype decorators imported a config module, which read environment variables, which failed during type checking because the type checker didn’t have env vars set. The fix is the same: lazy initialization.
When to Ignore This Advice
If you’re writing a small script or a notebook, import-time side effects are fine. The problems only show up in test suites with isolation, parallelization, or shuffled execution order. For one-off scripts, just set your environment variables and move on.
I’m personally still figuring out the best patterns for FastAPI apps with dependency injection. FastAPI’s Depends() system is supposed to handle lazy initialization, but I’ve run into edge cases where startup events run at import time and break tests. If you’ve solved this cleanly, I’d be curious to hear how. Debugging at 2am and need a productivity boost? Dark Chocolate Espresso Beans have saved more test debugging sessions than I’d like to admit.
For production code: defer everything you can. Read environment variables lazily, open connections lazily, register plugins explicitly. Your test suite will thank you, and so will the next person who tries to run your code in a fresh environment.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,880 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (967 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (886 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (828 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (636 views)