How it works¶
your package (.py) py2axum (Python, stdlib only) generated crate
────────────────── ┌──────────────────────────────────────────────┐ ─────────────────────────────
app/main.py │ modules.py static module index & imports │ Cargo.toml (+ tested Cargo.lock)
app/models.py ───▶ │ frontend.py apps, routers, routes, models, │ ──▶ src/main.rs tokio + axum server
app/views/... │ schemas, middleware │ src/gen.rs your code, compiled
uv.lock (versions) │ dyn.py compiler of project code │ src/dynrt/ the runtime (copied)
│ libmap.py closed map of library APIs │
│ report.py per-route coverage report │
└──────────────────────────────────────────────┘
py2axum is a compiler: it turns the Python source of a FastAPI application into a Rust crate, once, at build time. Nothing of Python remains at run time, except for the routes you choose to leave to a Python process (section 4). This page follows a route through the three stages, then explains the design choices. To use py2axum, start with the getting-started guide; for what is and is not translated, see supported.md.
One route, end to end. In the bookshelf example, GET /me returns the user
behind a JWT Bearer token:
# app/routers/auth.py
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser): # CurrentUser = Annotated[User, Depends(current_user)]
return user
The frontend finds the route, its response_model and its dependency chain (current_user → get_db and
HTTPBearer). The compiler emits one handler that resolves the parameters in FastAPI's order, collects
validation errors like FastAPI, calls the compiled endpoint and serializes the result through the
UserOut schema:
/// GET /me (from auth.py:34 `me`)
async fn route_4_me(cx: &Cx) -> R<axum::response::Response> {
let __body: Option<V> = None;
let mut __errv: Vec<crate::dynrt::pyd::ErrDetail> = Vec::new();
let __errs = &mut __errv;
let p_user = dep_app_security__current_user(cx, &__body, __errs).await?.unwrap_or(V::None);
crate::dynrt::web::check(__errv)?; // a 422 with FastAPI's body, if any
let __ret = f_app_routers_auth__me(cx, p_user).await?;
crate::dynrt::web::respond(cx, __ret, Some(&TD_5), 200).await // response_model=UserOut
}
current_user itself is compiled the same way, statement by statement: jwt.decode(...) becomes a call to
the runtime's PyJWT implementation, except jwt.ExpiredSignatureError an isinstance test on the error,
await db.get(User, ...) a call to the runtime's SQLAlchemy session, which emits the same SQL as
SQLAlchemy and keeps the same identity map. The runtime is what makes the result identical: it implements
Pydantic's validation and serialization, SQLAlchemy's session, Starlette's routing and middleware, and
CPython's semantics for every value the code touches.
1. Reading the project without running it¶
modules.py parses every module of the package with ast and indexes its definitions and imports
(relative, from x import *, re-exports, function-level imports). A name is resolved to a project symbol,
a module, or an external dotted name (fastapi.APIRouter), never by importing anything. frontend.py
finds the FastAPI app(s), routers and their prefixes, routes, SQLAlchemy models and Pydantic schemas.
Library versions matter (Starlette changed CORS, Pydantic error URLs carry its version, CPython changed
some messages): they are read from the project's uv.lock (else requirements*.txt, else pyproject.toml), and the
target Python version from requires-python. A library version outside the
tested ranges is refused.
2. Compiling project code¶
The compiler (dyn.py) compiles, for each route, its whole closure: dependencies, schemas, models
and every project function it can reach (calls on objects of unknown class depend on all project methods of
that name). Each Python function becomes a Rust async fn over a dynamic value type V (None, bool, int,
float, str, bytes, list, tuple, dict, set, datetime types, Decimal, model and schema instances, enum
members, exceptions, functions, coroutines, library objects...). Control flow is translated directly
(if/for/while/try/with/match, comprehensions, generators); exceptions are Rust Results
routed to the enclosing try with labelled blocks.
Library calls go through libmap.py, a closed map from dotted names (datetime.datetime.now,
sqlalchemy.select, redis.asyncio.from_url) to runtime functions. An unknown name is a compile error with
file:line. Some constructs are compiled specially: SQLAlchemy models and Pydantic schemas become static
descriptors, TypeAdapter(list[X]) a static validator, decorators a startup-time value d1(d2(f)).
A function that cannot be translated does not stop the build unless a translated route can reach it; it is
replaced by a stub that raises at run time (py2axum: ...). --report uses the same machinery to tell, per
route, the first blocking construct and what fixing it would unlock.
3. The runtime¶
py2axum/runtime/dynrt/ is Rust, copied into every generated crate:
| Module | Reproduces |
|---|---|
v.rs, ops.rs, methods.rs |
values, operators, builtins and methods with CPython semantics (repr, float formatting, hashing, comparisons) |
pyd.rs |
Pydantic v2 validation (pydantic-core's order, error types and messages), serialization, TypeAdapter |
orm.rs |
the SQLAlchemy session (identity map, unit of work, relationships, loaders) and a SQL compiler emitting SQLAlchemy's SQL, on sqlx/PostgreSQL |
web.rs, asgi.rs, resp.rs |
FastAPI request handling (parameters, dependencies, response_model) and Starlette's routing, middleware and responses |
aio.rs, thread.rs |
coroutine objects, asyncio, threading and background loops on tokio |
dt.rs, decimal.rs, pickle.rs, stdlib.rs, libs.rs |
standard library |
http.rs, rds.rs, jose.rs, auth.rs, mail.rs, webpush.rs, ... |
supported third-party libraries |
4. Hybrid deployments¶
Paths declared with --python-side PATH (a route pattern like /items/{id} or a mount prefix) and
--python-side lifespan are left to the Python application. With PY2AXUM_PYTHON_URL set, the binary
relays those paths to it (method, headers, body; streamed response), so one entry point serves both;
otherwise it answers 404 and your ingress routes them.
--python-side auto finds those routes for you: a first pass compiles every route without stopping, then
every path whose route does not translate (its own code or a function it reaches) is left to Python, along
with raw add_route routes with a literal path and the app's last app.mount()s (--python-side mount: the
requests under the mount that no translated route fully matches). Each moved path is printed with the error that blocked it:
python-side (auto): /assistant/chat (POST chat) — app/tools.py:61: method .model_json_schema() is not
implemented by the runtime for any type (it would raise AttributeError at run time)
Rules:
- A path moves as a whole: if
GET /items/{id}translates butDELETE /items/{id}does not, both are served by Python. The binary relays by path, and Python serving a route is always correct. - Errors about the whole application (an unsupported middleware,
FastAPI(lifespan=...), an app option) are not moved: generation stops on them, withfile:line. Declare--python-side lifespanexplicitly: a lifespan left to Python does not run in the binary, which is a decision only you can make. - The list follows the code: a route that becomes translatable after a py2axum upgrade moves to the binary on
the next build. Pin the list with explicit
--python-side PATHflags (or check--reportin CI) if you want deployments to change only when you decide. - Moved paths go through the Python app's own middleware stack, not the translated one (the relay happens before it). State shared between the two processes must be external (database, Redis, broker).
5. Why differential testing¶
The compiler aims at observable equivalence: status, headers, content type and body bytes. The only reliable
way to know is to run both implementations on the same inputs, which tests/conformance.py does (see
conformance.md). Every supported construct in this repository has such a test, and
every refused construct has a rejection test.
6. How it differs from RustPython¶
RustPython and py2axum are both "Python in Rust", but they solve different problems.
| RustPython | py2axum | |
|---|---|---|
| What it is | A Python 3 interpreter written in Rust: it replaces CPython and runs any program at run time | An ahead-of-time compiler for one stack (FastAPI, SQLAlchemy, Pydantic): it emits Rust, then no Python runs |
| Scope | The whole language and standard library (in progress) | A closed subset of Python and a closed list of libraries |
| Outside the scope | Fails at run time | Refused at compile time with file:line, or left to Python (--python-side) |
| C and PyO3 extensions | Not loadable (no CPython C API): pydantic-core, psycopg's binary build, uvloop... | Not needed: Pydantic validation, the ORM session and asyncio are reimplemented natively in the runtime |
| Speed | An interpreter, generally slower than CPython | Native code on axum/tokio/sqlx; measured against uvicorn and granian in bench/ |
| Correctness check | CPython's test suite, partially passing | Differential testing against the Python app itself, byte for byte |
| Best at | Python in the browser (WebAssembly), scripting embedded in a Rust program | Serving an existing FastAPI API from a single binary |
In practice a FastAPI application does not run on RustPython today, since Pydantic v2 depends on pydantic-core, a compiled extension; and if it did, it would not be faster than on CPython. py2axum gets its speed and its guarantees from its narrow scope: because it only targets one framework stack, it can reproduce that stack's observable behaviour exactly and compile everything else away.
The two projects share one concern: reproducing CPython's semantics in Rust (repr, float formatting,
hashing, integer and string methods). RustPython's implementation is a useful reference for those edge cases.
See also¶
- Getting started: install, check, build, run, deploy.
- Supported subset and known differences.
- Conformance: checking your own application against the binary.
- The generated code of the example: run
py2axum examples/bookshelf/app --root examples/bookshelf --python-side auto -o build/bookshelfand readbuild/bookshelf/src/gen.rs.