Why move an `import` statement into a function body instead of the module top?
answer
- An import is code, not a declaration
- Where it sits decides when it runs
- The module body runs once per process
- sys.modules makes repeats a dict lookup
- The failure moves to first call
basics
~20 sTo keep its cost off the startup path. A module-level import runs while the module body runs, so every process pays it; an import inside a function runs only when that function is called, and never if it is not. It also breaks import cycles.
solid answer
~50 sAn `import` statement is executable code, not a declaration. At module top level it runs while the enclosing module's body runs, so a command-line tool pays for it before it has even parsed its arguments. Inside a function body it runs on the first call and, because the module is then cached in `sys.modules`, later calls cost only a dictionary lookup plus a name binding. The three defensible reasons are a heavy dependency used on one rare code path, breaking a circular import, and an optional dependency that may not be installed. The price is that an `ImportError` (and any side effect in the imported module's body) now surfaces at call time rather than at startup, and readers and static tooling no longer see the dependency at the top of the file. For annotation-only imports, guard them with `typing.TYPE_CHECKING` instead.
code
python · 14 linesimport argparse
def main(argv=None):
parser = argparse.ArgumentParser(prog="payroll")
parser.add_argument("--check", action="store_true")
args = parser.parse_args(argv)
if args.check:
from decimal import Decimal # paid only on this path
print(Decimal("1.10") * 3)
return 0
main(["--check"])go deeper
Be ready to say that an import runs when the line runs, and that the module body executes only once per process because it is cached. Knowing that import is a statement, not a compile-time declaration, is the whole foundation here.
Explain the mechanics: the sys.modules check, the one-time body execution, and the fact that later executions of the same statement are just a lookup and a binding. Name the legitimate reasons to defer — rare heavy dependency, circular import, optional dependency — and the price you pay for it.
Show the judgement: which imports stay eager because you want them to fail fast, which move behind a rarely used path, and how you stop the deferral from rotting — a test asserting the heavy module is absent from sys.modules after a fresh import beats a review convention.
Own the policy. Decide where the eager/lazy line sits for a codebase, what the team is allowed to import in a package's top-level module, and whether the readability and fail-fast cost of widespread deferral is worth the startup it buys for your actual invocation pattern.
## An import is a statement, not a declaration `import x` compiles to bytecode that runs where it is written. Running it does this: look `x` up in `sys.modules`; on a hit, bind the already-loaded module object to the name and stop; on a miss, locate the module, compile it if there is no usable cached bytecode, execute its body top to bottom in a fresh module namespace, insert the result into `sys.modules`, and only then bind the name. Everything expensive lives in the miss path — locating, compiling, executing the body, and recursively doing all of that for whatever the body itself imports. It happens **once per process**. Every subsequent execution of the same statement is a dict lookup and a `STORE_FAST`/`STORE_NAME`. That is the whole basis of the decision. The question is never "how often does this import run", it is **"does this process run it at all, and when"**. ## What top level actually costs A module's body runs the moment something first imports it. For a command-line tool, the entry module's imports run before `argparse` has looked at `sys.argv` — so `--help` pays for the full dependency tree of every subcommand, including the ones the user did not ask for. In a short-lived process invoked once per job, that cost is the dominant cost. ## The four defensible reasons to move it down 1. **Heavy and rare.** A module needed only by one subcommand, one error path, or one debug flag. Moving it into that function moves its cost onto the users who asked for it. 2. **Circular imports.** Two modules that need each other at call time but not at definition time. Deferring one side to a function body lets both module bodies finish before either needs the other's attributes. 3. **Optional dependency.** Code that must import successfully on a machine where the extra is not installed, degrading gracefully in a `try`/`except ImportError` inside the function. 4. **Backend selection at runtime.** Choosing among alternative implementations after reading configuration, where importing all candidates eagerly would be waste. Anything else — including "it looks tidier" — is not a reason. PEP 8 puts imports at the top for a reason: that is where a reader looks to learn what a module depends on. ## What it costs you **The failure moves.** An `ImportError`, a missing shared library, or a module body that raises now fires on the first call rather than at startup. A tool that used to die immediately with a clear message can instead die twenty minutes into a run. Cover the deferred path with a test, or probe the import once at startup behind a flag. **Side effects move with it.** If the imported module's body opens a file, reads an environment variable, configures logging or registers something globally, that now happens at an unpredictable moment, possibly inside a worker thread, possibly after your own configuration has already been applied. **The per-call lookup is small but not free.** After the first call the statement is a `sys.modules` hit plus a binding — well under a microsecond, but it is inside the function. If the function is genuinely hot, do the import once and cache the result: bind it to a module-level global on first use, or wrap the lookup in a `functools.cache`-decorated accessor. **Tooling loses sight of it.** Dependency scanners, IDE navigation and some linters read top-level imports. A buried import is a real dependency that no longer looks like one. ## Annotations are a special case An import that exists only to name a type in an annotation does not need to run at all. Put it under `if typing.TYPE_CHECKING:` — a constant that is `False` at runtime and `True` for type checkers. On **Python 3.14** this got easier: under PEP 649/749 annotations are evaluated lazily, so the annotation expression is not executed when the function or class is defined and an unquoted name from a `TYPE_CHECKING` block no longer raises `NameError`. On 3.13 and earlier the same annotation had to be a string literal, or the module needed `from __future__ import annotations`. ## How to decide in practice Measure before you move anything, and move the branch with the largest cumulative cost rather than the module with the longest name. Keep an eager core — the handful of modules every invocation needs — and push the rest behind the functions that use them. Then write a test that asserts the heavy module is absent from `sys.modules` after importing your package, so the next well-meaning refactor cannot quietly pull it back to the top.
- If the function is called in a tight loop, does the repeated import statement matter?Only marginally. After the first execution the statement is a `sys.modules` dictionary lookup plus a name binding — sub-microsecond, but it is inside the loop's body. If profiling shows it, hoist it: import once into a module-level global on first use, or put the lookup behind a small accessor decorated with `functools.cache`. Do not remove the deferral to fix a cost you have not measured.
- What can go wrong when a deferred import is executed from several threads at once?The import system takes a per-module lock, so two threads importing the same module do not run its body twice; one waits and both get the finished module. The hazard is circularity: if a partially initialized module is already on the stack in another thread, a thread can be handed a module whose body has not finished, so an attribute it expects is missing. Deferring one leg of a cycle usually fixes that; deferring both can hide it until production.
- Why is `typing.TYPE_CHECKING` preferable to just importing the type at the top?Because the import exists only to give a type checker a name. Guarding it with `if TYPE_CHECKING:` keeps the module out of the runtime dependency graph entirely — no execution, no startup cost, and no import cycle when the annotated type lives in a module that imports this one back. On 3.14 the annotation can stay unquoted, since annotations are no longer evaluated when the function is defined.
Top-level imports are the tools you carry in your hands to the job; function-body imports are the ones left in the van and fetched only if that job actually comes up.
saying these in an interview costs you the question
- Claims a module-level import runs on every function call
- Says an import inside a function re-executes the module body each time
- Moves every import into functions as a blanket style rule
- Ignores that ImportError now surfaces at call time
- Forgets that the imported module's side effects move too
- Uses a runtime import purely to satisfy an annotation