Skip to content

What's new in 4.1

Minor release. No breaking changes vs 4.0 — every addition is opt-in or zero-cost when unused.

1. CockroachDB backend

CockroachDB speaks the PostgreSQL wire protocol, so dorm reuses the psycopg-based pipeline and ships a thin subclass with port 26257 + a serialization-retry helper.

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "cockroachdb",
        "NAME": "defaultdb",
        "HOST": "localhost",
        "PORT": 26257,
        "USER": "root",
        "PASSWORD": "",
        "OPTIONS": {"sslmode": "disable"},
    }
}

Install with pip install 'djanorm[cockroachdb]' (alias for [postgresql]). vendor stays "postgresql" so every PG-only ORM feature (CreatePGEnum, copy_from, partitioning, materialised views, HStoreField, …) keeps working; the new dialect = "cockroachdb" attribute is the opt-in escape hatch for Cockroach-specific code.

Serialization-failure retry (SQLSTATE 40001 / 40003):

import dorm
from dorm.contrib.cockroach import retry_on_serialization, with_retry


def transfer() -> None:
    with dorm.transaction.atomic():
        src = Account.objects.select_for_update().get(pk=1)
        src.balance -= 100
        src.save()

# Functional form:
retry_on_serialization(transfer, max_attempts=5)


# Decorator form (auto-detects async):
@with_retry(max_attempts=5)
def transfer_money(src_id: int, dst_id: int, amount: int) -> None:
    with dorm.transaction.atomic():
        ...

2. AsyncSchemaEditor

Async façade over SchemaEditor — every DDL helper offloads to asyncio.to_thread so a FastAPI / Litestar startup hook can bootstrap a schema without blocking the event loop.

from dorm.migrations.schema import AsyncSchemaEditor
from dorm.db.connection import get_connection


async def init_schema() -> None:
    async with AsyncSchemaEditor(get_connection()) as se:
        await se.acreate_model(Article)
        await se.aadd_field(Article, "summary", dorm.TextField(null=True))

3. PostgreSQL Row-Level Security migration ops

Full RLS policy lifecycle as first-class migration operations.

from dorm.migrations.operations import (
    AlterPolicy,
    CreatePolicy,
    DropPolicy,
    EnableRowLevelSecurity,
    ForceRowLevelSecurity,
)

operations = [
    EnableRowLevelSecurity("articles"),
    ForceRowLevelSecurity("articles"),
    CreatePolicy(
        "p_tenant_isolation",
        "articles",
        command="ALL",
        using="tenant_id = current_setting('app.tenant_id')::int",
        check="tenant_id = current_setting('app.tenant_id')::int",
    ),
]

CreatePolicy rejects SELECT/UPDATE/DELETE/ALL policies without an explicit using= (and INSERT without check=) so a silent default predicate can't slip into a security-sensitive migration. All ops are PostgreSQL-only and no-op on every other backend.

4. PII field tagging

class User(dorm.Model):
    email = dorm.EmailField(pii=True)
    full_name = dorm.CharField(max_length=120, pii=True)
    username = dorm.CharField(max_length=40, unique=True)

The flag has no effect on the SQL schema. Consumers:

from dorm.contrib.pii import (
    anonymize_row,
    has_pii_fields,
    mask_dict,
    mask_instance,
    pii_fields,
)

pii_fields(User)
# [<EmailField: email>, <CharField: full_name>]

mask_instance(user)
# user.email == "[REDACTED]", user.full_name == "[REDACTED]"

anonymize_row(user)   # mask + .save() for GDPR right-to-be-forgotten

Enable audit-trail masking for @track_history models:

dorm.configure(
    HISTORY_MASK_PII=True,
    ...,
)

5. PgBouncer transaction-pool compatibility

PgBouncer in transaction-pool mode reuses backend connections across client transactions, so server-side prepared statements survive past the client that allocated them. The new PGBOUNCER_MODE setting disables them on every connection.

DATABASES = {
    "default": {
        "ENGINE": "postgresql",
        "NAME": "app",
        # True, "transaction", or "statement" — disables psycopg's
        # server-side prepared-statement cache.
        "PGBOUNCER_MODE": "transaction",
    }
}

The setting overrides any explicit PREPARE_THRESHOLD and logs a warning when it does.

6. ASGI middleware

Three pure-ASGI-3 middlewares, framework-agnostic:

from fastapi import FastAPI

from dorm.contrib.asgi import (
    NPlusOneMiddleware,
    OTelDormMiddleware,
    QueryBudgetMiddleware,
)

app = FastAPI()
app.add_middleware(NPlusOneMiddleware, threshold=10)
app.add_middleware(QueryBudgetMiddleware, timeout_ms=2000, max_rows=10_000)
app.add_middleware(OTelDormMiddleware)

Order matters — the outermost (last add_middleware) sees the request first. OTel goes outermost so every later span (budget, N+1 detector, dorm queries) is parented under the request span.

Each middleware bypasses lifespan / websocket scopes — those aren't request-scoped, so wrapping them in a per-request budget would be wrong.

7. Litestar plugin

from litestar import Litestar
from dorm.contrib.litestar import dorm_plugin

app = Litestar(
    plugins=[
        dorm_plugin(
            budget_timeout_ms=2000,
            nplusone_threshold=10,
            warmup_pool=5,  # opens 5 connections at startup
        )
    ],
)

Wires the three ASGI middlewares via Litestar's DefineMiddleware and registers async startup / shutdown hooks that pre-warm the connection pool and drain it cleanly on shutdown. Install with pip install 'djanorm[litestar]'.

8. connection_for(alias) / aconnection_for(alias)

Public handle for the per-alias backend wrapper, mirroring Django's connections['default']:

import dorm

conn = dorm.connection_for("replica")
rows = conn.execute("SELECT 1")

aconn = await dorm.aconnection_for("replica")
rows = await aconn.execute("SELECT 1")

Replaces the previously private dorm.transaction._connection_for.

9. QuerySet.bulk_update_when / abulk_update_when

Compile a list of (Q, {field: value}) cases into a single UPDATE … SET col = CASE WHEN … THEN … END WHERE … statement.

from dorm.expressions import Q

Article.objects.filter(published=True).bulk_update_when(
    [
        (Q(score__gte=90), {"label": "A", "featured": True}),
        (Q(score__gte=70), {"label": "B"}),
    ],
    default={"label": "C", "featured": False},
)

Without default, a field's column is preserved (ELSE col) for rows that no condition matches. QuerySet.update also accepts Case expressions directly for callers who prefer the manual form.

10. QueryLog dumps

Export captured queries to JSON, JSONL, or Parquet for replay / analysis pipelines:

from dorm.contrib.querylog import QueryLog

with QueryLog() as log:
    do_work()

log.dump_json("queries.json")
log.dump_jsonl("queries.jsonl")           # memory-bounded streaming
log.dump_parquet("queries.parquet")       # requires djanorm[parquet]

Parameters are excluded by default — pass include_params=True on each helper to opt in, and pair with dorm.contrib.pii.mask_dict(Model, row) for a per-column redaction pass before persisting.


See the project CHANGELOG for the full list of changes. Upgrading from 4.0 → 4.1 needs no code changes.