skip to content

Why does a module containing a match statement fail to import on Python 3.9 even if that branch never runs?

level: seniorimportance: nice to knowfreq 20%

answer

  1. The interpreter reads before it runs
  2. A version check runs too late
  3. Move the new syntax behind an import
  4. Packaging metadata can refuse the install

basics

~20 s

Python compiles a whole module before running any of it, so syntax an older interpreter does not know is a compile-time SyntaxError. A runtime version check comes too late; the match statement must live in a separately imported module.

solid answer

~50 s

Importing a module compiles its entire source to bytecode first, then runs the resulting code object. `match` is grammar, added in Python 3.10 by PEP 634, so on 3.9 the parser rejects the file before a single statement executes — wrapping the block in `if sys.version_info >= (3, 10):` or in `try`/`except SyntaxError` inside the same file changes nothing, because the guard is compiled at the same moment as the thing it guards. The workable answers are structural: put the 3.10-only code in its own module and import it conditionally, so the `try` or the version test wraps the *import*; or raise the floor for the whole distribution with `requires-python = ">=3.10"` in the `[project]` table of `pyproject.toml`, which makes pip resolve an older release rather than install something that cannot import. In CI, `ast.parse(source, feature_version=(3, 9))` catches the mistake without installing an old interpreter.

code

python · 9 lines
python
import ast

src = "match value:\n    case 1:\n        pass\n"

ast.parse(src, feature_version=(3, 10))
try:
    ast.parse(src, feature_version=(3, 9))
except SyntaxError as exc:
    print("older grammar rejects it:", exc.msg)

go deeper

for a junior

Recall that Python checks a file's syntax when it imports it, before running anything. New syntax such as the match statement therefore needs a new enough interpreter for the whole file, not just for the line that uses it.

for a middle

Explain the mechanics: source is compiled to a code object first, so a version check or a try/except in the same module cannot help. Describe the fix of moving the new syntax into its own module imported conditionally.

for a senior

Show you have debugged it: an import-time SyntaxError in a crash-looping worker looks like a corrupted checkout until you notice the interpreter version. Bring the remedies — an import boundary, requires-python in pyproject.toml, and a parse-level CI check.

for a principal

Own the policy: decide the organisation's minimum interpreter version, make it enforceable in packaging metadata and CI rather than by convention, and keep the distinction between syntax features and library features explicit in how the codebase handles version support.

## Compilation precedes execution When Python imports a module it reads the whole source, parses it, and compiles it to a code object; only then does it execute that code object top to bottom. Syntax errors are therefore *import-time* errors, and they are raised for the entire file, not for the branch that would have run. A conditional cannot protect a construct the parser cannot even read, because the conditional is compiled in the same pass. `match` is grammar. It entered the language in Python 3.10 with PEP 634 and simply does not exist in the 3.9 grammar. So a file containing one is unimportable on 3.9 whatever runtime guard surrounds it. ## What this looks like in production Take a genome-annotation pipeline whose workers each start by importing a shared annotation library. One worker image is pinned to an older interpreter — an inherited base image nobody has revisited. Someone adds a `match` statement to a code path guarded by a version check, tests locally on a current interpreter, and ships. The failure arrives as a crash loop: each worker pays its 45-second cold start, imports the library, dies with a `SyntaxError` pointing at the `case` line, and is restarted. Two things make it nastier than a normal outage. First, the traceback names a line that looks syntactically fine to everyone reading it on a modern interpreter, so it reads like a corrupted file or a bad checkout rather than a version floor. Second, the version-guard in the source *looks* like it handled the problem, so reviewers skim past the real cause. The tell is that the error is a `SyntaxError` at all: a runtime feature gap produces `AttributeError`, `ImportError` or `TypeError`, never a syntax error. ## Why try/except does not save you `try: ... except SyntaxError: ...` around the match statement fails for the same reason the `if` fails — the handler is compiled alongside the protected code, so the module never produces a code object to run. The construct works only when the new syntax is in a *different* module, because then the `try` wraps an `import` statement, and the import is what raises. That gives the standard shape: isolate the 3.10-only code in its own module, and reach it through a conditional import or a `try`/`except ImportError` around it, with a fallback implementation for older interpreters. `importlib.import_module` is convenient when the module name is computed. An `exec(compile(source, name, "exec"))` over a string also defers compilation to runtime, but it costs you static analysis and readability, so it is a last resort rather than a pattern. ## Prevent it at the distribution boundary The cleaner answer for a published package is not to run on the old interpreter at all. `requires-python = ">=3.10"` in the `[project]` table of `pyproject.toml` is published in the distribution metadata, and pip honours it during resolution: it will pick an older release of your package, or refuse, rather than install a version that cannot import. Trove classifiers naming supported Python versions are documentation for humans and search facets on the index — they do not affect resolution, and relying on them for this is a common mistake. A related detail worth knowing: cached bytecode does not help either. A `.pyc` compiled by a newer interpreter carries a magic number that older interpreters reject outright, so you cannot ship pre-compiled files as an escape hatch. ## Catching it in CI cheaply You do not need every historical interpreter installed to catch syntax that is too new. `ast.parse(source, feature_version=(3, 9))` applies the grammar of the named version, and for a match statement raises `SyntaxError` with a message stating that pattern matching requires 3.10 or greater. Running that over the package's source files in CI is a fast, dependency-free floor check. Its limit is precise, and worth stating in an interview: it checks *syntax* only. New library APIs, new keyword arguments and new behaviours are invisible to it, and those still require an actual run on the oldest interpreter you claim to support. The two checks are complementary — parse-level for grammar, a real test matrix for everything else. ## The general lesson This is not about `match` specifically; it is about the difference between a syntax feature and a library feature. Library features can be feature-detected at runtime and gracefully degraded. Syntax features cannot be feature-detected in the file that uses them — they must be isolated behind an import boundary or excluded by metadata. Every new statement Python adds inherits the same constraint.

  • Can try/except SyntaxError protect code that uses syntax the running interpreter does not support?
    Only around an import, never around the syntax itself. The module containing the new syntax fails to compile, and its own `except` clause is compiled in the same pass, so it never runs. Put the new-syntax code in its own module and wrap the `import` — or a call to `importlib.import_module` — in the `try` instead.
  • What stops pip from installing a 3.10-only package on an older interpreter?
    `requires-python = ">=3.10"` in the `[project]` table of `pyproject.toml`. The value is published in the distribution metadata and pip honours it during resolution, picking an older release or refusing rather than installing something unimportable. Trove classifiers are documentation and search metadata only; they do not affect resolution.
  • How would you catch too-new syntax in CI without installing every old interpreter?
    Parse each source file with `ast.parse(source, feature_version=(3, 9))`, which applies that version's grammar and raises `SyntaxError` — for pattern matching it says the construct needs 3.10 or greater. It is fast and dependency-free, but it checks syntax only: new library APIs still need a real run on the oldest supported interpreter.

saying these in an interview costs you the question

  • Guards new syntax with a runtime version check
  • Thinks try/except SyntaxError works in the same module
  • Says only the executed branch of a module is compiled
  • Relies on trove classifiers to block an install
  • Assumes a .pyc built on a newer interpreter runs on an older one
  • Confuses a syntax floor with a missing library attribute

context