v1.4.0 — The Native Core¶
Released: October 2026
One model declaration, compiled once in Rust, drives the ORM, request validation, response serialization and OpenAPI. Design background: the v1.4.0 plan.
Headline numbers¶
Cello 1.3.0 against 1.4.0, both servers running at the same time, wrk -t2 -c32, 10 alternating rounds of 5 s per endpoint, local Postgres (benchmarks/orm/paired.py; raw results in benchmarks/orm/results-v130-vs-v140.json).
| Endpoint | 1.3.0 rps | 1.4.0 rps | Change (mean) |
|---|---|---|---|
200 rows, return {"users": qs} (row → JSON fast path) | 112 | 2,207 | +1,869% (19.7×) |
200 rows, [u.to_dict() for u in users] | 111 | 418 | +278% (3.8×) |
200 rows, raw db.fetch | 156 | 511 | +227% (3.3×) |
Model.objects.get(id=…) | 999 | 5,373 | +438% (5.4×) |
Model.objects.create(…) | 1,078 | 4,977 | +362% (4.6×) |
{"ok": true} control | 33,157 | 35,598 | +7.4% (within this box's noise) |
body= dataclass DTO, 20 fields | 10,616 | 10,577 | −0.4% (unchanged) |
How to read this:
- The box is a shared WSL2 VM. Its A/A noise floor is about ±1.5% on the mean (≈5% on the very fast control endpoint), see
benchmarks/orm/RESULTS.md. Only the database-backed endpoints moved by more than the noise, and by far more. - Response bodies are not byte-identical to 1.3.0 on the database endpoints: 1.4.0 fixes NUMERIC (1.3.0 returned a placeholder string) and other value shapes. The harness flags this; it is expected.
- The control and DTO endpoints exercise code 1.4.0 did not change on that path, so "unchanged" is the expected result. The native
body=Modelvalidator is measured separately in the Phase 03 section (18.1k vs 13.4k rps for Pydanticbody=). - These are one paired run on a shared machine. Re-run with the command in
benchmarks/orm/README.mdbefore quoting a figure externally.
What is in 1.4.0¶
| Area | Pillar | Summary |
|---|---|---|
| Prepared-statement cache, async bridge, serializer fast path, row → JSON pipeline | Speed | Sections below |
Identifier-injection fix, write_only/read_only, Postgres TLS, null-byte rejection | Security | Sections below |
Schema registry, native body=Model validation, OpenAPI from models, Q objects, aggregates, relations, bulk operations, cello migrate | Simplicity | Sections below |
Deliberate breaking changes (each is listed with its migration in the sections "Deliberate breaks — ORM" and "Value layer"): undeclared column names raise FieldError; NUMERIC/arrays/intervals decode to real values instead of a placeholder; out-of-range integers raise instead of wrapping; fetchrow/fetchval return the first row instead of raising; exclude() is null-safe; filter(x=None) means IS NULL; database errors are DatabaseError subclasses (still RuntimeError); the default Postgres sslmode is prefer.
Also fixed: async def shutdown hooks were skipped (with a "coroutine was never awaited" warning) when the server was stopped with Ctrl-C; SIGTERM was unaffected. This dates from 1.3.0. Ctrl-C reached CPython's signal handler as well as the server's and left a pending KeyboardInterrupt that the hook's first Python call raised and the server then filtered out. The pending interrupt is now consumed before shutdown hooks run (tests/test_shutdown_hooks.py).
Security fix — production error responses: an unhandled exception in a handler used to return its full text to the client in every environment, including env="production". With 1.4.0's database error messages that meant constraint names and row values (DETAIL: Key (email)=(a@b.c) already exists) could reach the client. With debug off (including env="production") the response is now a generic {"error": "Internal Server Error", "status": 500} and the details go to the server log with the request method and path. Development still returns the description (tests/test_error_exposure.py).
Phase 00 — Prepared statement cache and identifier guard¶
The plan schedules both of these for a 1.3.1 backport, ahead of the rest of 1.4.0.
Speed: prepared statements are cached per connection¶
Before: every fetch / fetchrow / fetchval / execute, on the pool and inside transaction(), passed a SQL string to tokio-postgres. Each call prepared an unnamed statement first: a Parse/Describe/Sync round trip to Postgres before the Bind/Execute round trip, followed by a Close. Every ORM call and every raw db.fetch() paid this.
Now: src/db/postgres.rs prepares through the pooled connection's statement cache (deadpool-postgres prepare_cached) and runs the cached Statement. The first call with a given SQL string on a connection does the prepare. Every later call with the same string is a single Bind/Execute round trip. This includes transactions, because a transaction holds a pooled connection.
Parameter binding is unchanged. SqlParam still picks its encoding from the target column type, and those types come from the cached statement.
Measured (loopback Postgres on the dev box, pool size 1, 1,000 sequential fetchval("SELECT $1::int", i)):
| Parse | Describe | Close | Sync | |
|---|---|---|---|---|
| 1.3.0 | 1,000 | 1,000 | 999 | 2,999 |
| Phase 00 | 1 | 1 | 0 | 1,001 |
These counts come from a small proxy that counts the frontend protocol messages between Cello and Postgres.
Latency was measured with 2,000 sequential fetchval calls. The 1.3.0 build and the Phase 00 build ran in alternation, 10 runs each, on the same machine under the same load:
| median | min | max | |
|---|---|---|---|
| 1.3.0 | 502 µs/query | 474 | 513 |
| Phase 00 | 385 µs/query | 351 | 401 |
That is about 23% lower latency per query on loopback. On loopback the network round trip is cheap, so most of the remaining ~385 µs is per-call overhead in the Python↔Rust async bridge, not network. The saving grows with network distance. With 1 ms of round-trip delay injected by a forwarding proxy, we measured 6.3 ms → 2.8 ms per query. The proxy delays every packet separately, so it overstates the baseline. Treat that pair as showing the direction of the effect, not as a benchmark.
Bounded cache. Each connection caches at most 512 statements. When it is full, that connection's cache is cleared, and dropping a statement deallocates it on the server. Code that builds SQL strings with literals in them therefore cannot grow server memory without limit. In our test, 1,300 distinct SQL strings on one connection left 277 server-side statements.
Schema changes (DDL) are handled. A cached statement can go stale. Example: a SELECT * cached before ALTER TABLE ... ADD COLUMN, or before a table is dropped and recreated with different columns. Postgres then rejects the statement with cached plan must not change result type (SQLSTATE 0A000). The same happens after DISCARD ALL / DEALLOCATE ALL (26000). Cello now:
- On the pool (
db.fetchand the other methods, plus the ORM): clears the statement cache of every connection in the pool, prepares the statement again and retries once. The caller does not see the error. We verified this for drop-and-recreate,ADD COLUMN,ALTER COLUMN ... TYPEandDEALLOCATE ALL. - Inside
transaction(): a retry is not possible, because the failed statement has already aborted the transaction. The caches are cleared and the error is raised with an explicit message: "…the schema changed under a cached prepared statement, which aborted this transaction. Statement caches have been cleared — retry the transaction." The next transaction succeeds. This only happens when DDL changes a table between two uses of the same SQL text on the same connection, and the second use is inside a transaction.
Query errors now include the server's message. Previously a server error reached Python as the bare text db error. It now reads, for example, db error: ERROR: relation "x" does not exist.
Connection poolers
A pooler in front of Postgres that runs in transaction pooling mode can route the statements of one session to different server connections. Cello now relies on named prepared statements, which conflicts with that mode. Use session pooling, or a pooler version that supports prepared statements.
Security: identifiers in ORM queries are validated¶
Before: QuerySet._resolve_column() returned any unrecognised key verbatim, and the ORM pasted it between double quotes into SQL. Values were always parameterised, but keys were not. A request that passed user-controlled keys to filter / exclude / order_by / update was injectable. For example, the filter key x" = 1 OR "1 closed the quote.
Now (python/cello/orm.py), following step 1 of the migration path in the plan:
- Invalid identifiers are rejected. A key or
order_byname that does not match^[A-Za-z_][A-Za-z0-9_]*$raisescello.orm.FieldError, which subclassesValueErrorand names the bad identifier. The check runs when the query is built, before any SQL reaches the database. The leading-oforder_byand thefield__lookupsuffix are removed first, and what remains is validated. The match is ASCII-only and checks the whole string, so a trailing newline does not pass. - Declared names work without warnings. These are field names, a ForeignKey's
<name>_idcolumn (sofilter(author_id=5)still works), and anydb_column. - Undeclared names that are valid identifiers still work but are deprecated. They emit a
DeprecationWarningthat points at the calling line, says this becomes an error in 1.4.x, and names the two alternatives: declare the field on the model, or useawait db.fetch(sql, *params). - Unsupported lookups on declared fields fail clearly. An unsupported lookup on a declared field, such as
name__iexact, now raises a clearFieldError. Before, the whole key was treated as a column name and Postgres failed with an unrelated error. - Names from model declarations are escaped. Table names,
db_columnvalues and ForeignKey targets are quoted with embedded"doubled. on_deleteis checked.ForeignKey(on_delete=...)must be one ofCASCADE,RESTRICT,SET NULL,SET DEFAULTorNO ACTION(any case).
Tests: tests/test_orm_injection.py (50 tests). Some use a recording stand-in pool to prove that rejected identifiers never reach the database. Others run against live Postgres and are skipped when it is unreachable. The existing ORM suites in tests/test_native_db.py pass unchanged with -W error::DeprecationWarning.
Phase 01 — The schema registry¶
No API change. Every ORM model is now compiled, once, into a Rust description of itself, and the ORM reads identifiers and DDL from that description instead of building them in Python.
Design¶
class User(Model) stays a Python declaration. When the class statement runs, the metaclass in python/cello/orm.py turns each field into a plain descriptor dict and calls cello._cello.schema_register(...). The new src/schema/ module compiles those into a ModelSpec and stores it in a process-wide registry (DashMap<ModelId, Arc<ModelSpec>>).
| Type | Holds |
|---|---|
ModelSpec | class name, table (raw and quoted), fields in metaclass order, primary key, the identifier map used for resolution |
FieldSpec | name, column (raw and quoted), Postgres type, nullability, unique, max_length, default, auto (database-generated), auto_now_add, read/write visibility, foreign-key target and on_delete |
PgType | SERIAL, INTEGER, BIGINT, DOUBLE PRECISION, BOOLEAN, VARCHAR(n), TEXT, JSONB, TIMESTAMPTZ, or Custom (a field's own sql_type, kept verbatim) |
DefaultSpec | none, now(), a callable (no DDL default), or a literal (bool / int / float / text / other) |
RelationSpec | target table, target primary-key column, target ModelId, and an OnDelete from a closed set |
Specs are plain Rust data (Send + Sync, no Python objects), so Phase 02 can read them from Tokio tasks without the GIL. PgType::wire_type() gives the tokio-postgres type a column decodes as, for the row-to-JSON path. Phase 03 reads the same FieldSpec for body validation, serialization and OpenAPI. The write_only visibility flag exists in the spec today and is always off; Phase 03 adds the write_only=True field option that sets it.
Identity. A ModelId comes from a counter and is never reused, unlike Python's id(). The class holds the owning handle at cls._meta.schema. When a class is garbage collected (for example a model defined inside a function or a test), its registry entry is removed, so redefining classes does not grow the registry.
The design is documented in full at the top of src/schema/mod.rs.
Security: identifiers resolve through the registry¶
The Phase 00 rules are unchanged: same FieldError and DeprecationWarning messages, raised in the same cases. What changed is where they are enforced.
- The identifier check (
^[A-Za-z_][A-Za-z0-9_]*$, ASCII, whole string) and quoting run in Rust (src/schema/ident.rs). - At class definition the registry hands Python a dict of every valid identifier that names a declared field or column, mapped to its quoted column. A field name wins over another field's column of the same name, as before. Declared columns that are not valid identifiers (
db_column="my col") are not in the dict, so they still cannot be used as query keys. - A name that is not in the dict goes to
ModelSpec.resolve()in Rust. It returns eitherNone(Python raisesFieldError) or a quoted undeclared identifier (Python emits theDeprecationWarning, so it still points at the caller's line). - Table names,
INSERT/UPDATEcolumn lists and the primary-key column insave()/delete()come from quoted values computed once by the registry. Python no longer quotes identifiers anywhere. on_deleteis checked again in Rust against the closed set, so changingfield.on_deleteafter construction cannot reach DDL.
DDL is rendered in Rust¶
create_table() / drop_table() send ModelSpec.create_table_sql() / drop_table_sql(). Field.column_ddl() still works and renders through the same code. The output is byte-for-byte what 1.3.x produced, including the pinned quirks: a dict default renders as Python's str() of it (so JSONField(default={"a": 1}) still fails at create_table), and a ForeignKey to a model without a primary key still references "None". One hardening: an int/float subclass whose __str__ does not produce a number is now quoted as a string literal instead of being pasted into the DDL. Real ints and floats, including inf and nan, render exactly as before.
Two declaration errors used to surface only at create_table: a ForeignKey whose to is not a model class, and a custom sql_type callable that raises. They still do. The class defines, queries work, and create_table raises the original exception.
Speed¶
Identifier resolution: a paired micro-benchmark in one process, alternating the Phase 00 module and this build, 11 rounds of 300,000 calls each, run twice. Medians from the second run:
_resolve_column(...) | Phase 00 | Phase 01 |
|---|---|---|
field name title | 498 ns | 132 ns |
ForeignKey name author | 536 ns | 128 ns |
ForeignKey column author_id | 1,150 ns | 133 ns |
db_column book_code | 1,303 ns | 129 ns |
undeclared nickname (warns) | 2,885 ns | 1,468 ns |
invalid a b (raises FieldError) | 791 ns | 819 ns |
filter(title=…, author_id=…) | 3,276 ns | 1,659 ns |
order_by("-author_id", "title") | 2,695 ns | 1,154 ns |
The Phase 00 cost was mostly the regex match and re-quoting the column on every call. A direct Rust call (schema.resolve) costs about 295 ns, which is still cheaper than Phase 00 but about five times the cost of a dict lookup (56 ns). That is why declared names use the dict the registry builds. The rejection path is about 30 ns (3–5%) slower, because it now misses the dict before reaching Rust. It ends in an exception, so it is not a hot path.
Class definition is a cold path and costs more: defining two models (2 + 8 fields) takes about 93 µs instead of 55 µs.
These numbers measure query building only. A query's database round trip is hundreds of microseconds, so they are not a request-level speedup.
Tests¶
tests/test_schema_registry.py (44 tests, no database needed): registry contents (foreign keys, db_column, subclass and mixin field order, defaults, custom types, models without a primary key), identifier resolution and rejection, _meta caches, DDL compared byte for byte against a copy of the 1.3.x Python renderer, deferred declaration errors, and registry lifetime. The compatibility suites (tests/test_orm_compat_*.py), tests/test_orm_injection.py and tests/test_native_db.py pass unchanged.
Deliberate breaks — ORM¶
1.3.x had ORM behaviours that looked like bugs. The compatibility suites pinned them with # PINNED-QUIRK: so they could not change silently. These are the ones 1.4.0 changes on purpose. Each changed assertion in tests/test_orm_compat_*.py, tests/test_orm_injection.py and tests/test_schema_registry.py is marked 1.4.0 (deliberate break). New tests: tests/test_orm_fixes_v140.py (45 tests).
Unchanged on purpose: the metaclass still removes field attributes from the class, a subclass's own fields still come before inherited ones, get() still replaces a user limit() with LIMIT 2, isnull=None still means IS NOT NULL, and save() on an existing row still writes every column.
1. Undeclared identifiers are an error¶
Step 2 of the plan's migration path. A filter / exclude / order_by / update / values name that is a valid identifier but is not a declared field or column now raises FieldError instead of a DeprecationWarning. The message names the column and points at the two alternatives: declare the field on the model, or use await db.fetch(sql, *params). A ForeignKey's <name>_id column and any db_column are still valid. Nothing is sent to the database.
Migrate: declare the column on the model, or move the query to db.fetch(...).
2. Defaults are applied when the instance is built¶
| 1.3.x | 1.4.0 | |
|---|---|---|
User(name="x").active (default=True) | None until saved | True |
callable default (default=uuid4) | never called; no value | called once per instance |
dict / list default | shared object | deep-copied per instance |
create(score=None), score nullable with default=5 | wrote 5 | writes NULL |
create(active=None), active NOT NULL with a default | wrote the default | database error (NOT NULL) |
DateTimeField(auto_now_add=True) (NOT NULL) | create() failed: explicit NULL | now() on INSERT |
DateTimeField(auto_now_add=True, null=True) | stored NULL | now() on INSERT |
auto_now_add fills the column with the database's now() when the value is None at INSERT. An explicit value is written as given. The new DateTimeField(auto_now=True) writes now() on every INSERT and every save() UPDATE, overwriting the value. Both get DEFAULT now() in the DDL, and the instance gets the stored timestamp string from RETURNING.
Migrate: code that passed None to mean "use the default" should leave the argument out.
3. create() always inserts; save() inserts a missing row¶
Model.objects.create(...)is always anINSERT, including with an explicit primary key. In 1.3.xcreate(id=99, ...)ran anUPDATE, matched nothing, and returned an instance that was never stored. Models with a non-AutoFieldprimary key (code = CharField(primary_key=True)) can now be created.save()inserts when the primary key isNone. Otherwise it runsUPDATE ... RETURNING, and if no row has that key it inserts the row. In 1.3.x that was a silent no-op. AnAutoField(or aSERIAL/BIGSERIALkey) left asNoneis omitted from theINSERT, and its value comes back throughRETURNING *.
An instance built with a primary key (User(id=1, name="x").save()) still updates that row if it exists. This keeps the pinned "update writes every field" behaviour.
4. delete() on a model without a primary key¶
Raises ValueError("<Model> has no primary key ..."), which points at Model.objects.filter(...).delete(). In 1.3.x it was a TypeError from getattr(self, None). delete() on an unsaved instance (pk None) still returns 0, and now does so without sending SQL.
5. ForeignKey¶
- Assignment.
book.author_idalways holds the id.book.authorholds what was assigned. Assigning an instance keeps the instance and copies its primary key intoauthor_id. Assigning an id stores the id in both. Assigningauthor_iddirectly drops a cached instance whose key differs. After a load, both attributes hold the id. After a save,authorkeeps an assigned instance while its key still equalsauthor_id. There is no lazy loading. The sync is a__setattr__installed only on models that declare a ForeignKey, and not on a model whose class or mixins define their own__setattr__. create(author=obj)andcreate(author=5)both store the id and setauthor_id.book.author = other; await book.save()now persistsother. In 1.3.x the staleauthor_idwon and the reassignment was silently lost.filter/exclude/updateaccept a related instance and use its primary key, including inside__in. An unsaved instance raisesValueError.values("author")returns the id, the same asvalues("author_id").- DDL type. The FK column type follows the target's primary key:
SERIAL→INTEGER,BIGSERIAL→BIGINT,SMALLSERIAL→SMALLINT, and anything else (VARCHAR(10),UUID, ...) as declared. 1.3.x always usedINTEGER, so a non-integer key could not be referenced. - A ForeignKey to a model with no primary key raises
ValueErrorwhen the class is defined. 1.3.x renderedREFERENCES t("None"), which failed atcreate_table.
6. JSONField defaults render as JSON¶
JSONField(default={"a": 1}) renders DEFAULT '{"a": 1}' (from json.dumps). 1.3.x rendered the Python repr '{''a'': 1}', which Postgres rejected. {} and [] render the same as before. A default that json.dumps cannot encode keeps the old rendering. The registry still reports the default kind as "other".
7. Equality and hashing¶
Two instances are equal when they are the same concrete class and have the same non-None primary key. hash() is hash((class, pk)). Instances without a primary key value (unsaved, or a model with no key) compare and hash by identity. Saving an unsaved instance changes its hash, so do not keep unsaved instances in sets or dict keys across a save().
8. QuerySet¶
| 1.3.x | 1.4.0 | |
|---|---|---|
filter(x=None) / exclude(x=None) | x = NULL: matches nothing | IS NULL / IS NOT NULL (x__ne=None is IS NOT NULL) |
x__in="abc" (str / bytes) | iterated per character | TypeError |
x__in=<generator / set / range> | generator: TypeError | any iterable, materialized once when the query is built |
contains / startswith / endswith / i* with % _ \ | wildcards | matched literally (ESCAPE '\'); new iendswith |
exclude(a=1, b=2) | NOT a AND NOT b | NOT (a AND b) |
exclude(...) with a NULL column | row dropped | row kept: (cond) IS NOT TRUE, correct for every lookup |
second order_by() | appended | replaces; order_by() clears |
count() / exists() after limit / offset | ignored the slice | counts the slice (subquery); exists() uses EXISTS |
update() / delete() after limit / offset | ignored the slice, hit every match | TypeError: cannot update/delete a sliced queryset |
update() with no fields | SQL error | returns 0, no query |
int value outside the column's range (INTEGER, BIGINT, SMALLINT, FK by target key) | wrapped (2**31 → -2**31) | ValueError naming the column and range, before any SQL (also for __in and update()) |
exclude(qty__gt=5) now includes rows where qty is NULL. To drop them as before, add .filter(qty__isnull=False).
9. ORM calls inside a transaction use it¶
Inside async with db.transaction() as tx: and inside a @transactional handler, ORM calls without using= run on that transaction and commit or roll back with it. In 1.3.x they ran on another pooled connection and survived a rollback. The open transaction is held in a contextvars variable (cello.database.current_transaction()). Tasks created inside the block (asyncio.gather, create_task) inherit it. Concurrent requests and tasks outside the block do not see it. An explicit using(db) always wins, so Model.objects.using(request.database) still reaches the pool from inside a block. Queries a block's tasks run concurrently are serialized on its single connection.
Swallowed errors. When a statement in the block fails and user code catches the error, Postgres aborts the transaction, and the COMMIT on a normal exit silently becomes a rollback. 1.4.0 detects this. After a failed statement the exit sends a probe (SELECT 1). If the transaction is aborted, the exit rolls back and raises cello.TransactionAborted (a RuntimeError), chained to the swallowed error. A failure that did not abort the transaction (a parameter rejected in Python, or a ROLLBACK TO SAVEPOINT recovery) still commits. The probe costs one round trip, and only after a statement in the block has failed.
Implementation: python/cello/database.py wraps the native Transaction's __aenter__ / __aexit__ / execute / fetch / fetchrow at import. This is Python-level wrapping. BEGIN / COMMIT / ROLLBACK and every query are still native.
Speed¶
Query building only, in one process. The 1.4.0-pre module and this build are loaded side by side, with 11 alternated rounds of 200,000 calls each. Medians:
| before | after | |
|---|---|---|
filter(title=…, author_id=…) | 2,841 ns | 2,939 ns (+3%) |
filter(pages__isnull=True) | 2,393 ns | 2,289 ns (−4%) |
filter(title__icontains=…) (LIKE escaping) | 2,442 ns | 2,693 ns (+10%) |
filter(id__in=[1, 2, 3]) (materialize, coerce, range check) | 2,683 ns | 3,391 ns (+26%) |
exclude(pages__gt=…) (null-safe conjunction) | 2,534 ns | 3,122 ns (+23%) |
order_by("-author_id", "title") | 1,991 ns | 2,015 ns (+1%) |
filter(…).order_by(…)._select_sql() | 6,933 ns | 6,723 ns (−3%) |
_from_row (5 columns) | 2,388 ns | 974 ns (−59%) |
to_dict() | 1,745 ns | 1,336 ns (−23%) |
The added per-condition work is the int range check and the related-instance check (one type() test on the common path), LIKE escaping, and building the exclude conjunction. In absolute terms that is under 1 µs on paths that are followed by a round trip of hundreds of µs. SQL assembly is faster because placeholders are numbered with str.split instead of a per-character loop. Row hydration and to_dict() are faster because the field plan is precomputed once per class, and hydration writes __dict__ directly. Absolute numbers on this box were higher than in the Phase 01 run (the machine was loaded). The before/after pairs were measured side by side.
Response serializer fast path¶
Profiling the 1.3.0 baseline (benchmarks/orm/RESULTS.md) showed write_json_value (src/json.rs) at ~47% of a 200-row list endpoint: it tried extract as bool, i64, u64 and f64 before String, so every string value raised and discarded up to four Python exceptions. It now dispatches on the concrete type (str, dict, list, bool, int, float) first and keeps the old chain as the fallback for subclasses and foreign numeric types (IntEnum, str enums, Decimal, …). python_to_json gets the same fast paths; string escaping copies unescaped runs in bulk.
- Output is byte-identical to 1.3.0 (strings with escapes/control chars/non-ASCII, ints up to and beyond u64, NaN/inf, tuples, enum/int/str/float subclasses,
Decimal, the non-string-key 500). - Paired
wrk(both servers live, alternating, -t2 -c32, 4 × 5 s), 200 rows × 8 fields returned as a dict, against the same tree without the fix: ~316 → ~1,750 rps (≈5.5×). Measured while other builds were running on the box; the per-round ratio stayed between 4.0× and 7.1×.
Value layer — Postgres types, parameters and errors¶
src/db/value.rs (decode and bind), src/db/error.rs (new) and src/db/postgres.rs. This changes the raw app.database / request.database API as well as the ORM. Each change below fixes a 1.3.0 bug and was approved as a deliberate break. Each one was pinned with # PINNED-QUIRK: in tests/test_orm_compat_values.py, and those tests now pin the new behaviour.
Deliberate breaks — value layer¶
1. NUMERIC decodes to its exact decimal string. In 1.3.0 every numeric value, NULL included, came back as the placeholder "<unsupported column type: numeric>". Values now decode from Postgres' binary format (base-10000 digits, weight, sign, display scale) to exactly what the server prints: "12.50", "12.3400", "-0.5", "0", "NaN", and "Infinity" / "-Infinity" on PostgreSQL 14+. The scale is kept, so numeric(10,2) gives "3.14" and never 3.14. NULL decodes to None. A string was chosen over Decimal because it is the frozen shape (see the plan's value-shape table) and the cheapest one to build.
Binding: a numeric parameter accepts decimal.Decimal, int (exact, any size), float (its shortest round-trip digits, so 0.1 binds as 0.1) and str ("12.50", "1e-5", "NaN"). The display scale matches what Postgres' own text input gives (Decimal("1.50") stays 1.50). A Decimal still binds to float4 / float8 as before.
2. NULL of any type decodes to None. 1.3.0 returned the placeholder for a NULL of an unsupported type (NULL::inet, NULL::numeric, …). A non-NULL value of a type that is still unsupported (point, box, ranges, …) still decodes to the placeholder, as the plan requires.
3. Arrays decode to lists, and lists bind to array parameters. 1-D arrays of int2, int4, int8, float4, float8, bool, text, varchar, uuid, numeric, timestamptz, date and jsonb decode to Python lists, with NULL elements as None. Elements decode exactly as the same scalar column would, so a numeric[] gives strings and a jsonb[] gives dicts. Arrays of every other supported scalar type (bytea[], interval[], enum arrays, …) decode the same way.
- Multi-dimensional arrays decode to nested lists (
ARRAY[[1,2],[3,4]]→[[1, 2], [3, 4]]). Postgres array lower bounds are dropped ('[0:1]={7,8}'→[7, 8]). - An array of an unsupported element type (
point[]) decodes to the placeholder for the whole column. - Binding: a
listortuplebound to an array parameter ($1::int4[],= ANY($1), an array column) is encoded as a Postgres array. Nested lists bind as a multi-dimensional array and must be rectangular. A ragged list raisesParameterValueError. Forjson[]andjsonb[], a nested list is a JSON array element. A list bound tojson,jsonbortextstill binds as JSON, as it did in 1.3.0.
4. "char", interval, money, inet, cidr, macaddr, timetz decode to text. All of these use binary decoders, with no extra round trip.
| Type | 1.3.0 | 1.4.0 |
|---|---|---|
"char" (the one-byte internal type, e.g. pg_class.relkind) | raised RuntimeError | one-character str ("" for the empty char) |
interval | placeholder | Postgres text form for IntervalStyle = postgres (the default): "1 year 2 mons 3 days 04:05:06.5", "-1 days +02:00:00", "00:00:00" |
timetz | placeholder | "13:14:15+02", "13:14:15.25-05:30" |
inet / cidr | placeholder | "10.0.0.1", "10.1.0.0/16", "10.0.0.0/8", "::1", "2001:db8::/32". The netmask is printed as Postgres prints it, and IPv6 follows RFC 5952. |
macaddr / macaddr8 | placeholder | "08:00:2b:01:02:03" |
money | placeholder | plain decimal with two fractional digits: "12.50", "-0.05" |
Caveats:
money's Postgres text form depends on the server'slc_monetary(e.g."$12.50"). The binary value has no currency or locale, so Cello returns the plain decimal and assumes two fractional digits.intervalis always rendered in thepostgresstyle, whatever the session'sIntervalStyleis.- The tests compare
interval,timetz,inet,cidrandmacaddrwith the server's own output function (format('%s', v)).
Two related decodes changed along with these. Enum values (and enum arrays) decode to their label, where 1.3.0 gave the placeholder. Infinite timestamp, timestamptz and date values decode to "infinity" / "-infinity", where 1.3.0 raised RuntimeError.
5. Integer parameters are range-checked. 1.3.0 truncated an int bound to int2 / int4 (two's-complement wrap: 2**31 → -2**31). An int larger than i64 became a float whose IEEE-754 bits were read as an int8 (2**63 → 4890909195324358656). Now an out-of-range value raises cello.ParameterRangeError (a DataError that is also an OverflowError) before the query is sent:
int8 takes the full i64 range, and oid takes 0..=4294967295. An int of any size binds exactly to numeric. This also affects the ORM: an IntegerField given 2**31, or a filter such as qty__gt=2**31 against an INTEGER column, now raises where 1.3.0 wrapped silently. The ORM compat test test_orm_compat_queryset.py::test_large_int_filter_values pinned the wrap and needs updating.
6. bool binds to text as "true" / "false", and mismatches raise a clear error. 1.3.0 sent a bool bound to a text parameter as its binary byte: True read back as "\x01", and False failed as a NUL byte. It now binds as "true" / "false" for text, varchar, bpchar and name.
Any other Python type that cannot be bound to the parameter's Postgres type raises cello.ParameterTypeError before the query is sent. Examples are an int for a text parameter, a float for int4, and a str for int4 or jsonb. The error is a DataError, so also a RuntimeError, and it is also a TypeError. It names the parameter and its type:
parameter $2: expected a value for Postgres type int8, got Python str (cast in SQL to parse text, e.g. $1::text::int8)
In 1.3.0 these reached the server in the wrong binary format and failed as an opaque RuntimeError, or, for some types, were silently misread. A ParameterTypeError is still a RuntimeError, so except RuntimeError still catches it.
A str is still sent as raw text to every type whose binary input is text: text-like types, json, xml, bytea, "char", enums, and extension types such as citext. That is unchanged.
7. Temporal and UUID parameters accept ISO strings and Python objects. A timestamptz, timestamp, date, time or uuid parameter now accepts both forms. Before, a string reached the server in the wrong format (RuntimeError), and a datetime / date / uuid.UUID failed in json.dumps (TypeError). This makes ORM DateTimeField writes work, both create(at="2024-01-01T00:00:00Z") and create(at=datetime(...)).
| Parameter | Accepts |
|---|---|
timestamptz | datetime (aware, or naive, which is treated as UTC), date (midnight UTC), ISO string (2024-01-02T03:04:05.5+05:30, …Z, space separator, no offset = UTC, date only), "infinity" / "-infinity" |
timestamp | as above. An offset is ignored and the wall-clock time kept, which is how Postgres parses text into timestamp. |
date | date, datetime (the time part is ignored, as Postgres does), ISO date string, "±infinity" |
time | time (any tzinfo is ignored, as Postgres does), "HH:MM[:SS[.ffffff]]" |
uuid | uuid.UUID, or any string uuid.UUID() would accept (hyphenated, 32 hex digits, upper case) |
Fractional seconds beyond microseconds are rounded, as Postgres rounds them. An unparsable value ("2024-02-30", "not-a-uuid") raises cello.ParameterValueError, which is a DataError and also a ValueError. A value of the wrong kind (a time for a date parameter, a datetime for text) raises ParameterTypeError. Decoding is unchanged: these types still come back as the frozen strings.
8. Database errors map to an exception hierarchy. 1.3.0 raised every driver and server error as a bare RuntimeError. Errors now map to these classes, which are exported from cello:
RuntimeError
└── DatabaseError .sqlstate .detail .constraint .table .column
├── IntegrityError SQLSTATE class 23
│ ├── UniqueViolation 23505
│ ├── NotNullViolation 23502
│ ├── ForeignKeyViolation 23503
│ └── CheckViolation 23514
├── DataError class 22 (and parameters Cello cannot bind)
│ ├── ParameterTypeError also TypeError
│ ├── ParameterRangeError also OverflowError
│ └── ParameterValueError also ValueError
├── OperationalError connection / pool failures; classes 08, 40, 53, 57, 58
└── ProgrammingError class 42; wrong number of parameters
DatabaseError subclasses RuntimeError, so 1.3.0 code that catches RuntimeError keeps working. A server error keeps its 1.3.0 message (db error: ERROR: … with DETAIL / HINT). The five attributes come from the server's error fields when it sends them, and are None otherwise. They are also None for client-side parameter errors, which never reach the server.
from cello import UniqueViolation
try:
await db.execute("INSERT INTO users (email) VALUES ($1)", email)
except UniqueViolation as e:
e.sqlstate # "23505"
e.constraint # "users_email_key"
e.table # "users"
e.detail # "Key (email)=(a@b.c) already exists."
These are built on the event-loop thread, where the GIL is already held. The I/O thread carries the error as plain Rust data, which keeps the async bridge's no-GIL I/O thread design.
9. fetchrow / fetchval return the first of several rows. In 1.3.0 they raised RuntimeError("query returned an unexpected number of rows") when the query yielded more than one row. They now return the first row, or its first column, and the remaining rows are discarded. That includes transaction().fetchrow. Zero rows still gives None.
The rows after the first are drained by the connection and never reach Python. They are still produced and sent by the server, so add LIMIT 1 when the result is large. An error raised by the server after the first row (e.g. a division by zero in row 2) is not reported.
Unchanged, still pinned: JSON object keys come back sorted, a JSON number outside the f64 range (1e400) fails the query, and duplicate column names collapse to the last value.
Fixes¶
| # | 1.3.0 behaviour | 1.4.0 behaviour | Raised as |
|---|---|---|---|
| 1 | numeric → placeholder (NULL too) | exact decimal string; Decimal / int / float / str bind exactly | — |
| 2 | NULL of an unsupported type → placeholder | None | — |
| 3 | arrays → placeholder; lists only bind as JSON | lists, nested for multi-dim; lists bind to array params | ParameterValueError (ragged) |
| 4 | "char" raised; interval / money / inet / cidr / macaddr / timetz → placeholder | text forms (money as plain decimal) | — |
| 5 | int wrapped into int2 / int4; > i64 garbled as float bits | range-checked | ParameterRangeError |
| 6 | bool → text sent as a binary byte; mismatches failed opaquely | "true" / "false"; mismatch names $n and the Postgres type | ParameterTypeError |
| 7 | ISO strings and datetime / date / UUID rejected | accepted (naive → UTC for timestamptz) | ParameterValueError / ParameterTypeError |
| 8 | every error a bare RuntimeError | SQLSTATE-mapped DatabaseError subclasses with .sqlstate etc. | — |
| 9 | fetchrow / fetchval raised on more than one row | first row | — |
| — | enum values → placeholder | label str | — |
| — | infinite timestamp / timestamptz / date raised | "infinity" / "-infinity" | — |
The prepared-statement cache and the stale-statement retry (Phase 00) are unchanged. The first-row path of fetchrow / fetchval goes through the same retry.
Speed¶
Decoding now reads each column's raw wire bytes once and builds the Python object directly, without an intermediate Rust String or Vec for text, bytea and uuid. fetch builds each column-name string once per result set instead of once per cell. Binding checks the common types (None, bool, int, float, str, bytes) with exact-type tests before the slower isinstance checks for Decimal, datetime, UUID and list.
Paired benchmark against the Phase 01 build (bench_values.py). Each round runs both builds as separate processes against the same Postgres 16 server, alternating, for 10 rounds, and the whole benchmark was run twice. Each process first runs 100 warm-up fetches. It then times 2,000 sequential db.fetch("SELECT * FROM vl_bench") of a 200-row × 8-column table (int4, int8, text, varchar, float8, bool, timestamptz, jsonb) on a 1-connection pool. Finally it times 5,000 sequential fetchval("SELECT $1::int4 + $2::int8, $3::text", …).
| Median of 10 rounds | Phase 01 | value layer |
|---|---|---|
| 2,000 × fetch 200 rows, run 1 | 2.45 s | 2.15 s (−12%) |
| 2,000 × fetch 200 rows, run 2 | 3.19 s | 2.73 s (−14%) |
| 5,000 × fetchval with 3 params, run 1 | 1.73 s | 1.78 s (noise) |
| 5,000 × fetchval with 3 params, run 2 | 2.11 s | 2.10 s (noise) |
Run 2 was taken while the machine was under other load, so its absolute numbers are higher, but the direction is the same in both runs. The bind path is dominated by the round trip, and no difference is measurable there.
Tests¶
tests/test_db_values_v140.py (128 tests, live Postgres). Where possible the server is the oracle:
- numeric: about 30 fixed literals and 400 random literals, each decoded value compared with the server's
::text. Bind round trips of 300 randomDecimals plus edge cases, each compared with the server's own parse of the same text. - arrays: every listed element type decoded and bound, with NULL elements, multi-dim, a non-1 lower bound, ragged / mixed-depth / range / type errors,
= ANY($1)and array columns. - text-form types:
interval,timetz,inet,cidr,macaddrandmacaddr8compared with the server's output function. - parameters and errors: integer bounds for each type, temporal and UUID coercion including offsets and rounding, the exception classes and their attributes for each integrity violation, data / programming / operational errors, and an integrity error inside a transaction.
- fetchrow / fetchval: first-row semantics on the pool and in a transaction, discarded rows not leaking into the next query on a 1-connection pool, and the stale-statement retry on the first-row path.
tests/test_orm_compat_values.py was updated only for the tests that pinned items 1–9. Every other pin in it is unchanged.
Phase 02 — The row pipeline¶
No breaking change. Existing code returns the same bytes; two new shapes are faster, and the shape every doc taught is faster too.
Row → JSON: return the queryset¶
@app.get("/users")
async def users(request):
return {"users": User.objects.filter(active=True).order_by("id")}
A QuerySet returned from a handler — on its own or anywhere inside the returned dict / list / tuple — is no longer an error. The response serializer (src/json.rs) leaves a hole where the queryset sits and keeps going. src/handler.rs then runs every such queryset on the persistent loop (concurrently when there are several) and splices the results in. The rows of each query are encoded on the cello-io thread, without the GIL, straight from the Postgres wire bytes into one pre-sized buffer (src/db/rows.rs). No Python object is created for any row or value.
The bytes are identical to serializing [u.to_dict() for u in await qs]:
- keys and their order come from the compiled
ModelSpec(ModelSpec::output_plan/output_columns): field names,<name>_idfor a ForeignKey, thedictrule when two fields produce one key,nullfor a declared column the table lacks, andwrite_onlyfields (the registry'sreadableflag) left out; - values: decoding was restructured so there is one type dispatch (
decode_cellinsrc/db/value.rs) feeding two writers,cell_to_py(thefetch/ ORM path) andwrite_cell_json(this path). The JSON writer uses the response serializer's own string escaping and number formatting (write_json_string,write_json_f64), so NaN / infinity arenull, floats print without an exponent, jsonb keys come out sorted, and so on, exactly as before.
await qs.json() does the same inside the handler and returns a cello.RawJSON (pre-serialized JSON the serializer writes verbatim; it cannot be built from arbitrary text). Use it when the query must run in the handler:
- Transactions. A returned queryset runs after the handler returns, on the pool (or its
using()database), outside any transaction the handler had open.await qs.json()runs inside it. - Errors. A returned queryset that fails becomes a
500the handler cannot catch.
Fallbacks, same bytes at Python speed: a model that overrides to_dict(), _from_row() or __getattribute__, or shadows a field attribute with a data descriptor such as a @property, is encoded from real instances; so is a queryset bound with using() to something that is not a native Database / Transaction. A bytea column raises TypeError (the serializer never accepted bytes). A queryset inside Response.json(...) or RawJSON.encode(...) raises an error that points at await qs.json().
Rust-backed to_dict() and native hydration¶
Model.to_dict()isModelSpec.to_dict(self)(src/schema/python.rs): the samegetattr(obj, name, None)reads, ForeignKey fallback to an assigned instance's primary key, and dict, built through the C API without the GIL pool.write_onlyfields are omitted.await qs/all()/get()/first()build instances natively (rows_to_models):cls.__new__(cls), then the_from_rowassignments into__dict__, skipping the per-row dict. Instances stay realclsinstances; user__new__, methods,@propertyandisinstancebehave as before. A model that overrides_from_rowkeeps the Python path.- The serializer accepts model instances (
return user,return {"users": await qs}) and writes theirto_dict()directly. json/jsonbvalues and decoded strings are built through the C API with owned references (json_to_py,py_str), which also speeds updb.fetch().
Lazy row handles: not done. The plan allowed eager hydration on the iteration path if a lazy backing store could not keep instance semantics. It cannot cheaply: vars(obj), obj.__dict__, copy, pickling and the ORM's own _load_row / ForeignKey sync read __dict__ directly. The taught pattern (to_dict()) reads every field anyway, so laziness would not help it. Native eager hydration was chosen.
Speed¶
Paired benchmarks/orm/paired.py (both servers live, wrk -t2 -c32, 10 × 5 s ABBA rounds, contaminated rounds re-run). New endpoint orm_list_qs returns the queryset; on a build without QuerySet.json it serves the to_dict() shape, so A/B compares the best shape each build offers.
Phase 02 alone. A = the same wheel with the Phase 02 Python paths patched back (Python to_dict(), all() via db.fetch + _from_row, no QuerySet.json); B = this build. The Rust value layer is identical on both sides.
| endpoint | A rps | B rps | delta | paired-round range |
|---|---|---|---|---|
| control | 34,695 | 34,531 | −0.5% | −9.8% … +7.7% |
orm_list (taught to_dict() shape) | 510 | 647 | +26.8% | +11.5% … +41.6% |
orm_list_qs (return the queryset) | 568 | 2,182 | +284% (3.8×) | +257.5% … +320.5% |
raw_list (unchanged code on both sides) | 697 | 641 | −8.0% (noise) | −24.3% … +13.1% |
orm_get | 5,650 | 5,764 | +2.0% (noise) | −15.0% … +15.2% |
raw_list runs identical code in A and B; its −8% with a −24% … +13% round range is this session's noise, wider than the A/A floor in benchmarks/orm/RESULTS.md (±1.5% on the mean). Only deltas well outside it are claimed: orm_list_qs and orm_list.
Against 1.3.0 (f9119e0, everything in 1.4.0 so far: statement cache, async bridge, serializer fix, value layer, Phase 02):
| endpoint | 1.3.0 rps | now rps | delta |
|---|---|---|---|
| control | 32,409 | 34,242 | +5.7% |
orm_list | 127 | 546 | +330% |
orm_list_qs (1.3.0 serves to_dict()) | 150 | 2,186 | +1,354% (14.6×) |
raw_list | 216 | 857 | +298% |
orm_get | 1,160 | 6,227 | +437% |
control moved +5.7% in that run, so read the others as ±~6% on top.
Micro (same process, timeit, 200 bench rows): to_dict() × 200 — Python 299 µs, native 148 µs. Loop-thread CPU for a 200-row query: qs.json() ≈ the cost of db.execute() with no rows (≈50–120 µs, per-row work happens on the I/O thread); db.fetch() / await qs ≈ 600 µs, most of it building Python objects for the jsonb column.
Query compiler: not done, by measurement¶
_select_sql() costs 2.9 µs for order_by("id").limit(200), 4.6 µs for the get(id=…) query and 7.1 µs for a three-condition filter with ordering. At the measured service times (≈175 µs per orm_get, ≈460 µs per orm_list_qs request in one saturated process) that is 0.6–2.6% of a request. Moving SQL assembly to Rust could recover at most that share, so it was not rewritten. Identifier resolution already goes through the registry (Phase 01).
Tests¶
tests/test_orm_fastpath.py (132 tests, live Postgres):
- Byte parity, raw: ~100 SQL expressions — every decoded type (ints, oid, float4/8 incl. NaN / ±inf / −0.0 / 1e20, numeric incl. NaN / ±Infinity, text with quotes / backslashes / control characters / non-BMP / U+2028,
"char", bpchar, uuid, timestamp(tz) / date / time / timetz / interval incl. infinities, json / jsonb with u64 / i64-min / floats / unicode / nesting, 1-D / 2-D / empty / lower-bounded arrays of many element types with NULLs, inet / cidr / macaddr / money, enum, domain, unsupported types → placeholder, NULLs) —_fetch_jsonvsfetch+ serializer, byte for byte. - Byte parity, models: a 20-field model (every ORM field plus numeric, uuid, date, timestamp, interval, int[] / text[], inet,
db_column, two ForeignKeys) with value and all-NULL rows:qs.json(), the taught shape, and serialized instances all equal a reference built with the 1.4.0-pre Python_from_row/to_dict(). Also: missing / extra table columns, a field named like a ForeignKey's column,write_only,@propertyshadowing, overriddento_dict/_from_row/__new__, a non-native database, transactions. to_dict()in every instance state (unsaved, assigned related instance, unsaved related instance, deleted attribute, extra attributes) vs the reference: values, key order,type(...) is dict.- HTTP:
{"users": qs}from async, sync andblocking=Truehandlers, a top-level queryset, several querysets in nested containers, model instances,RawJSON, a failing deferred query (500), a queryset insideResponse.json(clear error).
The compatibility suites, test_orm_fixes_v140.py, test_db_values_v140.py and test_orm_injection.py pass unchanged. Full suite: 1159 passed.
Phase 03 — Native validation, visibility and OpenAPI¶
body=Model with a cello ORM model now validates in Rust against the model's compiled ModelSpec. Pydantic, dataclass and plain-class body= are unchanged, and type-hint validation still returns 422.
Design¶
wrap_handler_with_body (python/cello/validation.py) detects an ORM model and builds one cello._cello.BodyValidator per route at decoration time. Per request it reads the Request's body bytes directly (no Python copy) and runs src/schema/validate.rs:
- A
serdeDeserializeSeedcompiled from the spec streams the JSON once. Each known key goes to a per-field seed that type-checks, coerces and checks constraints while parsing. Unknown and read-only keys are skipped withIgnoredAny. There is no intermediate JSON tree and no Python dict. - serde_json's 128-level recursion limit applies everywhere, including inside ignored keys and
JSONFieldvalues. Bodies over 32 KB are parsed with the GIL released. - Only after the whole body is accepted is the instance built:
cls.__new__(cls)and one attribute set per field — the same attributesModel.__init__sets, with absent fields filled from the model's init plan (literal default,default()called, mutable default deep-copied, elseNone). If the model overrides__init__,cls(**validated)is called instead, so the 1.3 behaviour is kept. - Errors are written straight to a 400
{"detail": [...]}body.
Decisions¶
| Question | Decision |
|---|---|
| Error item shape | Exactly {"loc", "msg", "type"}, the keys every 1.3 body= item had. type/msg use the Pydantic vocabulary (missing, int_parsing, int_from_float, string_too_long, bool_parsing, datetime_parsing, less_than_equal, ...). loc is [field] (or [column] when the FK column key was sent), and ["body"] for whole-body errors. Input values are not echoed, so a rejected password is never reflected. |
| Coercion | Pydantic lax mode for JSON input (table in docs/features/advanced/dto-validation.md). Strings are not coerced from numbers. Integers are range-checked against the column type. |
| Datetimes | ISO-8601 / RFC 3339, bare dates, Unix timestamps. Naive values are UTC. Stored as the RFC 3339 string a TIMESTAMPTZ row decodes to (frozen value shape), not a datetime. |
| Unknown keys | Ignored, matching the 1.3 default for both Pydantic and plain classes. |
AutoField / read_only=True | Ignored on input, like unknown keys. This prevents mass assignment: a body cannot set id and turn save() into an UPDATE of another row. |
| Required | Accepts input, null=False, no default, not auto_now_add. Absent optional fields get exactly what Model(**data) gives them (see "Defaults are applied when the instance is built"). |
| Duplicate keys | Last wins. |
| U+0000 | Rejected in string fields and anywhere in a JSONField value: {"type": "string_null_byte", "msg": "String should not contain null characters (U+0000)"}. Postgres refuses it in text/varchar and in jsonb, so it is a 400 before the handler instead of a database error after it. Custom sql_type columns are not checked. |
Visibility¶
write_only=True(any field): accepted on input, never serialized. It clearsFieldSpec.readable, so Phase 02's output plan leaves it out of the nativeto_dict(), of model instances returned from a handler, ofResponse.json(model)and of the queryset row → JSON path.read_only=True: serialized normally, ignored onbody=input. It does not affect what the ORM writes.- Both on one field raises
ValueErrorat declaration. - In the spec,
write_onlyclearsFieldSpec.readableandread_onlyclearsFieldSpec.writable. Both are input/output visibility only.autostill decides INSERT omission. - No serializer of its own: model instances and querysets are written by Phase 02's paths, which already honour
readable.writableis not used to choose INSERT/UPDATE columns anywhere.
OpenAPI¶
/openapi.json documents body= routes. Before this, every POST/PUT/PATCH got {"type": "object"}.
- ORM models get a schema generated from the spec by
src/openapi.rs::model_schema():type/format(int32,int64,double,date-time),maxLength,nullable,readOnly,writeOnly, literaldefaults, andrequired. Property keys are theto_dict()keys, in declaration order. - Pydantic models get their own JSON schema, with
$defsmoved intocomponents/schemas. - Both are added once to
components/schemasand shared by every route that uses them. Two different models with the same class name getNameandName2. These routes also document a400response. - Dataclasses, plain classes and routes without
body=keep{"type": "object"}.
Speed¶
20-field body (5 strings, 5 ints including one numeric string, 3 floats, 3 bools including "true", 2 datetimes, a JSON object, a text field). Sync handlers. The dev box is a shared aarch64 WSL2 VM, so absolute numbers are low. On it Python's json.loads takes 6.7 µs for this 355-byte body.
HTTP, paired (main build, with Phase 02 and the ORM/value-layer changes). Four servers ran simultaneously; wrk -t2 -c32 -d5s alternated between them, 6 rounds:
| Endpoint | mean rps | rounds |
|---|---|---|
Pydantic body= (1.3 path, unchanged) | 13,391 | 12.2k–14.2k |
ORM model body= (native) | 18,147 | 16.8k–19.3k |
ORM model body= as in 1.3 (Model(**request.json()), no validation) | 14,341 | 12.5k–15.2k |
request.json() only, no model | 16,517 | 16.0k–17.0k |
Native was fastest in every round: about 36% over Pydantic body=, and ahead of a bare request.json() because no Python dict is built. (An earlier run on the pre-Phase-02 tree measured 17,627 vs 12,231.)
Validation alone. One process, 11 alternating rounds of 20,000 calls, medians, main build. Each case includes building a Request (9.0 µs here), so subtract it:
| median | minus Request | |
|---|---|---|
Pydantic body= (request.json() + model_validate) | 27.9 µs | ~18.9 µs |
native BodyValidator(request) | 16.6 µs | ~7.6 µs |
native validate_bytes(raw) (no Request) | 6.4 µs | — |
Pydantic model_validate_json(bytes) (not what body= uses) | 5.5 µs | — |
Of the native ~7 µs, roughly 3 µs is parse + validate in Rust and the rest is creating the Python values and the instance. Pydantic's own bytes-in path is faster at validation alone. It builds datetime objects instead of strings and is not the path body= has ever taken. The error path (17 errors) costs about 2.8 µs.
Tests¶
tests/test_native_validation.py (58 tests, live HTTP) covers:
- the coercion and error tables, all-errors collection, exact item keys, and that inputs are not echoed; U+0000 rejection; absent fields filled like
Model.__init__(literal, callable and copied defaults); - invalid-JSON and non-object items, unknown and read-only keys being ignored, and duplicate keys;
- 100k-deep nesting (in a field and in an ignored key), the 413 limit, and the 32 KB GIL-release path;
- 400 malformed bodies fuzzed directly and 80 over HTTP (no 500, no panic);
write_onlyonto_dict(), on returned models, lists andResponse.json, and the spec flags;- custom
__init__, and unchanged Pydantic/dataclassbody=and 422 type-hint validation; - OpenAPI components, dedup and name collisions;
- a validated instance saved to live Postgres.
The validator also has Rust unit tests (src/schema/validate.rs). Like the rest of the crate, they cannot link under cargo test --lib.
Phase 05 — Migrations (cello migrate, experimental)¶
python -m cello migrate --experimental {make,apply,status,sql} compares the schema registry with the live Postgres catalog. make writes reviewable SQL files (migrations/NNNN_<slug>.sql). apply runs them under an advisory lock and records each one in cello_migrations(id, name, checksum, applied_at). Every command refuses to run without --experimental. See Migrations.
Design¶
- Rust decides, Python orchestrates.
src/schema/migrate.rsis a pure diff. It takes the registry'sModelSpecs, the live tables and the canonical column forms, and returns ordered operations, each marked destructive or not. It has no I/O, so Rust unit tests cover it.src/schema/migrate_conn.rsaddscello._cello.MigrationConnection: one dedicatedtokio_postgressession on a private runtime, driven with the GIL released. That session runs thepg_catalogintrospection (columns, defaults, identity and generated flags, PK, UNIQUE and FK constraints withON DELETE).python/cello/migrate.pyhandles model discovery, files, checksums, the apply loop and the CLI.python/cello/__main__.pyand thecelloconsole script dispatch to it. - Postgres renders both sides. Declared types and defaults are canonicalized by creating the models as temporary tables in a transaction that is always rolled back, then reading back
format_typeandpg_get_expr. No hand-written alias table can drift from the server, and customsql_typevalues work too. - Correctness rules. Destructive statements (
DROP TABLE,DROP COLUMN, type changes without a lossless assignment cast) are written commented out unless--allow-destructiveis given.applyrefuses live destructive statements without the flag. Renames are never guessed: a drop plus an add on the same table prints a warning. A narrowing type change uses the assignment cast, which raises on a value that does not fit, never a truncatingUSING ::varchar(n). Defaults are dropped and set again around a type change. Applied files are checksummed, and a file edited after it was applied stopsapplyandmake.makerefuses to run while files are pending. Each file runs in one transaction together with its bookkeeping row. - Locking.
applyholds a session-levelpg_advisory_lockon the dedicated connection, so lock, migrations and unlock always use the same session. When several deploys runapplyat once, one applies the files and the others wait, then find nothing to do. - Security. Every identifier goes through the registry's
quote_ident, including constraint names read from the catalog. The shadow tables are created through the extended protocol, one statement per call, so a declared type cannot smuggle in a second statement.
Hooks outside the new files¶
src/schema/mod.rs:pub mod migrate; pub mod migrate_conn;src/lib.rs: registersMigrationConnection.pyproject.toml:[project.scripts] cello = "cello.__main__:main".
Tests¶
tests/test_migrations.py: 26 live-Postgres tests. Each test runs in its own schema, which is dropped afterwards. They cover:- empty → create, with FK ordering
- adding a nullable column
- adding a NOT NULL column with a default (existing rows are filled)
- adding a NOT NULL column without a default (the file rolls back)
- widening and narrowing type changes, and flagged type changes
- NOT NULL and default changes
- FK add,
on_deletechange and FK drop - unique add and drop, and unique over duplicate values (rolls back)
- guarded destructive statements, including commented-out drops beside safe changes and the rename warning
DROP TABLEfor a removed model, and a disabled table drop that stays tracked- idempotence:
makeafterapplyfinds nothing makerefused while files are pending- checksum tampering refused (a CRLF-only change is accepted), a missing applied file, and transaction control in a file body
- lock contention: a held lock times out, and 4 concurrent applies apply the file once
- round trip: models → make → apply → introspect matches the registry
- quoting of identifiers that contain
", and the reserved table name - the CLI end to end in a subprocess, including the refusal without
--experimental - Rust unit tests in
src/schema/migrate.rs: - type-change classification
- creation order, and cycles deferred to
ADD FOREIGN KEY - column changes
- a flagged type change that disables its default statements
- FK and unique drift
- table drops ordered by their references
- identifier quoting
- duplicate table detection
To run them, link libpython: RUSTFLAGS="-L <libdir> -l python3.X" cargo test --lib schema::.
Limitations¶
The diff writes no SQL for:
- primary-key changes and SERIAL or identity conversions (it warns)
- multi-column constraints (it warns), CHECK constraints and indexes
- generated columns (it warns)
- statements that cannot run in a transaction
- defaults or identifiers that contain a line break (
makerefuses) - schemas other than
current_schema()
Data migrations and renames are written by hand.
Phase 04 — Advanced ORM features¶
Q objects, aggregates and grouping, relations, bulk writes, and the public raw-SQL fast path. Nothing here changes existing behaviour. The compatibility suites, test_orm_fixes_v140.py, test_orm_fastpath.py, test_orm_injection.py, the validation and migration suites all pass unchanged. User-facing documentation is in docs/data-layer.md (sections "Q objects", "Aggregates and GROUP BY", "Relations", "Bulk operations" and "Raw-SQL fast path").
Q objects¶
from cello.orm import Q. Q(**lookups) combines with |, & and ~, nests, and is accepted by filter() / exclude() / get() (also on the manager) alongside keyword lookups. Each lookup inside a Q is compiled by the same _condition as a keyword lookup, so field resolution through the registry, LIKE escaping, x=None → IS NULL, int range checks and related-instance coercion are identical. Negation is (...) IS NOT TRUE, the null-safe form exclude() has used since the ORM deliberate breaks. filter(~Q(x=5)) sends the same SQL text as exclude(x=5), and keeps rows where x is NULL. exclude(q) is filter(~q). ~~q is q: in a WHERE clause only "is it true" matters, and both forms are true for the same rows. An empty Q() adds no condition and is dropped from | / &.
Aggregates and GROUP BY¶
Count, Sum, Avg, Min, Max (all take distinct=True; Count() / Count("*") counts rows):
qs.aggregate(...)returns a dict. It aggregates the slice whenlimit/offsetare set, using the ordered query as a subquery.qs.values(...).annotate(...)doesGROUP BYon thevalues()fields.order_by()accepts the annotation aliases, andcount()counts groups.
Aliases are validated by the registry's identifier rule (new schema_ident, src/schema/python.rs) and quoted. Aggregate fields and group-by fields resolve through _meta.lookup. Value shapes are the value layer's, unchanged: sum(bigint) and avg(<integer>) are Postgres numeric, returned as a decimal string. sum(integer) is bigint, an int. Documented in a table in docs/data-layer.md.
API change: values() returns a queryset. It is still awaited exactly as before (await qs.values("a") gives the same list, from the same SQL). It is now a ValuesQuerySet (a QuerySet subclass) instead of a coroutine, so it can be chained (annotate, order_by, limit, ...) and returned from a handler. Field errors are raised when values() is called, not when it is awaited. A returned or json()-encoded values queryset is encoded natively by _fetch_json(None, ...), each key aliased to its name. The bytes are identical to serializing the awaited list (tested).
Relations¶
- Reverse accessors. A ForeignKey installs a non-data descriptor on its target:
<source>_set, orForeignKey(..., related_name=...). It returnsSource.objects.filter(fk=instance). Collisions: - an explicit name that clashes with a field, an attribute or another model's accessor raises
ValueErrorwhen the class is defined; - two default names from one model are made ambiguous (
AttributeErrorwith a hint); - a default name that clashes with an attribute is skipped;
- redefining a model (same module and qualname) replaces its accessors.
Only ForeignKeys declared on the class itself install accessors. - select_related("author__publisher", ...). One LEFT JOIN per ForeignKey over the base query as a subquery, so unqualified WHERE / ORDER fragments stay unambiguous. The outer ORDER BY repeats the ordering on "_c0". Model column segments are separated by NULL AS "__cello_join__". Database._fetch_related / Transaction._fetch_related (rows_to_model_graph in src/db/rows.rs) hydrate each segment with its own model's plan through the same Hydrator that rows_to_models now uses. They attach a related instance to its parent's __dict__ only when its primary key is non-NULL, so a NULL or dangling ForeignKey keeps the id. A database without _fetch_related, or a model in the graph that overrides _from_row, gets the same instances from one = ANY($1) query per relation. - prefetch_related(...). Each name is a ForeignKey or a reverse accessor, chained with __. Each path step is one WHERE key = ANY($1) query: a single array parameter, so the statement text is constant and cached. Reverse results are stored as a QuerySet holding the rows in the instance __dict__, so awaiting the accessor runs no query. Children get their ForeignKey attribute set to the parent instance. - Fast path kept. to_dict() never nests related objects, so json() / a returned queryset with select_related or prefetch_related runs the base query alone on the native path. The bytes are identical to [b.to_dict() for b in await qs] with the joins (tested, including _run_querysets). Models that fall back to instance encoding keep their relations, in case a custom to_dict() reads them. - A ForeignKey with db_column equal to its name keeps the id under the field name, so it cannot hold an instance. select_related / prefetch_related reject it with FieldError.
Bulk operations¶
Model.objects.bulk_create(objs, batch_size=None, using=None): one multi-rowINSERT ... RETURNING *per batch. The write plan is the onesave()uses:- an
AutoField/ serial left asNoneisDEFAULT, and the column is omitted when no row sets it; auto_nowisnow();auto_now_addisnow()when unset;- ForeignKey instances are written as their primary key.
Each instance is refreshed with _load_row from its returned row, relying on Postgres returning a multi-row INSERT ... RETURNING in VALUES order. A row-count mismatch raises. - bulk_update(objs, fields, batch_size=None, using=None): one UPDATE t SET col = "_v"."cN" FROM (VALUES ($1::type, ...), ...) AS "_v" (...) WHERE t.pk = "_v"."c0" per batch, returning the number of rows updated. - Field names resolve through the registry. - auto_now fields are set to now() and read back. - Casts come from the registry's column type. Serial types map to their integer type, and length / precision modifiers are dropped, so VARCHAR(10) overflow raises instead of being truncated by an explicit cast. - Duplicate or missing primary keys raise ValueError before any SQL. - Batches are capped at 65535 // params_per_row rows. Several batches on a pool run in one transaction, so the call is all-or-nothing (tested). On a transaction, they use it.
Raw-SQL fast path: db.fetch_json(sql, *params)¶
The public form of _fetch_json(None, ...) on Database and Transaction. It returns a cello.RawJSON whose bytes equal serializing await db.fetch(...). A failure inside a transaction is tracked like fetch (TransactionAborted). _fetch_related and fetch_json were added to the transaction hooks in python/cello/database.py.
Speed¶
All runs are on one box against local Postgres, in alternated rounds, and report medians. The scripts were run ad hoc from the session scratchpad and are not committed.
| median | range | |
|---|---|---|
1,000 rows, loop of create(), autocommit (7 rounds) | 2,082 ms | 1,802–2,362 |
1,000 rows, loop of create() inside one transaction (9 rounds) | 498 ms | 433–719 |
1,000 rows, bulk_create (two separate runs) | 44–65 ms | 37–83 |
| 200 books + author, N+1 loop (21 rounds) | 116 ms | 55–141 |
200 books + author, select_related | 1.94 ms | 0.99–2.90 |
200 books + author, prefetch_related | 2.21 ms | 1.16–2.95 |
bulk_create is about 11× the transactional loop and about 32× the autocommit loop. select_related is about 60× the N+1 loop.
QuerySet._clone keeps an attribute-by-attribute path for plain QuerySet and a generic __dict__ copy only for subclasses. A plain generic copy measured +260 ns per filter(). With the specialised path, filter(title=…, author_id=…) is 2,660 ns against 2,715 ns before (11 alternated rounds, within noise).
Tests¶
tests/test_orm_advanced.py has 28 tests, live Postgres plus recording stand-ins:
- Q: SQL equality of
~Qandexclude, nesting, emptyQ, registry errors, and live NULL semantics. - Aggregates: alias and field injection attempts (no SQL sent), SQL shape, every value shape (including empty sets and slices), GROUP BY with ordering on aliases, count of groups, and JSON parity for
values()querysets. - Relations:
- validation and joined-SQL shape;
- native hydration, including NULL and nested-NULL ForeignKeys and equal column names;
- fallback (plain database, overridden
_from_row) equals native; - the JSON fast path is byte-identical and skips the join;
- transactions;
- reverse accessors and every collision rule;
- prefetch with query counting (cached accessors run no query), nested and empty.
- Bulk:
- batches and statement counts, defaults,
auto_now*, mixed explicit primary keys, a non-serial primary key; - the 65,535-parameter split (7,000 rows × 10 parameters → 2 statements);
- multi-batch atomicity;
bulk_updatevalues, casts (jsonb, FK, NULL, db_column name),auto_nowread back, every error, and no truncation.fetch_json: parity withfetch, inside a transaction, and abort tracking.
Full suite: 1271 passed.
Not done¶
- Lookups across relations (
filter(author__name=...)),HAVINGon annotations, and per-objectannotate()withoutvalues(). Each needs a join planner for WHERE clauses, not only for SELECT. select_relatedon reverse relations (that isprefetch_related).- Prefetch keys are bound as one array parameter. Integer and text keys are tested. A
uuidkey, decoded as a string, was checked by hand to bind touuid[]. Other key types are untested.
Postgres TLS¶
Postgres connections (the enable_database() pool and the cello migrate session) now support TLS. Before this release both connected with tokio_postgres::NoTls, so every query, row and password crossed the network in plaintext, whatever the server was configured to do.
What changed¶
- libpq
sslmode:disable,prefer(the default, as in libpq),require,verify-caandverify-full.allowis treated asprefer. The option is accepted in the URL query string, in libpqkey=valueDSNs and asDatabaseConfig(sslmode=...). TheDatabaseConfigoption overrides the URL. sslrootcert: a PEM CA file; when it is given, only those CAs are trusted. When it is omitted or set tosystem, the bundled Mozilla roots (webpki-roots) are used.sslcert/sslkey: PEM client certificates. As in libpq,require+sslrootcertverifies the chain (verify-ca).- One TLS stack: this is a small
MakeTlsConnectimplementation over the existingrustls 0.22/tokio-rustls 0.25(src/db/tls.rs), so it adds no OpenSSL or native-tls. It also implements SCRAM channel binding (tls-server-end-point), soscram-sha-256logins over TLS use SCRAM-SHA-256-PLUS, andchannel_binding=requireworks. - Unix-socket hosts never use TLS, and
sslmodeis ignored for them, as in libpq. - Errors: bad certificate paths, non-PEM files, an unknown
sslmode, andsslcertwithoutsslkeyraiseValueErrorfromenable_database()(or fromMigrationConnection(...)). Handshake failures raiseOperationalError. Pool-checkout errors now include their cause chain (... invalid peer certificate: NotValidForName,... FATAL: no pg_hba.conf entry ... no encryption). Before, they showed only the driver's generic "error performing TLS handshake" or "db error". app.database.sslmodereports the effective mode, and theenable_database()banner prints it.
Behaviour change¶
prefer is the new default, so an app pointed at a server with ssl = on now gets an encrypted connection where it used to get a plaintext one. This includes localhost on Debian/Ubuntu, whose packaged clusters ship with the snakeoil certificate. That matches what psql does. To keep plaintext, pass sslmode="disable". In production, use sslmode="verify-full": prefer and require do not authenticate the server.
Performance¶
The handshake runs once per pooled connection, on the cold path. Measured on localhost (aarch64 WSL2, PG 16, interleaved runs):
disable | require (TLS 1.3) | |
|---|---|---|
New connection + first SELECT 1, p50 | 11.1 ms | 18.7 ms |
SELECT 1 on a warm connection, p50 | 413–596 µs | 439–591 µs (≈0–6 %) |
| 1,000 rows × ~100 B, p50 | 1.84–2.58 ms | 1.99–2.76 ms (≈7–8 %) |
Tests¶
tests/test_db_tls.py (23 tests) starts two throwaway clusters, using initdb into a temp dir on non-default ports, and removes them afterwards:
- a TLS cluster (
ssl=on, ahostssl-onlypg_hba,certauth for one role) with an EC CA and server/client certificates generated byopenssl - a plaintext cluster (
ssl=off)
The tests cover:
verify-fullwith the right CA, and with the wrong hostname (NotValidForName)- the wrong CA, and public roots against a private CA (
UnknownIssuer) verify-caignoring the hostnamerequirewithout a CA- the
require+sslrootcert→verify-caupgrade - the
preferdefault disableagainst ahostssl-only server (rejected)preferfalling back on a non-TLS serverrequireagainst a non-TLS server (fails)- a config option overriding the DSN
channel_binding=require(SCRAM-SHA-256-PLUS)- client-certificate auth
- text, jsonb, bytea and a 200 KB value round-tripping over TLS, plus a transaction
pg_stat_ssl.ssl = truefor the session- the migration session over TLS, and its hostname failure
- configuration errors at
enable_database()time, and Unix-socket hosts disabling TLS
The tests skip with a reason when initdb/pg_ctl (also looked up in /usr/lib/postgresql/*/bin) or openssl are missing. When the tests run as root, the clusters run as the postgres user.
Rust unit tests in src/db/tls.rs cover:
- DSN splitting, for URLs and
key=valuestrings with quoting and escapes - mode parsing and override precedence
- the Unix-socket rule
- bad-path errors
- the channel-binding hash selection
Limitations¶
- There is no implicit
~/.postgresql/{root.crt,postgresql.crt,postgresql.key}lookup. - There are no CRLs.
- Encrypted private keys and
sslpasswordare not supported. preferdoes not retry in plaintext after a failed handshake. It still falls back when the server declines TLS.- Channel binding is offered only for RSA PKCS#1 and ECDSA certificate signatures. With RSASSA-PSS and Ed25519 certificates it falls back to plain SCRAM, so
channel_binding=requirefails with those.