Why prefer a try/except ImportError probe over a sys.version_info gate for optional features?
answer
- Shipped upstream is not installed here
- Ask the environment, not the release notes
- Keep the guarded block one line wide
- A silent fallback is an invisible outage
- Syntax fails before any handler runs
basics
~10 sA version number only says what the release shipped upstream; the probe tests what this environment actually has. Stripped builds, split system packages and other interpreters report the right version with the module absent.
solid answer
~50 sA version comparison answers "did this release ship the feature?", which is not the question — the question is "is it importable here?" A minimal container image, a system package that splits the standard library, or a non-CPython interpreter can all report 3.14 with the module absent, and the version gate then takes the wrong branch. `try: import x / except ImportError:` tests exactly what the code needs, bound once at import time. Two disciplines make it safe: keep the `try` around the import line only, so an `ImportError` raised *inside* the module is not mistaken for "absent"; and never let the fallback be silent — log which path was chosen, because a quietly-degraded worker is far harder to diagnose than an exception. Version checks remain necessary for syntax-level features, which fail at compile time.
code
python · 14 linesimport logging
try:
from concurrent import interpreters
except ImportError:
interpreters = None
logging.getLogger(__name__).warning("subinterpreters unavailable; using threads")
def pool_kind() -> str:
return "interpreters" if interpreters is not None else "threads"
print(pool_kind())go deeper
Learn the shape: try the import, bind None in the except branch, and check that name later. Do not write an empty except that hides the reason the import failed.
Explain why the two checks differ — a release shipping a module is not the same as this environment having it — and why the guarded block should cover the import statement only.
Bring the production judgement: where the probe is evaluated, how the chosen path is logged and monitored, when a missing optional feature should fail the deploy instead of degrading, and how both branches get exercised in CI.
Own the policy across services: how many optional-capability branches the codebase tolerates, whether the runtime image is pinned tightly enough that probing is redundant, and who is accountable when a degraded path ships silently.
## The two questions are not the same question `sys.version_info >= (3, 14)` answers: *did the language release that this interpreter claims to implement ship the feature upstream?* What your code needs to know is: *can I import it, here, right now?* Those coincide most of the time, which is exactly what makes the gap dangerous — it is not visible in development and shows up in one deployment. Ways they come apart in production: - **A stripped-down runtime image.** Minimal images and vendored interpreters routinely drop parts of the standard library to save space. The version is right; the module is gone. - **A system package that splits the standard library.** Some platform packaging splits pieces of the stdlib into separately-installable OS packages; a container that installs only the base package reports a complete version number with holes underneath. - **Another implementation at the same language level.** It can report the language version faithfully while lacking a CPython-specific module or an accelerator. - **The module imports but the attribute you need does not exist**, because a function was added in a later patch release than the one you gated on. - **A backported provider on an older interpreter**, where the feature *is* available below your version floor and the gate needlessly takes the slow path. A probe has none of these failure modes, because it asks the environment the question the code actually has: ```python try: from concurrent import interpreters except ImportError: interpreters = None ``` Bind the result to a module-level name once, at import time. Probing inside a function that runs per work item pays the `sys.modules` lookup and the exception machinery over and over, and worse, it scatters the decision across the codebase instead of concentrating it in one line you can log. ## The failure that makes people careful about this An image-thumbnail worker gated an optional codec on a version comparison and wrapped its whole setup block in `except ImportError: pass`. On the build machine the primary path ran. In the deployed image the module was absent, the handler swallowed the error, and a fallback object stayed bound to a partially-initialised default that processed only the first chunk of each job. A 6,800-row batch came back as a silent truncation: no exception, no non-zero exit, just a shortfall nobody noticed until the counts were compared days later. Three things went wrong, and all three are avoidable: 1. **The gate tested the wrong proposition** — a release number instead of an import. 2. **The `try` block was too wide.** A broad `try` around several statements means an `ImportError` raised *inside* one of the imported modules — a genuinely broken dependency deeper down — is reported as "the optional feature is absent". Keep the block to the import statement alone. Where you specifically mean "not installed", catch `ModuleNotFoundError`, the narrower subclass of `ImportError`; a plain `ImportError` also covers "the module exists but a name inside it does not". 3. **The fallback was silent.** A degradation that emits nothing is invisible in production. Log the chosen path at startup, expose it in a health or version endpoint, and — if the fallback is not actually acceptable for the job — raise at import time rather than degrade. Failing loudly at start-up beats truncating output for a week. ```python import logging try: from compression import zstd except ImportError: zstd = None logging.getLogger(__name__).warning("zstd codec unavailable; using the slower codec") ``` ## Probing without importing `importlib.util.find_spec("compression.zstd")` returns `None` when the module cannot be found, and does so without executing it. That is useful when the import is expensive, or when you want to report availability without paying for the module. Note that finding a spec for a submodule imports the parent package, and that a spec being findable is not quite proof the import will succeed — the module body can still fail. When you are going to use the module anyway, importing it inside `try` is both simpler and stronger evidence. ## Where a version check is still the right tool The probe pattern has one hard limit: **syntax**. A module using a construct the running interpreter cannot parse raises `SyntaxError` at compile time, before a single statement of it executes, and no `except ImportError` anywhere will catch that. The standard answer is to isolate the newer syntax in its own module and gate the *import of that module* on a version comparison, or to ship separate modules per version floor. Version checks also stay correct for behavioural changes with no importable marker — the default `multiprocessing` start method becoming `forkserver` on Unix other than macOS in 3.14 is one such change: nothing new becomes importable, the default simply differs. The rule that survives: **probe for capabilities, compare versions for semantics.** And whichever you use, make the branch observable, and run both sides of it in CI. An untested fallback path is not a fallback; it is an outage with a delay on it.
- When is a version comparison still the only option?For syntax and for undifferentiated behaviour changes. A module written with newer syntax raises SyntaxError while it is being compiled, before any except clause can run, so the newer syntax has to live in its own module whose import is guarded by a version check. Behaviour that changes with nothing new to import — a default start method, a changed evaluation rule — also has no probe available, so the version number is the honest signal.
- Why catch ModuleNotFoundError rather than ImportError in some probes?ModuleNotFoundError is the subclass raised specifically when the module cannot be located. Plain ImportError is broader: it also covers a from-import where the module was found but the requested name inside it is missing, and it is what a failing import *inside* the imported module propagates. Catching the narrow one keeps a genuinely broken install from being silently reclassified as an absent optional feature.
- How would you make the fallback branch visible in a long-running worker?Decide the path once at import, log it at start-up at WARNING with the reason, and surface it where operators already look — a version or health endpoint, a start-up banner, a metric label carrying the chosen path. If the degraded path is not acceptable for the workload, do not degrade at all: raise during start-up so the deploy fails fast instead of producing quietly wrong output.
Checking the version number is trusting the catalogue; wrapping the import in try/except is walking to the shelf to see whether the book is there.
saying these in an interview costs you the question
- Treats a version number as proof the module is installed
- Wraps a whole setup block in one try/except ImportError
- Swallows the ImportError with pass and no log
- Believes try/except can detect a syntax-level feature
- Re-probes the import on every call in the hot path
- Catches bare Exception around the optional import