v1.4.0 — The Native Core (Plan)¶
Status: implemented in 1.4.0
This is the design document 1.4.0 was built from. All phases (00–05) have landed; see the v1.4.0 release notes for what shipped, the measured numbers, and where the implementation departed from this plan (notably: the native query compiler in Phase 02 was skipped because query building measured at under 3% of request time, and the response serializer — not row materialization — turned out to be the largest cost).
From: 1.3.0 · Pillars: Speed · Simplicity · Security
One schema definition, compiled once in Rust, driving the ORM, request validation, response serialization and OpenAPI generation.
The verdict¶
A native Rust ORM core is buildable, and it is the right call — but it is not faster for the reason it sounds like it should be.
Building SQL strings is not what costs you. A filter().order_by().limit() chain assembles in single-digit microseconds; the Postgres round trip behind it is 200–2000 µs. Rewriting the query builder in Rust to save 4 µs on a 900 µs request is a rounding error, and any benchmark claiming otherwise is measuring noise.
What actually costs you is the crossing. Every field of every row is currently materialized as a Python object four separate times on its way from the wire to the response body. That is the win, it is large, and it exists only because the ORM's output path runs through Python.
The class User(Model) declaration stays in Python — that is the DX and it must not move. It gets compiled into a Rust schema registry once at import time, and the registry is what every hot path reads. "Native" means the execution engine is native, not the declaration.
Where the time goes¶
Trace one field of one row through GET /users returning 200 rows × 8 columns as it works today. src/db/value.rs already decodes Postgres wire types natively — and then hands the result to Python, which hands it back.
Today — 1,600 fields, four Python materializations each:
| Stage | Owner | Cost |
|---|---|---|
pg wire → pg_value_to_py | Rust | — |
row_to_pydict — dict per row | Python objects | 1,600 |
_from_row — setattr × 8 | Python | 1,600 |
to_dict() — getattr × 8 | Python | 1,600 |
| SIMD JSON walks the dicts | Rust | — |
1.4.0 — the row-to-JSON fast path: pg wire, typed by the registry → serialize straight to response bytes. The three middle stages disappear.
A handler that returns a queryset never needs a Python object at all. When it does touch rows, it gets a #[pyclass] row handle that decodes a column on attribute access — you pay for the three fields you read, not all eight.
| Endpoint shape | Python objects today | After | Expected |
|---|---|---|---|
| List, 200 rows × 8 cols, returned as JSON | 4,800 | 0 | large — the headline case |
| List, 200 rows, handler reads 2 fields each | 4,800 | 400 | substantial |
.get(id=…), one row | 24 | 0–3 | noise — DB round trip dominates |
| Insert / update, one row | ~16 | ~8 | noise |
| Request body validation, 20 fields | dict + model + coercions | validate off raw bytes | substantial |
Stated as a claim, not a number
No speedup figure goes in the release notes until it comes off the paired benchmark harness — both builds running simultaneously, alternating wrk runs. Sequential runs swing ±20% and will happily invent a 2× that isn't there.
Schema handlers and the type system¶
Both are feasible in Rust, and together they are the actual centerpiece of 1.4.0 — bigger than the ORM speedup.
Cello currently defines the same shape three or four times over: an ORM field in python/cello/orm.py, a Pydantic model for body= validation, the DTOConfig field machinery in src/dto.rs, and whatever src/openapi.rs infers. Four descriptions of one users table, kept in sync by hand. That is a simplicity failure before it is a speed one.
One declaration, five consumers. A field declared once compiles into a Rust FieldSpec — Postgres type, nullability, constraints, coercion rule, read/write visibility — and that single spec drives DDL, query typing, body validation, response serialization, and the OpenAPI schema.
# Python declares. Rust compiles it once, at import.
class User(Model):
id = AutoField()
email = CharField(max_length=255, unique=True)
password = CharField(max_length=128, write_only=True) # can never serialize
active = BooleanField(default=True)
@app.post("/users", body=User) # validated in Rust, off the raw bytes
async def create(request, user: User):
return await user.save() # serialized in Rust, password omitted
Why Rust wins here, genuinely¶
- Validation compiles to a decision tree built once at class definition, then executed per request — no
get_type_hints, noinspect.signature, no per-call Python branching. - It validates directly against the SIMD-parsed body. Today the JSON becomes a Python dict, then a model, then gets read back out. The intermediate dict disappears.
- Type coercion (
"42"→i32, ISO-8601 →TIMESTAMPTZ) happens in the same pass as parsing, against the Postgres type the column actually is.
What it must not break¶
- Pydantic stays fully supported. The native schema is an option, never a requirement —
body=SomePydanticModelkeeps working unchanged. - Error shape is frozen. Responses keep the existing
400 {"detail": [...]}format, field-path and all. A faster validator that reports errors differently is a breaking change wearing a performance costume. - Exotic types keep degrading gracefully. Unknown Postgres types fall back to text decode rather than failing the query — the behavior
pg_value_to_pyhas today.
Drivers: integrate, don't write¶
Decision: keep tokio-postgres. Do not write a wire protocol driver.
The one capability a custom driver would give us — raw wire bytes plus the column type — is already available through fully public API (tokio-postgres-0.7.18/src/row.rs:169):
So the row-to-JSON fast path needs no fork at all. A newtype with a permissive FromSql impl receives the raw bytes and writes JSON straight into a buffer:
struct JsonCell<'a>(&'a [u8], Type); // accepts(_) -> true
row.try_get::<_, Option<JsonCell>>(idx)? // raw wire bytes, no PyObject
Writing a driver would mean protocol v3 message framing, SCRAM-SHA-256 auth, the TLS handshake, extended query protocol, prepared-statement caching, portals and cursors, COPY, async notifications, cancellation, and ~50 type OID codecs. Months of work, permanent maintenance liability, and none of it touches the bottleneck — which is Python object creation, something the driver has nothing to do with.
MySQL (post-1.4.0): mysql_async, not sqlx. sqlx bundles its own row and type abstraction, but the schema registry is our abstraction layer — adopting sqlx's would mean maintaining two. mysql_async matches the tokio-postgres shape already in the codebase.
Security: fix before shipping¶
SQL injection · ORM QuerySet · present in 1.3.0¶
QuerySet._resolve_column() returns an unrecognized field name verbatim so that raw column names work, and it lands inside an interpolated SQL fragment. Values are correctly parameterized; identifiers are not. Any endpoint that forwards user-controlled keys into filter, exclude, order_by or update is injectable.
# python/cello/orm.py — the fragment is f-string assembled
column = self._resolve_column(field_name) # unknown name → returned as-is
frag = f'"{column}" {op} ?'
# a filter key of: x" = 1 OR "1 → closes the quote and injects
await User.objects.filter(**request.json()).all()
Fix: resolve every identifier against the compiled schema registry and reject anything that is not a declared column. Because 1.4.0 puts the registry in Rust and routes all SQL assembly through it, the Python API is left with no path that can interpolate an unvalidated identifier — the class of bug goes away rather than getting patched. Ships in Phase 1; also backport a validation guard to a 1.3.1.
Two more that come free with the unified schema: write_only fields are dropped by the Rust serializer, so a password hash cannot leak through a to_dict() someone forgot to trim; and DDL identifier quoting moves out of Python string building into the registry.
Speed is the non-negotiable¶
Where compatibility and speed conflict, speed wins and the compatibility clause gets redesigned — not the other way round. Recorded here so the tension is resolved in the plan rather than argued in a review.
Most of the compatibility contract costs nothing:
- Frozen value shapes are a speed win. Returning an RFC-3339 string is cheaper than constructing a
datetime. KeepingNUMERICas text is cheaper than building aDecimal. The compatible choice is also the fast choice. - Rejecting undeclared identifiers is a speed win. It turns identifier resolution into a hash lookup against the registry, and it is what makes a compiled-SQL cache keyed on query shape sound.
- Frozen status codes and
to_dict()key names are serialization metadata, decided once at compile time, free per request.
The one real conflict is per-row model instances. The contract says a row must remain a genuine cls instance so user methods and @property keep working; the fast path wants zero Python objects. It resolves cleanly because they are different paths:
- Handler returns the queryset → nothing is materialized, ever. Zero Python objects. This path is never compromised for compatibility, and it is the one that carries the headline number.
- Handler iterates rows → real instances, with the Rust row handle as a lazy
__getattr__backing store so only touched columns decode.
If the lazy backing store cannot preserve instance semantics, the fallback is eager hydration on the iteration path only. The zero-object path is not cut, reduced, or made opt-in under any circumstance.
Standing performance commitments¶
Beyond the phases, these are required work, not optimizations to consider later:
- Prepared statement cache — see Phase 00. Removes a network round trip per query.
- Compiled query plan cache — a given
filter(...).order_by(...)shape assembles its SQL once, keyed on model plus lookup shape; repeat calls bind parameters into a cached plan and skip assembly entirely. - Serialization buffer reuse — rows serialize into a reused, pre-sized buffer that becomes the response body directly, with no intermediate
Vec<u8>and no copy at the response boundary. to_dict()is Rust-backed — the pattern every doc teaches must get faster too, not just the new one.
What ships¶
Each phase depends on the one above it.
00 — Prepared statement cache (ships in 1.3.1)¶
The single largest win in this document, and it is roughly twenty lines.
src/db/postgres.rs passes a &str to client.query() / client.execute() (lines 81, 94, 253, 269). In tokio-postgres that takes the ToStatementType::Query branch, which calls prepare::prepare(...) — a full Parse/Describe network round trip to Postgres before every single query. Cello pays it on every ORM call and every raw db.fetch().
deadpool-postgres already provides the fix and Cello never calls it:
let stmt = client.prepare_cached(&sql).await?; // cached per connection
let rows = client.query(&stmt, &refs).await?; // one round trip, not two
On a LAN Postgres with ~0.3 ms RTT this is close to halving query latency for short queries, and it requires none of the rest of this plan. It ships in 1.3.1 alongside the injection guard, ahead of the whole 1.4.0 line.
Speed — largest ratio of win to effort in the project
01 — The schema registry (src/schema/)¶
The foundation everything else reads. Python field declarations compile at import into a Rust ModelSpec: columns, Postgres types, nullability, defaults, constraints, visibility, relations. Nothing user-visible changes; the existing ORM is re-pointed at the registry and the identifier-injection class is closed.
Speed — groundwork · Security — injection class closed · no API change
02 — Native query compiler and row pipeline¶
SQL assembly, parameter binding and lookup resolution move into Rust against the registry. Adds the row-to-JSON fast path (a returned queryset never becomes a Python object) and lazy row handles for handlers that do read fields. The public Model.objects.filter(...) surface is unchanged — this is a re-implementation underneath, not a new API.
Speed — the headline · largest single piece · paired benchmark required
03 — Native validation and serialization¶
The registry becomes the type system: body=Model validates in Rust off the SIMD-parsed body; responses serialize from the registry with visibility rules applied; src/openapi.rs generates from the same specs instead of inferring. Pydantic remains a first-class alternative throughout.
Simplicity — one declaration · Speed · Security — write_only
04 — Advanced ORM features¶
The parity gap worth closing, in value order: Q objects (OR/NOT composition), aggregates (count/sum/avg/min/max, group_by), relations (select_related as a Rust join planner, prefetch_related, reverse accessors), bulk operations (bulk_create/bulk_update via a single multi-row statement). Each is far cheaper to build once the registry knows the schema.
Simplicity · cut from 1.4.0 if 02 slips
05 — Migrations (cello migrate)¶
Autogenerated by diffing the registry against live information_schema, emitted as reviewable SQL files with an applied-migrations table. Cold path, so Rust buys no speed here — it lives in Rust purely so the CLI ships as one binary with the extension. Ships behind an explicit --experimental flag in 1.4.0; a migration tool that is wrong once is worse than no migration tool.
Simplicity · experimental — data loss risk
Effort¶
Single experienced developer, focused. Tests and documentation are inside these numbers, not after them.
| Phase | New Rust | Focused effort | What dominates |
|---|---|---|---|
| 00 Statement cache (1.3.1) | ~20 lines | 1 day | Verifying the round trip actually disappears |
| 01 Schema registry | ~1.5k lines | 2–3 weeks | Python→Rust spec compile, type mapping |
| 02 Query compiler + row pipeline | ~2.5k lines | 4–6 weeks | Keeping the whole QuerySet surface green |
| 03 Validation + serialization | ~1.8k lines | 3–4 weeks | Error-shape parity with the 400 detail format |
| 04 Q / aggregates / select_related | ~2k lines | 4–5 weeks | The join planner is the hard half |
| 05 Migrations (experimental) | ~1.5k lines | 3–4 weeks | Diff correctness, not code volume |
Phases 01–03 — the actual 1.4.0 release — is roughly 9–13 focused weeks, plus stabilization. All five phases is 5–6 months.
Two assumptions behind that estimate:
- Test volume grows a lot. Cello has 7,960 test lines today and
tests/test_native_db.pyis only 246 of them. The ORM rewrite needs several times that before Phase 1 or 2 can land. - Phase 2 carries real benchmark time. The whole justification is a number that cannot be faked.
The riskiest line is Phase 2. It is not novel work, it is replacement work: 639 lines of working Python behavior must keep behaving identically while the engine underneath changes completely.
Compatibility contract¶
If you do not use the ORM, 1.4.0 changes nothing for you. Routing, middleware, request/response, guards, DI, templates and the raw app.database / app.redis API are all out of scope. db.fetch() keeps returning list[dict] with the same value shapes.
For ORM users, these are the guarantees — and the one deliberate break.
Frozen: value shapes¶
The registry must decode to exactly what pg_value_to_py returns today. Changing any of these is breaking even though it looks like an improvement:
| Postgres type | Stays | Not |
|---|---|---|
TIMESTAMPTZ | RFC 3339 string | datetime object |
TIMESTAMP | %Y-%m-%dT%H:%M:%S%.6f string | datetime object |
DATE / TIME | string | date / time object |
UUID | string | uuid.UUID object |
NUMERIC | string — correction: 1.3.0 actually returns the placeholder "<unsupported column type: numeric>" (even for NULL); see compat suite. Decoding it to a decimal string is a fix, not a break | Decimal or float |
JSONB / JSON | nested Python objects | raw string |
| unknown type | text decode, then placeholder | raised exception |
Richer types can be offered later as an opt-in per-field option. They cannot become the default in 1.4.0.
Frozen: model instances stay real instances¶
_from_row currently builds a genuine cls instance via cls.__new__(cls), so user-defined methods, @property, __str__, isinstance() checks and subclassing all work today. The lazy row handle must therefore be a backing store behind a real model instance (resolved through __getattr__), not a replacement for it. If that cannot preserve instance semantics, the fallback is eager hydration on the iteration path only — the zero-object path where a queryset is returned for serialization is never compromised. See Speed is the non-negotiable.
Also frozen: to_dict() key naming (field name for normal fields, <name>_id for foreign keys), DoesNotExist / MultipleObjectsReturned as per-model subclasses, and awaiting a QuerySet directly.
Frozen: validation status codes¶
Two paths exist today and both stay as they are — body=Model returns 400 {"detail": [...]}, type-hint validation returns 422. Unifying them would be a silent break for any client branching on the status code.
Not automatic: the speedup¶
Every doc and example currently teaches this shape:
That materializes every row in Python, which is precisely what the fast path exists to avoid. Existing code stays correct, but does not automatically get the headline win — the fast path engages when a queryset is returned for serialization, not when the handler has already walked it. Two obligations follow: to_dict() itself must get a Rust-backed implementation so the canonical pattern still improves, and the docs must be updated in the same release rather than leaving the old shape as the taught default.
The one deliberate break: undeclared identifiers¶
The injection fix means filter / exclude / order_by / update will reject any identifier that is not a declared column. Today they are passed through verbatim, so this breaks a real, currently-working pattern: querying a column that exists in the table but is not declared on the model.
Foreign keys are unaffected — the registry knows that author maps to author_id, so filter(author_id=5) keeps working.
Migration path:
- 1.3.1 — emit a
DeprecationWarningon an undeclared identifier and reject anything outside^[A-Za-z_][A-Za-z0-9_]*$outright. That closes the injection without breaking valid queries. - 1.4.0 — undeclared identifiers become an error, naming the column and pointing at the two supported alternatives: declare the field on the model, or drop to
await db.fetch(...)with parameters.
There is no .raw() on QuerySet today; db.fetch() is the documented escape hatch and stays one.
How this gets verified¶
tests/test_native_db.py and the ORM tests are the compatibility suite: they run unchanged against the native implementation. Any test that needs editing to pass is a break that has to be justified in this document first, not fixed in the test.
Explicit non-goals¶
1.4.0 will not:
- Move model declaration into Rust. The metaclass API is the DX. It stays Python.
- Add an admin site. Enormous surface, unrelated to the three pillars.
- Add MySQL or SQLite backends. The registry should be written so a second dialect is possible later; adding one now doubles the test matrix during the riskiest rewrite in the project's history.
- Add signals or model lifecycle hooks. They re-introduce a per-row Python call and undo Phase 2.
Honesty conditions¶
- The 1.3.0 threadpool work went in with a real measurement — 94 → 3,735 rps, paired runs. The 1.4.0 ORM claim meets the same bar or ships without a number.
cargo test --libstill cannot link libpython (pre-existing pyo3extension-modulelimitation); Python integration tests against a live Postgres remain the verification path.- Any phase that cannot be finished properly is cut, not shipped half-wired. The 1.3.0 audit found seven
enable_*plugins announcing features they did not have. That mistake does not get repeated in the data layer.