ORM API¶
Cello's ORM is a small async ORM over the native Postgres pool. A model is a Python class declaration that Cello compiles once into a Rust schema registry. The same compiled description drives the SQL the ORM builds, the native body=Model request validation, response serialization and the OpenAPI schema.
For a guided tour see Database & Redis. For the raw query API, transactions and the exception hierarchy see Database API.
Requirements
Python 3.12+ and PostgreSQL. Every example on this page was run against a live Postgres server on Cello 1.4.0.
Importing¶
from cello.orm import (
setup, Model,
Field, AutoField, IntegerField, BigIntegerField, FloatField, BooleanField,
CharField, TextField, JSONField, DateTimeField, ForeignKey,
Q, Aggregate, Count, Sum, Avg, Min, Max,
DoesNotExist, MultipleObjectsReturned, FieldError,
TransactionAborted, current_transaction,
)
Manager and QuerySet are importable from cello.orm as well, but they are not in __all__; you normally reach them as Model.objects and by chaining.
Binding the ORM to a database¶
setup(database)¶
Binds the ORM to a native Database pool. Call it once in a startup hook.
from cello import App, DatabaseConfig
from cello.orm import setup
app = App()
app.enable_database(DatabaseConfig(url="postgresql://user:pass@localhost/mydb"))
@app.on_event("startup")
async def startup():
setup(app.database)
Which database a query runs on is decided in this order:
- An explicit
using=argument or.using(db). - The ambient transaction of the current task, if you are inside
async with db.transaction()or a@transactionalhandler (see Transactions). - The database passed to
setup().
If none of these applies, the query raises RuntimeError("ORM is not bound to a database. ...").
Declaring models¶
from cello.orm import Model, AutoField, CharField, IntegerField, BooleanField
class User(Model):
id = AutoField()
name = CharField(max_length=100)
email = CharField(max_length=255, unique=True, null=True)
age = IntegerField(null=True)
active = BooleanField(default=True)
class Meta:
table_name = "users"
| Rule | Behaviour |
|---|---|
| Table name | Meta.table_name, otherwise the lowercased class name (class NoMeta(Model) is table nometa). table_name is the only Meta option. |
| Field order | Declaration order. Inherited fields are added after the class's own fields. |
| Primary key | The first field with primary_key=True (AutoField always is). A model may have none, but then it cannot be the target of a ForeignKey, and an instance cannot be deleted on its own. |
| Class attributes | Field attributes are removed from the class; values live on instances. |
Model.objects | The Manager. |
Model.DoesNotExist / Model.MultipleObjectsReturned | Per-model subclasses of the module-level exceptions. |
Model._meta | Internal compiled metadata (table_name, fields, pk_name). Not a stable API. |
Defining a class registers it in the process-wide schema registry. When the class is garbage collected its registry entry is removed.
Fields¶
Every field class accepts the options of Field. CharField, DateTimeField and ForeignKey add their own.
Field(primary_key=False, null=False, default=None, unique=False, db_column=None, write_only=False, read_only=False)¶
| Option | Type | Default | Meaning |
|---|---|---|---|
primary_key | bool | False | Marks the primary-key column. |
null | bool | False | True allows NULL. A column without null=True is NOT NULL. |
default | any, or a callable | None | Applied when the instance is built. A callable is called once per instance (default=uuid4), and a dict / list default is deep-copied per instance. Also written into the DDL as DEFAULT for literal values (bool, number, text, and JSON for JSONField). A callable has no DDL default. |
unique | bool | False | Adds UNIQUE to the column. |
db_column | str | None | Column name when it differs from the attribute name. Queries may use either the field name or the column name. |
write_only | bool | False | Accepted by body= validation, never serialized: left out of to_dict(), returned model instances, Response.json(model) and the queryset row-to-JSON path. |
read_only | bool | False | Serialized normally, ignored on body= input. It does not change what save() writes. |
Setting both write_only and read_only raises ValueError at class definition.
Field classes¶
| Class | Postgres type | Extra options | Notes |
|---|---|---|---|
AutoField | SERIAL PRIMARY KEY | none | primary_key=True is forced. Left as None, it is omitted from INSERT and read back through RETURNING. |
IntegerField | INTEGER | none | Values outside the 32-bit range raise ValueError in queries and ParameterRangeError when bound. |
BigIntegerField | BIGINT | none | |
FloatField | DOUBLE PRECISION | none | |
BooleanField | BOOLEAN | none | |
CharField | VARCHAR(n) | max_length: int = 255 | Native validation rejects longer strings with string_too_long. |
TextField | TEXT | none | |
JSONField | JSONB | none | Reads back as nested Python dict / list / scalar. A dict / list default is copied per instance and rendered in the DDL as JSON. |
DateTimeField | TIMESTAMPTZ | auto_now_add=False, auto_now=False | Reads back as an RFC 3339 string. See below. |
ForeignKey | the target primary key's type | to, on_delete="CASCADE", related_name=None | See below. |
A custom field is a Field subclass whose sql_type class attribute is a type string (or a callable field -> str).
DateTimeField(auto_now_add=False, auto_now=False, **options)¶
| Option | Behaviour |
|---|---|
auto_now_add=True | When the value is None at INSERT, the column is set to the database's now(). An explicit value is written as given. The column gets DEFAULT now(). |
auto_now=True | now() on every INSERT and every save() UPDATE, overwriting any value. The column gets DEFAULT now(). |
The instance receives the stored timestamp from RETURNING:
class Book(Model):
id = AutoField()
title = CharField(max_length=100)
created = DateTimeField(auto_now_add=True)
updated = DateTimeField(auto_now=True)
b = Book(title="T")
await b.save()
b.created # '2026-10-11T06:56:48.070801+00:00' (a str)
ForeignKey(to, on_delete="CASCADE", related_name=None, **options)¶
class Book(Model):
id = AutoField()
author = ForeignKey(Author, on_delete="CASCADE", related_name="books", null=True)
| Option | Meaning |
|---|---|
to | The target model class. It must declare a primary key, otherwise ValueError at class definition. |
on_delete | One of CASCADE, RESTRICT, SET NULL, SET DEFAULT, NO ACTION, in any case ("set null" works). Anything else raises ValueError. |
related_name | Name of the reverse accessor on the target. Must be a Python identifier that does not start with _. Default: <source lowercased>_set. |
- Column.
<name>_id(ordb_column). Its SQL type follows the target's primary key:SERIALbecomesINTEGER,BIGSERIALbecomesBIGINT, and other keys (VARCHAR(5),UUID, ...) are used as declared. - Attributes.
book.author_idalways holds the id.book.authorholds what you assigned (an instance or an id). Assigning an instance copies its primary key intoauthor_id; assigningauthor_iddirectly drops a cached instance whose key differs. Rows loaded from the database hold the id in both. There is no lazy loading: useselect_relatedorprefetch_related. to_dict()writesauthor_id, never a nested object.- Queries.
filter(author=obj),exclude,updateandcreate(author=obj)accept a related instance and use its primary key (an unsaved instance raisesValueError).values("author")andvalues("author_id")both return the id. - Reverse accessor. See Relations.
Model¶
Construction and defaults¶
- Defaults are applied at construction; an explicit
Noneis kept and stored asNULL(leave the argument out to get the default). - Unset fields without a default are
None. - A callable default is called once per instance.
Instance methods¶
| Method | Description |
|---|---|
await obj.save(using=None) -> Model | Inserts when the primary key is None (or the model has none). Otherwise runs UPDATE ... RETURNING for every column, and inserts the row when no row has that key. Database-generated values (AutoField, auto_now, auto_now_add) are read back. Returns the instance. |
await obj.delete(using=None) -> int | Deletes the row by primary key and returns the count. Returns 0 without sending SQL when the primary key is None. Raises ValueError on a model with no primary key (use Model.objects.filter(...).delete()). |
obj.to_dict() -> dict | One key per field, in declaration order, <name>_id for a ForeignKey, write_only fields omitted. Built natively. |
Class methods¶
| Method | Description |
|---|---|
await Model.create_table(using=None, if_not_exists=True) | Runs the CREATE TABLE rendered by the registry. |
await Model.drop_table(using=None, if_exists=True) | Runs DROP TABLE. |
For schema changes on an existing database see Migrations (experimental).
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, so saving an unsaved instance changes its hash. Do not keep unsaved instances in sets or as dict keys across a save().
Manager¶
Model.objects forwards to a fresh QuerySet. It has only these members:
| Method | Returns |
|---|---|
all() | QuerySet |
filter(*q, **lookups) / exclude(*q, **lookups) | QuerySet |
order_by(*fields) | QuerySet |
select_related(*paths) / prefetch_related(*names) | QuerySet |
values(*fields) | ValuesQuerySet |
using(db) / get_queryset(using=None) | QuerySet bound to db |
await get(*q, **lookups) | one instance |
await create(using=None, **fields) | the inserted instance |
await count() | int |
await aggregate(*aggs, **named) | dict |
await bulk_create(objs, batch_size=None, using=None) | list |
await bulk_update(objs, fields, batch_size=None, using=None) | int |
limit, offset, first, exists, update, delete and json are QuerySet methods. Call Model.objects.all() (or filter()) first: await User.objects.all().limit(10).
create(using=None, **fields)¶
Always an INSERT, including with an explicit primary key, so models with a non-AutoField key can be created. Returns the instance populated from RETURNING *.
QuerySet¶
A QuerySet is lazy, chainable and awaitable. Every chaining method returns a new queryset; await qs (or await qs.all()) runs it and returns a list of instances.
Field names in queries are validated when the query is built, not when it is awaited: User.objects.filter(nope=1) raises FieldError immediately.
Chaining¶
| Method | Description |
|---|---|
filter(*q, **lookups) | Rows for which every condition is true. Positional arguments must be Q objects; all conditions are ANDed. |
exclude(*q, **lookups) | Rows for which the conditions, ANDed, are not true. exclude(a=1, b=2) is NOT (a = 1 AND b = 2). Rows where a column is NULL are kept. exclude(q) is filter(~q). |
order_by(*fields) | "name" ascending, "-name" descending. A second order_by() replaces the first; order_by() clears. Names go through the registry. |
limit(n) / offset(n) | LIMIT / OFFSET. |
select_related(*paths) | See Relations. select_related(None) clears. |
prefetch_related(*names) | See Relations. prefetch_related(None) clears. |
using(db) | Run on db (a Database or Transaction) instead of the ambient default. |
values(*fields) | A ValuesQuerySet of dicts. See Aggregates. |
Terminal operations¶
| Method | Returns | Description |
|---|---|---|
await qs / await qs.all() | list[Model] | Runs the query. Instances are built natively. |
await qs.get(*q, **lookups) | Model | Exactly one row. Raises Model.DoesNotExist or Model.MultipleObjectsReturned. It always sends LIMIT 2, replacing a limit() you set. |
await qs.first() | Model \| None | First row (LIMIT 1). |
await qs.count() | int | Number of rows. After limit() / offset() it counts the slice (via a subquery). |
await qs.exists() | bool | EXISTS. Respects limit() / offset(). |
await qs.aggregate(*aggs, **named) | dict | See Aggregates. |
await qs.update(**fields) | int | UPDATE on every matching row; returns the row count. With no fields it returns 0 without sending a query. |
await qs.delete() | int | DELETE on every matching row; returns the row count. |
qs.json() | awaitable of cello.RawJSON | See Returning rows. |
update() and delete() on a sliced queryset (after limit() / offset()) raise TypeError, because the slice would otherwise be ignored and every match affected. Filter on the primary key instead, for example .filter(id__in=[...]).delete().
await User.objects.filter(age__lt=18).update(active=False) # -> rows updated
await User.objects.filter(active=False).delete() # -> rows deleted
await User.objects.filter(age__gt=65).exists() # -> bool
Returning rows from a handler¶
@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 run after the handler returns and its rows are encoded to JSON on the I/O thread, straight from the Postgres wire format, with no Python object per row. The bytes equal serializing [u.to_dict() for u in await qs].
Because the query runs after the handler returns:
- it runs on the pool (or the queryset's
using()database), outside any transaction the handler had open; - a database error becomes a
500that the handler cannot catch.
When either matters, call await qs.json() inside the handler. It takes the same native path and returns a cello.RawJSON that the response writes verbatim:
A model that overrides to_dict(), _from_row() or __getattribute__, or shadows a field with a @property, is encoded from real instances instead (same output, no speed-up). A bytea column raises TypeError.
Lookups¶
A keyword has the form field or field__lookup. Only the last __ segment is treated as a lookup, and only when it is one of the names below. An unsupported lookup on a declared field (for example name__iexact) raises FieldError listing the supported ones.
| Lookup | SQL | Notes |
|---|---|---|
exact (default) | col = $n | field=None becomes col IS NULL. |
ne | col != $n | field__ne=None becomes col IS NOT NULL. |
gt / gte / lt / lte | > / >= / < / <= | |
in | col IN ($1, $2, ...) | Any iterable (list, tuple, set, generator, range), materialized once when the query is built. A str / bytes raises TypeError. An empty iterable matches nothing (FALSE). |
isnull | col IS NULL / IS NOT NULL | True / False. Note isnull=None means IS NOT NULL. |
contains / icontains | LIKE / ILIKE '%v%' | See LIKE escaping below. |
startswith / istartswith | LIKE / ILIKE 'v%' | |
endswith / iendswith | LIKE / ILIKE '%v' |
LIKE escaping. The value is escaped before the pattern is built, so %, _ and \ in user input match literally (ESCAPE '\'):
qs = P.objects.filter(s__contains="50%_")
qs._select_sql() # internal helper; shows the generated SQL
# ('SELECT * FROM "ref_p" WHERE "s" LIKE $1 ESCAPE \'\\\'', ['%50\\%\\_%'])
Identifier safety. A key, order_by name, update field or values name must be a declared field name, a ForeignKey's <name>_id, or a declared db_column. Anything else raises FieldError and nothing is sent to the database. Query undeclared columns with await db.fetch(sql, *params). Lookups across relations (filter(author__name=...)) are not supported.
Integer range checks. An int outside an integer column's range raises ValueError naming the column and range, before any SQL, also inside __in and update():
P.objects.filter(n=2**40)
# ValueError: 1099511627776 is out of range for P.n (an integer column holds -2147483648..2147483647).
Q objects¶
Q(**lookups) holds the same lookups as filter(). Combine with | (OR), & (AND) and ~ (NOT), nest freely, and pass to filter(), exclude() and get() alongside keyword lookups (everything is ANDed).
from cello.orm import Q
Book.objects.filter(Q(title__istartswith="the") | Q(pages__gt=500), active=True)
Book.objects.filter(~Q(author=None) & (Q(category="sci") | Q(category="art")))
await Book.objects.get(Q(isbn=code) | Q(slug=code))
| Fact | Detail |
|---|---|
| Constructor | Q(*q, **lookups); positional arguments must be Q. |
| NOT | ~q compiles to (q) IS NOT TRUE, so it keeps rows where q is false or unknown (a NULL column). filter(~Q(x=5)) is the same query as exclude(x=5). Add Q(x__isnull=False) to drop NULL rows. |
| Empty | Q() adds no condition and is dropped from \| and &. bool(Q()) is False. |
| Validation | Each lookup goes through the same field resolution, LIKE escaping, None handling and range checks as a keyword lookup. |
Aggregates and GROUP BY¶
Count, Sum, Avg, Min and Max are subclasses of Aggregate.
| Class | Signature | Alias for a positional use |
|---|---|---|
Count | Count(field="*", distinct=False) | count (for "*"), else <field>__count |
Sum | Sum(field, distinct=False) | <field>__sum |
Avg | Avg(field, distinct=False) | <field>__avg |
Min | Min(field, distinct=False) | <field>__min |
Max | Max(field, distinct=False) | <field>__max |
field must be a string naming a declared field. Sum("*") and Count("*", distinct=True) raise FieldError. distinct must be a bool.
aggregate(*aggs, **named)¶
Returns one dict. filter / exclude select the rows; order_by with limit / offset aggregate just that slice.
await Book.objects.aggregate(n=Count(), s=Sum("pages"), a=Avg("pages"), mx=Max("price"))
# {'n': 2, 's': 3, 'a': '3.0000000000000000', 'mx': 1.5}
await Book.objects.aggregate(Sum("pages")) # {'pages__sum': 3}
values(*fields).annotate(*aggs, **named)¶
values() returns a ValuesQuerySet (a QuerySet subclass of dicts). It is awaited as before, can be chained, and can be returned from a handler. With no fields each dict is to_dict(). annotate() groups by the values() fields (GROUP BY) and adds one column per alias; it needs values(<fields>) first.
await Book.objects.values("active").annotate(n=Count("id"))
# [{'active': True, 'n': 2}]
await Book.objects.values("n").annotate(c=Count()).order_by("-c").limit(1)
order_by()accepts annotation aliases as well as fields.count()counts groups.- Aliases must match
^[A-Za-z_][A-Za-z0-9_]*$, are quoted, and may not repeat avalues()key. A bad alias or field raisesFieldErrorbefore any SQL. - Not available:
HAVING, andannotate()withoutvalues().
Aggregate value shapes¶
Value shapes are the Postgres result types, decoded like any column.
| Aggregate | Column type | Python value |
|---|---|---|
Count | any | int (0 over no rows) |
Sum | INTEGER | int (Postgres returns bigint) |
Sum | BIGINT | str (numeric, e.g. "45") |
Avg | any integer | str (numeric, e.g. "3.0000000000000000") |
Sum / Avg | DOUBLE PRECISION | float |
Min / Max | any | the column's own type |
Every aggregate except Count is None over no rows.
Relations¶
Reverse accessors¶
Each ForeignKey declared on a class installs an accessor on its target: author.book_set by default, or the related_name. It returns Source.objects.filter(<fk>=instance).
- An explicit
related_namethat clashes with a field, an attribute or another accessor raisesValueErrorwhen the class is defined. - Two default names from one model to the same target make the accessor raise
AttributeErrorwith a hint to addrelated_name. - A default name that clashes with an attribute is not installed.
- Only ForeignKeys declared on the class itself install accessors, not inherited ones.
select_related(*paths)¶
Loads ForeignKey targets in the same query, with one LEFT JOIN per ForeignKey. __ follows further ForeignKeys.
rows = await Book.objects.select_related("author__publisher").order_by("id")
rows[0].author # <Author pk=1>
rows[0].author_id # 1
A NULL (or dangling) ForeignKey leaves the id in place. Naming a reverse accessor raises FieldError pointing at prefetch_related.
prefetch_related(*names)¶
One extra WHERE key = ANY($1) query per relation. A name is a ForeignKey or a reverse accessor; chain with __.
authors = await Author.objects.prefetch_related("books")
await authors[0].books # no query: already loaded
After a prefetch, awaiting the accessor runs no query and each child's ForeignKey attribute is set to the parent. Chaining on the accessor (a.books.filter(...)) runs a new query.
A ForeignKey whose db_column equals its own name stores the id under the field name and cannot hold an instance: select_related / prefetch_related reject it with FieldError.
to_dict() never nests related objects, so a returned queryset or json() with select_related / prefetch_related runs the base query alone.
Bulk operations¶
bulk_create(objs, batch_size=None, using=None) -> list¶
One multi-row INSERT ... RETURNING * per batch. Each instance is refreshed from its row. Values are written as create() writes them: an AutoField left as None takes the database's value, auto_now is now(), auto_now_add is now() when unset, ForeignKey instances are written as their primary key.
bulk_update(objs, fields, batch_size=None, using=None) -> int¶
One UPDATE ... FROM (VALUES ...) per batch; returns the number of rows updated. Only the named fields are written, plus auto_now fields. Every instance needs a primary key and may appear once, otherwise ValueError is raised before any SQL. Parameters are cast to the column types, without length modifiers, so a too-long string raises instead of being truncated.
books = await Book.objects.bulk_create([Book(title=f"x{i}") for i in range(3)])
for b in books:
b.pages = (b.pages or 0) + 100
await Book.objects.bulk_update(books, ["pages"]) # -> 3
Batches are cut so that no statement exceeds Postgres' 65,535 bind parameters; batch_size can lower that. Several batches on a pool run in one transaction (all-or-nothing); on a transaction they use it. Neither method calls save().
Transactions and the ambient transaction¶
Inside async with db.transaction() and inside an @transactional handler, ORM calls without using= run on that transaction and commit or roll back with it.
try:
async with request.database.transaction():
await Book.objects.create(title="rolled back") # in the transaction
raise RuntimeError
except RuntimeError:
pass
await Book.objects.filter(title="rolled back").count() # 0
- The open transaction is held in a
contextvarsvariable, available ascello.orm.current_transaction()(alsocello.database.current_transaction()). Tasks created inside the block inherit it; other requests never see it. .using(db)always wins, soBook.objects.using(request.database)still reaches the pool from inside a block. Inside the block the pool does not see uncommitted rows:
async with db.transaction():
await P.objects.create(n=778)
await P.objects.using(db).filter(n=778).count() # 0 (pool connection)
await P.objects.filter(n=778).count() # 1 (the transaction)
- If a statement in the block fails and you catch the error, Postgres has aborted the transaction; leaving the block normally rolls back and raises
cello.TransactionAborted(aRuntimeError), chained to the swallowed error. See Database API. - A returned queryset runs after the handler returns, outside the transaction; use
await qs.json()inside it.
Exceptions¶
| Exception | Base | Raised when |
|---|---|---|
DoesNotExist | Exception | get() matches no row. Each model has its own subclass, Model.DoesNotExist, so except Book.DoesNotExist is specific. |
MultipleObjectsReturned | Exception | get() matches more than one row. Also per model. |
FieldError | ValueError | An undeclared or invalid field name, an unsupported lookup, a bad aggregate or alias, or a rejected relation path. |
Other errors: ValueError for out-of-range integers, an unsaved related instance in a query, invalid on_delete, a ForeignKey to a model with no primary key, and delete() on a model with no primary key; TypeError for __in given a str, a non-Q positional argument, and update() / delete() on a sliced queryset. Database failures are DatabaseError subclasses, for example UniqueViolation from create().
try:
await Book.objects.get(title="nope")
except Book.DoesNotExist as e:
print(e) # Book matching query does not exist.
Unhandled, these exceptions become a 500 (see Error Codes).
Value shapes¶
Rows decode to a frozen set of Python shapes, the same for fetch(), ORM instances, to_dict() and the native JSON path.
| Postgres type | Python value | Example |
|---|---|---|
int2, int4, int8 | int | 5 |
float4, float8 | float | 1.5 |
bool | bool | True |
text, varchar, bpchar | str | 'x' |
numeric | str, the exact decimal text, scale kept | '1.50', 'NaN'; never a float |
timestamptz | str, RFC 3339 | '2024-01-02T03:04:05+00:00' |
timestamp | str, no offset | '2024-01-02T03:04:05.000000' |
date | str | '2024-01-02' |
time | str | '06:56:48.092651' |
uuid | str | '00000000-0000-0000-0000-000000000001' |
json, jsonb | nested dict / list / scalar (object keys come back sorted) | {'a': [1, 2]} |
bytea | bytes | b'x' |
| 1-D array of a supported type | list, None for NULL elements | [1, None, 3] |
| multi-dimensional array | nested list (lower bounds dropped) | [[1, 2], [3, 4]] |
interval | str, Postgres style | '1 day' |
timetz, inet, cidr, macaddr, "char" | str | '10.0.0.1' |
money | str, plain decimal with two fractional digits | '12.50' |
| enum | str label | |
infinite timestamp / timestamptz / date | 'infinity' / '-infinity' | |
NULL (any type) | None | |
| other unsupported type | placeholder string "<unsupported column type: ...>" |
Notes:
intervalis always rendered in thepostgresstyle whatever the session'sIntervalStyle.moneyloses its currency symbol because the binary value carries none.- A
DateTimeFieldtherefore holds a string, not adatetime. - Parameters accept more shapes than rows return; see SQL parameters.
Breaking changes from 1.3.x¶
1.4.0 changed these behaviours on purpose. Each was a 1.3.x quirk or bug; the full reasoning is in the v1.4.0 release notes.
| Change | Migration |
|---|---|
Undeclared column names in filter / exclude / order_by / update / values raise FieldError. | Declare the column on the model, or move the query to db.fetch(...). |
Defaults are applied when the instance is built (User(name="x").active is True; callables are called per instance; dict / list defaults are copied). create(x=None) now writes NULL. | Leave an argument out to get its default instead of passing None. |
DateTimeField(auto_now_add=True) now works on create(); auto_now=True is new. | None needed. |
create() always inserts; save() inserts a missing row instead of silently doing nothing. | None needed. create(id=99, ...) now really creates row 99. |
delete() on a model without a primary key raises ValueError (was TypeError). | Use Model.objects.filter(...).delete(). |
ForeignKey assignment is synced (book.author = obj persists on save()); the column type follows the target's key; a target without a primary key raises at class definition. | None needed. |
JSONField(default={"a": 1}) renders valid JSON in the DDL. | None needed. |
| Equality and hashing follow the primary key. | Do not keep unsaved instances in sets across a save(). |
filter(x=None) is IS NULL; exclude(x=None) is IS NOT NULL. | None needed (it matched nothing before). |
exclude() is null-safe: rows with a NULL column are kept, and exclude(a=1, b=2) is NOT (a AND b). | Add .filter(col__isnull=False) to drop NULL rows as before. |
x__in="abc" raises TypeError; any other iterable is accepted. | Pass a list. |
contains / startswith / endswith (and i*) match %, _, \ literally; iendswith is new. | Do not rely on user-supplied wildcards. |
A second order_by() replaces the first. | Pass all fields to one order_by(). |
count() / exists() honour limit() / offset(); update() / delete() on a sliced queryset raise TypeError. | Filter on the primary key (id__in=[...]). |
update() with no fields returns 0. | None needed. |
Integers outside a column's range raise ValueError (ORM) or ParameterRangeError (raw) instead of wrapping. | Validate or widen the column. |
| ORM calls inside a transaction use it. | Use .using(db) to reach the pool on purpose. |
values() returns a queryset (still awaitable). | None needed. |
NUMERIC, arrays, interval, inet, ... decode to real values instead of a placeholder string. | Update code that compared against the placeholder. |
fetchrow / fetchval return the first row instead of raising on several rows. | Add LIMIT 1 for large results. |
Database errors are DatabaseError subclasses (still RuntimeError). | None needed; catch the specific class for finer handling. |
The default Postgres sslmode is prefer. | Set sslmode="disable" to keep plaintext; use "verify-full" in production. |
What is not covered¶
Lookups across relations (filter(author__name=...)), HAVING, per-object annotate() without values(), select_related on reverse relations, and many-to-many fields are not supported. Use raw queries with request.database (fetch_json when the rows go straight into the response) for those.
See Also¶
- Database API for
fetch, transactions, parameter binding and errors - Database & Redis for a guided introduction
- DTO Validation for
body=Model - Migrations for
cello migrate