Lazy imports have a reputation as a performance trick: defer loading a module until it is actually needed, so your startup time stays low. That is true, but it is the least interesting thing about them.
In pyRPC's CLI, lazy imports serve as capability boundaries. They define which commands work under which conditions. They transform an ambiguous "this might work" CLI into a system where each command explicitly declares its dependencies, and the user knows exactly what to expect.
Three tiers of CLI commands
The pyrpc CLI (now in pyrpc-cli) has six commands. Each falls into one of three tiers based on what it needs at runtime:
Tier 1: No pyrpc-core needed
These commands work with pip install pyrpc-cli alone (or withpyrpc-cli pulled in transitively by pyrpc-core):
pyrpc codegen --url— fetch schema from HTTP, generate TypeScript. No Python introspection needed.pyrpc codegen schema.json— read schema file, generate TypeScript. Pure file I/O + template rendering.pyrpc shell <url>— connect to a running server over HTTP. The server handles introspection.pyrpc --version— print version. No core imports needed.
Tier 2: Lazy pyrpc-core import
These commands need pyrpc-core, but only when the user provides a module path argument. The import happens inside the command handler:
pyrpc pull app.main— import the user's module, walk the registry, save schema. Requirespyrpc_core.get_registry_schema.pyrpc codegen app.main— same import mechanism aspull, followed by template generation.pyrpc inspect app.main— import module, print table of procedures.
Tier 3: pyrpc-core at startup
These commands need pyrpc-core immediately. The import happens at the top of the command handler, before any user interaction:
pyrpc serve app.main— start uvicorn with the user's app. NeedsPyRPCAsgiAppanddefault_router.pyrpc dev app.main— serve + watcher + dev console. Needs core at startup.
The implementation pattern
The implementation is straightforward. Each command handler either imports core at the top or calls a lazy import helper:
# pyrpc_cli/main.py
# Tier 1: no core import at all
@app.command()
def codegen(source: str, ...):
schema = _load_schema(source) # file I/O or HTTP fetch
content = generate_typescript_client(schema)
save_typescript_client(schema, output)
# Tier 2: lazy import inside the handler
@app.command()
def pull(module_path: str, ...):
from pyrpc_core import get_registry_schema
schema = _extract_schema_from_module(module_path)
...
# Tier 3: eager import at handler start
@app.command()
def serve(module_path: str, ...):
from pyrpc_core import ...
...The pattern is so simple it barely deserves the name "pattern." But its implications are significant.
What lazy imports communicate
Every lazy import is a statement: "this command can work without this package." The absence of a lazy import says: "this command fundamentally needs this package." These are API contract signals, not performance hints.
Consider the alternative: eager imports at the top of the module. If pyrpc-cli eagerly imported pyrpc-core at the top of main.py, then every `pyrpc` command would fail if pyrpc-core was not installed. Even pyrpc --version would crash. The eager import creates a hard coupling that does not actually exist in the domain.
With lazy imports, the coupling is explicit and fine-grained. A user installing pyrpc-cli for frontend-only CI can run pyrpc codegen --urlwithout ever touching pyrpc-core. The error message for tier 2 commands (if core is missing) is clear: "This command requires pyrpc-core. Install it with: pip install pyrpc-core".
The packaging vs code distinction
Lazy imports also clarify the distinction between packaging dependencies (what pip installs) and code dependencies(what Python imports). They are not the same thing.
pyrpc-core declares pyrpc-cli as a packaging dependency. This ensures pyrpc-cli is on disk after pip install pyrpc-core. But pyrpc-core never imports pyrpc-cli. The code dependency is in the other direction: pyrpc-cli imports pyrpc-core (lazily) for commands that need it.
This asymmetry is intentional. The packaging graph ensures availability. The import graph ensures functionality. They are two different graphs that happen to share the same vertices. Lazy imports are the bridge between them.
Real-world impact: the CI workflow
The lazy import pattern enables a workflow that would otherwise require a full Python backend setup:
# In your frontend CI pipeline: # Install just the CLI (transitively via pyrpc-core or directly) pip install pyrpc-cli # Generate types from the deployed server # No Python backend, no module imports, no pyrpc-core pyrpc codegen https://api.example.com/rpc # → Writes types.ts with full TypeScript definitions
The same CI pipeline generates types from a staging or production server without ever importing pyrpc-core. The command fetches the schema over HTTP, passes it to pyrpc-codegen's template engine (pure Jinja2), and writes the output file. Zero pyrpc-core imports, zero framework dependencies, zero risk of import errors in CI.
When not to use lazy imports
Lazy imports are not always the right choice. Some cases where we deliberately avoided them:
- At module level in libraries. pyrpc-core and pyrpc-codegen never use lazy imports at module level. They import everything eagerly at the top of
__init__.py. Library users should get consistent import behavior. - When the dependency is always needed. pyrpc-cli's
codegencommand eagerly imports pyrpc-codegen because it always callsgenerate_typescript_client. There is no code path that skips it. - In hot code paths. If a lazy import is inside a function that runs on every keystroke (like the dev console REPL), it should be hoisted outside the loop. We import core once at dev console startup, not on every REPL command.
The principle
Think of lazy imports as explicit dependency declarations at the function level. A module-level import says "this entire module needs this dependency." A function-level lazy import says "this specific operation needs this dependency." The granularity of the import should match the granularity of the need.
When you treat lazy imports this way, they stop being a performance trick and become a design tool. They help you reason about what depends on what, they make your error messages more precise, and they enable workflows that would otherwise require separate packages or conditional installation.
In pyRPC's case, lazy imports are what make the three-package chain work. Without them, pyrpc-core would have to eagerly import pyrpc-cli (adding startup cost for production deployments) or the CLI would have to be its own separate install target (breaking the one-command UX). Lazy imports split the difference: one install, two import graphs, clear capability boundaries.
All 45 tests pass. The repo is at github.com/pyrpc/pyrpc.

pyRPC