skip to content

How would you set and defend a cold-start budget for a Python CLI a small team owns?

level: principalimportance: should knowfreq 28%

answer

  1. Cost matters multiplied by invocation count
  2. Budget a specific cheap command
  3. Measure on the deployment interpreter
  4. Policy beats one-off fixes
  5. A CI assertion outlives a convention

basics

~20 s

Pick a number tied to how the tool is invoked, measure it on the deployment interpreter with a representative cold run, then enforce it automatically — a test asserting heavy modules are absent from sys.modules outlasts any review convention.

solid answer

~60 s

Start from invocation pattern, not instinct. A tool run once per payroll batch can start in a second unnoticed; the same tool invoked once per CSV row, or per commit in a hook, cannot. Turn that into a written budget against a specific command — usually `--help`, the cheapest thing the tool can be asked to do, so it measures startup and nothing else. Measure with `-X importtime` on the interpreter and machine you deploy to, twice, so bytecode caching does not flatter the first run. Then set policy rather than fixing lines: a small eager core, heavy dependencies deferred into the subcommands that need them, no work in module bodies — no config reads, no directory scans, no client construction at import. Enforce it in CI with a check a four-person team cannot forget, such as importing the package in a fresh interpreter and asserting a named heavy module is not in `sys.modules`. And be willing to say the budget does not apply: a long-running service pays startup once, and effort spent there is effort stolen from real work.

code

python · 5 lines
python
import subprocess
import sys

probe = "import csv, sys; assert 'decimal' not in sys.modules, 'decimal reached startup'"
subprocess.run([sys.executable, "-X", "importtime", "-c", probe], check=True)

go deeper

for a junior

Know that a Python process pays for its imports every time it starts, and that a tool run many times a day feels this while a long-running service does not. Recognising the difference is the entry point to the whole discussion.

for a middle

Be able to turn a complaint into a measurement and a fix: profile a cold run, find the biggest cumulative branch, move it into the code path that needs it, and re-measure. Explain why work done in a module body counts as startup cost too.

for a senior

Demonstrate that you make the fix stick. Talk about measuring on the deployment interpreter, establishing the floor an empty script costs, and adding an automated check whose failure names the regressing import rather than just reporting a slower number.

for a principal

Own the tradeoff and the decision to stop. Tie the budget to the invocation pattern, state what deferral costs in fail-fast behaviour and tooling accuracy, decide what the team is allowed to import at package top level, and be willing to say the budget does not apply here and record why.

## Start from the invocation pattern Startup cost only matters multiplied by how often a process starts. The same 900 milliseconds is invisible in a service that starts once a week, irritating in a tool a person runs twenty times an hour, and disqualifying in something invoked per unit of work — a hook that fires on every commit, a per-record subprocess, a job runner that spawns a fresh interpreter for each item. So the first output is not a number, it is a sentence: *"this tool is invoked once per payroll batch, roughly a dozen times a day, by four people."* Everything else follows. That sentence is also what stops the budget from becoming folklore — when someone proposes a heavy dependency two years later, the sentence is what the decision is checked against, not a remembered target. ## Choose the number, and the command it applies to A budget needs a measurable subject. `--help` is usually the right one: it is the cheapest thing the tool can be asked to do, so what it measures is almost purely startup, and every CLI has it. Pin the budget to a percentile of a representative cold invocation on the interpreter and hardware you deploy to, not on a developer laptop. Say the number out loud and write it where the decision lives — the contributing guide, the CI job, the test's assertion message. "Under 300 ms for `--help` on the deployment image" is a budget. "Startup should be fast" is not. ## The three levers, in order of payoff 1. **Do not import it at all.** The largest wins are dependencies the tool does not really need, or needs on one path. A dependency added for one subcommand is paid by every invocation until someone moves it. 2. **Import it later.** Move a heavy import into the subcommand function that uses it; for a package whose public API must stay stable, expose the names through a module-level `__getattr__` that imports on first access. Cumulative time in an `-X importtime` report is what a deferral actually buys. 3. **Stop doing work in module bodies.** Import cost is not only imports. Module bodies that read configuration, scan a plugin directory, build large tables, construct clients, or open files spend real time at import — and, worse, they make the cost unpredictable and order-dependent. Push that work into functions and let the caller decide when it happens. ## Enforcement is the whole game Every startup fix decays. Someone adds a convenient top-level import, and the number creeps back. A four-person team cannot police this in review — nobody remembers to check, and the reviewer who does becomes the bottleneck. Two mechanisms work, and they are complementary: * **A negative-import assertion.** In a fresh interpreter, import your package and assert that a named heavy module is *not* in `sys.modules`. This is fast, deterministic, immune to machine speed, and it fails with a message that names exactly what regressed. It is the check to write first. * **A timing ceiling.** Run the real command and assert a wall-clock bound. Honest, but noisy on shared CI runners; give it generous headroom so it catches a doubling rather than a drift, and expect to tune it. Add the report itself to the failure output: printing `-X importtime` when the check fails turns "startup got slower" into "this module is why". ## Know what you are trading away Deferral is not free, and a lead should say so plainly. Errors move from startup to first use, so a broken installation may not announce itself until a rarely used path runs — the classic version of this is a boundary case at the end of a file that only some inputs reach, discovered in production rather than at launch. Static analysis and IDE navigation degrade. `__init__.py` stops describing the package's real shape. Every lazy path needs a test that actually walks it, or laziness becomes a way of hiding broken code. There is also a floor you cannot optimise past: the interpreter's own startup, the site initialisation, and whatever your smallest honest dependency set costs. Chasing below it wastes effort. Measure the floor first — an empty script on the same interpreter — and set the budget above it. ## Be willing to decide it does not matter The strongest version of this answer includes refusing the work. If the tool is a long-running process, or is invoked rarely by a human who is about to wait on a slow operation anyway, startup optimisation is a cost with no user on the other end. Say what the budget buys and for whom. If the answer is "nobody notices", the right decision is to spend the team's attention on something that does — and to write down that the question was asked and settled, so it is not re-opened every quarter.

  • Why prefer asserting a module is absent from `sys.modules` over asserting a wall-clock ceiling?
    Because it is deterministic and diagnostic. A timing bound depends on the CI machine's load and needs slack so wide that it only catches disasters; an absence assertion fails the moment someone reintroduces the import, runs in milliseconds, and its failure message names the culprit. Keep a loose timing check as a backstop for costs that are not any single import, such as work done in a module body.
  • What would make you refuse this work outright?
    A long-running process. A service or worker pays startup once per deployment, so hundreds of milliseconds are irrelevant next to anything it does afterwards, and the deferrals buy nothing while costing fail-fast behaviour and readability. I would record that the question was raised, state the invocation pattern that settles it, and put the effort where a user is actually waiting.
  • How do you keep laziness from hiding broken code paths?
    Test the deferred paths for real. A lazy import only fails when its path executes, so a subcommand nobody exercises in CI can ship with a missing dependency or a broken module body. Every deferred branch needs a test that calls it, plus a smoke check in the release pipeline that runs each subcommand's cheapest real invocation. If a path is too obscure to test, it is too obscure to make lazy.

saying these in an interview costs you the question

  • Optimises startup without asking how often the process starts
  • Sets a budget with no named command to measure
  • Benchmarks on a laptop, ships to slower hardware
  • Relies on code review to prevent regressions
  • Makes everything lazy and never tests the lazy paths
  • Ignores work done in module bodies, only counts imports

context