# Why do fallback imports make agents edit the wrong code?

> Rule AR-05: No conditional imports that bind the same name to different objects (try/except ImportError fallbacks get a typed shim).

When a file binds `dumps` from a fast library in a `try` block and from a pure-Python version in the `except` block, the name has two possible owners and which one runs is decided on each machine at start-up. An agent that reads the file sees both and has to guess which one the call reaches. Put the choice in one small shim module with one declared signature, and every other file imports from the shim.

## Why it happens
**Python.** An import statement is an ordinary statement. It runs when control reaches it, and a failure to find the module raises `ModuleNotFoundError` (a subclass of `ImportError`). So `try: import a / except ImportError: import b` executes only one branch at runtime, and the name is bound to whichever module succeeded. Nothing in the file says which. The choice depends on what's installed.

Static tools have to decide for you:
- Pyright reports `reportMissingImports` (error in basic, standard and strict) for an import with no source or stub file, and `reportMissingModuleSource` (warning by default) for stubs without source. The configuration docs don't describe a setting for try/except fallbacks.
- Pyright's `reportPossiblyUnboundVariable` (off in basic, error in standard and strict) fires when a name is bound only on some paths.
- Mypy documents that it understands `sys.version_info` and `sys.platform` checks in `if/elif/else`, so definitions in those branches are type-checked per version. It does not document the same for try/except fallbacks, and for two incompatible bindings of one name, mypy 2.4.1 with `--strict` reports `fallback.py:4: error: Incompatible import of "dumps" ... [assignment]`. A caller that imports the name from the fallback module also gets `caller.py:1: error: Module "fallback" does not explicitly export attribute "dumps"  [attr-defined]`, which is the `--strict` implicit re-export check.

**TypeScript and JavaScript.** Static `import` declarations are resolved before the module runs: the spec's `InitializeEnvironment` creates import bindings during linking, and an unresolvable import is a `SyntaxError` at that stage. That is why static imports are traceable and can't be conditional. The conditional forms are `require()` inside an `if`, and dynamic `import()`, which Node documents as an asynchronous way to load modules and which returns a Promise. A specifier built at runtime (`import(`./plugins/${name}`)`) has no fixed target for a tool to follow.

In both languages, the cost is the same. The module a call reaches is a runtime fact, and tools that read source can only list the candidates.

## Bad and good

```python
# bad: billing/serialize.py
try:
    from orjson import dumps          # returns bytes
except ImportError:
    from json import dumps              # standard library, str keys only
```

```python
# good: billing/_json.py is the only place that chooses
import json
from importlib.util import find_spec

HAS_ORJSON = find_spec("orjson") is not None

def dumps(obj: object) -> str:
    if HAS_ORJSON:
        import orjson
        return orjson.dumps(obj).decode("utf-8")
    return json.dumps(obj)
```

```python
# good: billing/serialize.py
from billing._json import dumps
```

The shim has one `dumps` and one signature. A type checker checks the body against that signature, a reader sees the choice in one place, and the fallback can have its own test.

```ts
// bad: src/store.ts
const driver = process.env.DB === "sqlite" ? require("./sqlite") : require("./postgres");
export const save = driver.save;
```

```ts
// good: src/store.ts
import type { Driver } from "./driver";
import * as sqlite from "./sqlite";
import * as postgres from "./postgres";

const driver: Driver = process.env.DB === "sqlite" ? sqlite : postgres;
export const save = (row: Row) => driver.save(row);
```

In the good TypeScript version both modules are imported statically, both are visible to tools, and `Driver` is the contract they must meet.

## Trade-offs and when a conditional import is acceptable
- **Optional dependencies** are a real need: a library that works with or without an accelerator. The shim above keeps the choice and still gives callers one name.
- **Platform and version splits** (`sys.version_info >= (3, 11)`) are the best-supported form. Mypy documents that it understands these checks. Pyright 1.1.414 handles them too: with `pythonVersion` set to Python 3.10 the Python 3.11 branch was unreachable and gave 0 errors, and with Python 3.12 it reported `reportReturnType` for the bad return inside the Python 3.11 branch (check.py:5:16) and nothing in the other branch.
- **`importlib.util.find_spec`** returns `None` when a module isn't found, without importing the module. For a dotted name, the parent package is imported. Ruff's `F401` docs recommend it for existence checks over a try/except.
- **Heavy imports inside functions** to delay start-up cost are a different pattern: the name has one owner and the import is only late. Ruff's `PLC0415` flags them, and moving them to the top is its suggested fix, so allow this on purpose where start-up time matters.
- **Dynamic `import()` for code splitting** is normal in front-end code. Keep the specifier a string literal so tools can follow it.
- **Shims cost a file.** For two or three conditional names, one `_compat.py` or `compat.ts` costs less than the confusion does.

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | `F401` unused-import | an import kept only for an availability check; the docs recommend `importlib.util.find_spec` | 2026-10-30 |
| ruff | `PLC0415` import-outside-top-level | imports inside functions or classes (ruff 0.12.0 and later) | 2026-10-30 |
| pyright | `reportPossiblyUnboundVariable` (none in basic, error in standard and strict) | a name bound on only some branches | 2026-10-30 |
| pyright | `reportMissingImports` (error), `reportMissingModuleSource` (warning) | optional dependencies that aren't installed in the checking environment | 2026-10-30 |
| mypy | `sys.version_info` / `sys.platform` checks | conditional definitions it can evaluate, per target version | 2026-10-30 |
| typescript-eslint | `no-require-imports` (in `recommended`) | `require()` calls and `import x = require()` (the latter allowed with `allowAsImport`) | 2026-10-30 |

There's no linter setting in the docs checked that bans try/except import fallbacks directly. A code-review rule plus a shim module is the control.

In `pyproject.toml`:
```toml
[tool.ruff.lint]
extend-select = ["F401", "PLC0415"]
```

## Rule block for AGENTS.md / CLAUDE.md
```markdown
- Don't write `try: import a / except ImportError: import b` in feature code. Put the choice in
  one shim module with a typed public function, and import from the shim everywhere else.
- Use `importlib.util.find_spec("pkg")` to test for an optional package.
- Don't build module specifiers from variables in `import()` or `importlib.import_module()`.
  If a plugin list is needed, keep a table of literal imports.
- When you edit code behind a shim, change the declared signature first and update both branches.
```

## Edge cases
- **`if TYPE_CHECKING:` blocks** import names for annotations only, for type checkers. In a test, mypy 2.4.1 with `--strict` reported no issues, and at runtime `hasattr(api, 'Invoice')` was `False` with no `NameError` (Python 3.14.7). They do not bind two objects to one name, so they are fine. A runtime import of the same name elsewhere is the problem.
- **Same name, two meanings:** `try: import tomllib / except ImportError: import tomli as tomllib` gives callers one name, which is the right goal, but the aliasing and the fallback are still in feature code. Move it to the shim.
- **`None` fallbacks** (`except ImportError: orjson = None`) make every later use a possible attribute error on `None`. Pyright's `reportOptionalMemberAccess` (error in all modes) catches the use sites.
- **`importlib.import_module(name)`** with a variable name has no static target. See AR-06 for registries filled this way.
- **Testing:** both branches need a test. The shim makes that a two-line monkeypatch of `HAS_ORJSON`.

## What LLMGRAPH shows
When a name is bound by try/except to two definitions, LLMGRAPH lists both and prints the call as `maybe_more (open: type unknown)` under each. After a shim module, the call prints certain.

## References
- Python Language Reference, "The import statement" (module initialization when the statement runs; `ImportError`): https://docs.python.org/3/reference/simple_stmts.html#the-import-statement
- Python Language Reference, "The import system": https://docs.python.org/3/reference/import.html
- `importlib.util.find_spec`: https://docs.python.org/3/library/importlib.html
- Pyright configuration: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
- Mypy, "Common issues" (version and platform checks): https://mypy.readthedocs.io/en/stable/common_issues.html
- Ruff F401 and PLC0415: https://docs.astral.sh/ruff/rules/unused-import/ · https://docs.astral.sh/ruff/rules/import-outside-top-level/
- ECMAScript spec, module linking (`InitializeEnvironment`): https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html
- Node.js ESM docs (dynamic `import()`): https://nodejs.org/api/esm.html
- typescript-eslint `no-require-imports`: https://typescript-eslint.io/rules/no-require-imports/
- TypeScript handbook, modules reference (`import()`, `require`): https://www.typescriptlang.org/docs/handbook/modules/reference.html

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: tool checks resolved (tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1, pyright 1.1.414)
- 2026-10-30: copy-gate wording fixes

## Related

- [AR-03: What happens when two functions share a name?](https://beta.llmgraph.in/agent-readable/ar-03.md)
- [AR-06: Why does my agent not see what importing this module does?](https://beta.llmgraph.in/agent-readable/ar-06.md)
- [AR-04: Do import aliases confuse coding agents?](https://beta.llmgraph.in/agent-readable/ar-04.md)

Last verified 2026-10-30
