import settings looks declarative, but it’s an instruction to run code. The first time a module is imported in a process, Python executes its top-level statements from top to bottom, stores the resulting module object in sys.modules, and binds the name. Everything at the top level runs then: function and class definitions, but also any print, file read, network call or environment lookup someone put there. Most import problems in real projects come from that work happening earlier, or in a different order, than the author assumed. Interviewers use this topic to see whether you understand Python’s execution model beyond syntax.
All examples below are small files run with Python 3.14.
What the first import does, and what the second doesn’t
Here’s a settings module that reads an environment variable:
# settings.py
import os
print("settings: module body running")
DB_URL = os.environ.get("DB_URL", "sqlite:///dev.db")And a script that sets the variable first, then imports twice:
# app.py
import os
os.environ["DB_URL"] = "postgresql://prod"
import settings
import settings # second import is a dict lookup
import sys
print("cached:", "settings" in sys.modules)
print("DB_URL:", settings.DB_URL)settings: module body running
cached: True
DB_URL: postgresql://prodThe body ran once. The second import settings found the module in sys.modules and did nothing else. That cache is why module-level state behaves like a singleton, and why importlib.reload exists for the rare cases where you want the body to run again.
Now reverse the order, which is what happens when the import sits at the top of a file (where style guides tell you to put it) and the environment is set later:
# app2.py
import settings
import os
os.environ["DB_URL"] = "postgresql://test"
print("DB_URL:", settings.DB_URL)settings: module body running
DB_URL: sqlite:///dev.dbDB_URL was computed during the import and never looked at again. This is the shape of a common test failure: a test fixture sets DB_URL for an isolated database, but some module imported during test collection has already read the variable, so tests hit the development database. It’s also why CLI tools that parse --config in main() sometimes find that half the program used the defaults.
The fix is to move the read from import time to call time. A function such as def get_settings(): return Settings(db_url=os.environ.get(...)), possibly cached with functools.cache, defers the work until someone actually needs it, and gives tests a single place to reset.
from-imports copy bindings
A related trap concerns from module import name. It doesn’t create a live link; it copies the current binding into the importing module’s namespace:
# db.py
from settings import DB_URL
def connect():
return f"connecting to {DB_URL}"import settings, db
settings.DB_URL = "postgresql://test"
print(db.connect())
db.DB_URL = "postgresql://test"
print(db.connect())settings: module body running
connecting to sqlite:///dev.db
connecting to postgresql://testChanging settings.DB_URL had no effect on db, which holds its own name pointing at the original string. Only rebinding db.DB_URL worked. This is the same rule that makes unittest.mock.patch confusing: you patch the name where it’s looked up, not where it was defined. patch("db.DB_URL", ...) works here and patch("settings.DB_URL", ...) doesn’t. Interviewers love this question because it tests whether you understand names as bindings rather than variables as boxes.
The main guard
When you run a file directly, Python executes it with __name__ set to "__main__". When another module imports it, __name__ is the module’s name. The guard uses that difference:
# tool.py
def main():
print("running the CLI")
print("__name__ is", __name__)
if __name__ == "__main__":
main()$ python3 tool.py
__name__ is __main__
running the CLI
$ python3 -c "import tool"
__name__ is toolWithout the guard, importing tool to reuse one function would run the whole CLI. The guard matters even more with multiprocessing on macOS and Windows, where the default start method (spawn) imports the main module in each child process; unguarded top-level code that starts workers will try to start workers recursively and fail.
Circular imports and partial modules
Because a module is added to sys.modules before its body finishes, a second module can import a half-built one. Two modules in a shop package that import from each other:
# shop/orders.py
from shop.customers import Customer
class Order:
pass# shop/customers.py
from shop.orders import Order
class Customer:
passTraceback (most recent call last):
File "main.py", line 1, in <module>
import shop.orders
File "shop/orders.py", line 1, in <module>
from shop.customers import Customer
File "shop/customers.py", line 1, in <module>
from shop.orders import Order
ImportError: cannot import name 'Order' from partially initialized module 'shop.orders' (most likely due to a circular import) (shop/orders.py)Follow the steps. Importing shop.orders registers an empty module and starts running its first line, which imports shop.customers. That module’s first line asks shop.orders for Order, but orders is paused on line 1, before class Order has run. The name doesn’t exist yet.
One thing worth knowing about 3.13 and later: if the two files sit directly in the script’s directory instead of in a package, the same mistake can produce a misleading hint. Running the flat version here gave cannot import name 'Order' from 'orders' (consider renaming 'orders.py' if it has the same name as a library you intended to import). The real cause is still the cycle; read the traceback’s import chain, not just the last line.
Fixes, roughly in order of preference:
- Restructure. Move the shared piece (often a base class or type definitions) into a third module both can import. Cycles usually point to a boundary that isn’t quite right.
- Import the module, not the name. With
from shop import ordersat the top andorders.Order()inside a method, the attribute is looked up when the method runs, by which time both modules are complete. Running that version prints<shop.orders.Order object at 0x104c5a3c0>. - Import inside the function that needs it, accepting a small cost per call after the first.
- For type hints only, use
if typing.TYPE_CHECKING:imports, which never run at runtime.
Import cost and startup time
Side effects aren’t only about correctness. Every top-level statement is paid for by every process that imports the module, including CLIs that just print --help and test runs that touch one function. python3 -X importtime reports microseconds per module. On this machine, import json took about 4 ms cumulatively and import asyncio about 60 ms. A CLI that imports pandas, boto3 or a machine-learning library at the top of its entry point can spend more than a second before doing anything. Lazy imports inside the functions that need them are the usual remedy for heavy, rarely used dependencies.
Interview practice: the test that hits production
A Flask app’s db.py has engine = create_engine(os.environ["DATABASE_URL"]) at the top level. The test suite’s conftest.py sets DATABASE_URL to a test database in a fixture. Some test runs connect to the developer’s local database instead. Why, and how would you restructure it?
Answer
create_engine runs when db.py is first imported, and pytest imports test modules (and whatever they import) during collection, before fixtures run. If any collected module imports db, the engine is built from whatever DATABASE_URL was in the shell at that moment. Whether that happens depends on which tests were collected, which is why it’s intermittent. Two changes fix it. Make engine creation lazy and explicit, for example an application factory create_app(config) or a get_engine() function cached after the first call, so the URL is read when the app starts rather than when a module loads. Then have the fixture pass the test URL in directly instead of relying on environment ordering. As a guard rail, the test configuration can refuse to run if the URL doesn’t point at a test database. The general principle to state: modules should define things at import time and do things when called.