Skip to content

What's new in 4.2

Minor release. No breaking changes vs 4.1 — every addition is opt-in.

Contents

Tier 1 — DX + observability

  1. PostgreSQL advisory locks
  2. Slow-query EXPLAIN auto-collect
  3. Real SQL from dorm sqlmigrate

Tier 2 — operational

  1. Per-template query stats
  2. OTel log correlation
  3. Bounded-lock ALTER COLUMN TYPE
  4. Migration rollback dry-run

Tier 3 — security

  1. EncryptedField key rotation
  2. PII auto-mask in streaming
  3. Append-only audit tables
  4. Pool saturation gauge + warning
  5. Read-replica circuit breaker

Tier 4 — DX

  1. Migration dependency graph
  2. dorm reset (dev only)
  3. Model.objects.cached(timeout=...) sugar
  4. DEBUG_NPLUSONE auto-detection

Tier 5 — advanced

  1. Async DataLoader
  2. Query plan drift detection
  3. LISTEN/NOTIFY broadcaster

Tier 6 — sugar

  1. F.apply() chainable transforms
  2. QuerySet.lookup(column=None)
  3. Manager.union_with() polymorphic
  4. SQL allow-list

Polish (post-4.2 audit round)

  1. dorm version
  2. dorm doctor v4.2 audits
  3. querystats in metrics_response()
  4. DataLoader prime() / clear_all()
  5. Async plan-drift APIs
  6. sql_allowlist capture-mode helpers

1. PostgreSQL advisory locks

from dorm.contrib.advisory import (
    advisory_lock,
    try_advisory_lock,
    advisory_xact_lock,
)

# Block until acquired; releases on context exit.
with advisory_lock("nightly-report"):
    run_nightly_report()

# Skip when contended.
with try_advisory_lock("nightly-report") as got:
    if got:
        run_nightly_report()

# Transaction-scoped — released automatically on commit/rollback.
with dorm.transaction.atomic():
    with advisory_xact_lock(("orders", 42)):
        ...

Keys can be int, str (deterministic blake2b-8 hash) or a (int, int) tuple. Async variants: aadvisory_lock / atry_advisory_lock.

2. Slow-query EXPLAIN auto-collect

dorm.configure(
    SLOW_QUERY_MS=200,
    SLOW_QUERY_EXPLAIN=True,
    ...,
)

Every query slower than 200 ms is re-explained automatically. The plan is logged on dorm.db.slow_explain (WARNING) and attached as an OTel span event when a span is active. SELECT / WITH only — mutations are skipped.

3. Real SQL from dorm sqlmigrate

dorm sqlmigrate <app> <migration> now captures the exact DDL via the dry-run connection (previously it only dumped each op's describe()).

4. Per-template query stats (Prometheus + JSON)

from dorm.contrib import querystats

querystats.collector().enable()

# /metrics/templates endpoint:
return Response(querystats.render_text(), media_type="text/plain")
# or JSON:
return querystats.render_json()

Exposes dorm_template_count / dorm_template_total_ms / dorm_template_{p50,p95,p99}_ms per SQL template. Memory-bounded reservoir (default 1000 samples).

5. OTel log correlation

import logging
from dorm.contrib.otel import install_log_correlation

install_log_correlation()  # idempotent
logging.basicConfig(
    format="%(asctime)s [trace=%(otel_trace_id)s span=%(otel_span_id)s] %(message)s"
)

Joins application logs to the trace timeline. No-op when OTel SDK isn't installed (placeholders "-" keep the formatter alive).

6. Bounded-lock ALTER COLUMN TYPE

from dorm.migrations.operations import AlterColumnTypeOnline

operations = [
    AlterColumnTypeOnline(
        "User", "age", "BIGINT",
        lock_timeout="5s",
        old_type="INTEGER",      # makes the op reversible
    ),
]

PG-only — wraps the ALTER in a short transaction with SET LOCAL lock_timeout so the operation aborts under contention instead of blocking writers.

7. Migration rollback dry-run

dorm migrate --dry-run 0042_drop_legacy

Prints the exact SQL the rollback would execute (uses the existing dry-run connection). Combine with --settings=staging to audit production rollbacks before running them.

8. EncryptedField key rotation

from dorm.contrib.encrypted import rotate_encryption_keys

# After prepending a new key to FIELD_ENCRYPTION_KEYS:
rotate_encryption_keys(User, batch_size=1000)
# Async:
await arotate_encryption_keys(User)

Walks the model in batches inside atomic() blocks, re-encrypting every column with the new head key. Optional progress callable fires after each chunk.

9. PII auto-mask in streaming

from dorm.contrib.streaming import stream_jsonl, astream_jsonl

for chunk in stream_jsonl(qs, mask_pii=True):
    yield chunk

# Async:
async for chunk in astream_jsonl(aqs, mask_pii=True):
    yield chunk

Fields flagged pii=True are replaced with the masked sentinel before serialisation. Silent no-op for plain iterables that don't carry a model class.

10. Append-only audit tables

from dorm.migrations.operations import MakeTableAppendOnly

operations = [
    MakeTableAppendOnly("article_history"),               # blocks UPDATE+DELETE
    MakeTableAppendOnly("audit_log", allow_delete=True),  # blocks only UPDATE
]

Installs a database-level trigger that rejects UPDATE / DELETE on the table. Works on PostgreSQL (PL/pgSQL exception) and SQLite (RAISE(ABORT, ...)); MySQL / DuckDB log a warning and skip.

11. Pool saturation gauge + warning

dorm.configure(POOL_SATURATION_WARN=0.85)

Prometheus output now carries dorm_pool_saturation{alias} (in_use / max_size). A WARNING fires on dorm.contrib.prometheus.pool when the threshold is crossed.

12. Read-replica circuit breaker

LagAwareReadRouter(
    primary="primary",
    replicas=["r1", "r2"],
    max_lag_seconds=2.0,
    failure_threshold=3,    # ← new
    cooldown_seconds=30.0,  # ← new
)

After 3 consecutive probe failures the router opens a breaker per replica and skips even the lag probe for 30 s, avoiding the cost of re-discovering a dead replica every cache window.

13. Migration dependency graph

dorm migrations-graph --format=mermaid > graph.mmd
dorm migrations-graph --format=dot | dot -Tpng -o graph.png

AST-walks every app's migrations/ directory without importing the migration modules, so missing runtime deps don't break the visualisation.

14. dorm reset (dev only)

dorm reset            # rolls back to zero + re-applies
dorm reset --force    # bypass the prod safety check

Convenience for development. Refuses to run when DATABASES looks production-shaped (non-DEBUG, no "test"/"dev" in NAME, remote HOST) unless --force.

15. Model.objects.cached(timeout=...) sugar

hot = Article.objects.cached(timeout=60).filter(published=True)

Equivalent to Article.objects.all().cache(timeout=60).filter(...).

16. DEBUG_NPLUSONE auto-detection

dorm.configure(
    DEBUG=True,
    DEBUG_NPLUSONE="raise",       # or True / "log"
    DEBUG_NPLUSONE_THRESHOLD=10,
    ...,
)

from dorm.contrib.nplusone import install_debug_global

install_debug_global()  # idempotent — typically wired at startup

Installs a process-wide N+1 detector. "raise" aborts the request at the first violation; True / "log" records findings without disrupting traffic.

17. Async DataLoader

from dorm.contrib.dataloader import DataLoader

loader = DataLoader(
    lambda pks: Author.objects.filter(pk__in=pks)
)

# N concurrent loads coalesce into ONE batched fetch:
results = await asyncio.gather(*(loader.load(pk) for pk in pks))

Same pattern as Facebook's DataLoader — GraphQL resolvers, fan-out RPC handlers. Accepts dict / iterable / async-iterable batch functions, configurable max_batch_size, optional caching.

18. Query plan drift detection

from dorm.contrib.plan_drift import record_baseline, compare, diff_text

record_baseline("orders.by_customer", sql, params=[1])

# In a healthcheck or cron:
result = compare("orders.by_customer", sql, params=[1])
if result.drifted:
    alert.fire("plan drift:\n" + diff_text(result))

Volatile fragments (costs, row estimates, buffers, timings) are stripped before comparison so the alarm only fires on structural changes (node types, scan strategies).

19. LISTEN/NOTIFY broadcaster

from dorm.contrib.listen_notify import Broadcaster

async with Broadcaster(["orders", "shipments"]) as bcast:
    async with bcast.subscribe("orders") as queue_a:
        async for notification in queue_a:
            ...
    # Many subscribers fan out from one LISTEN connection.

One pinned LISTEN connection serves N async subscribers, each with its own bounded queue.

20. F.apply() chainable transforms

from dorm.expressions import F
from dorm.functions import Lower, Trim, Substr

# Reads top-down:
F("name").apply(Lower).apply(Trim).apply(Substr, 1, 10)
# ↓ same as:
Substr(Trim(Lower(F("name"))), 1, 10)

.apply() lands on both F and every Func subclass.

21. QuerySet.lookup(column=None)

top10 = Article.objects.order_by("-score")[:10]
Comment.objects.filter(article_id__in=top10.lookup("pk"))

Sugar for Subquery(qs.values(column)).

22. Manager.union_with() polymorphic

Source.objects.union_with(Other.objects, all=True, order_by=["created_at"])
# With column mapping per branch:
Source.objects.union_with(
    (Other.objects, {"label": F("title")}),
    all=True,
)

UNION across heterogeneous models — column mapping per branch aligns SELECT lists.

23. SQL allow-list

from dorm.contrib.sql_allowlist import install, uninstall, rejected_templates

# Capture phase (canary):
install(allow_list, raise_on_violation=False, allow_ddl=True)
# ... run traffic ...
print(rejected_templates())  # curate

# Enforcement phase (production):
install(curated_list, raise_on_violation=True, allow_ddl=False)

Rejects every query whose template isn't on the curated list. Useful as a defence-in-depth layer when paired with input validation.

24. dorm version

$ dorm version
djanorm 4.2.0

Trivial CLI — paste the line into support tickets and CI logs.

25. dorm doctor v4.2 audits

dorm doctor now flags v4.2-specific misconfigurations:

  • DEBUG_NPLUSONE active outside DEBUG mode.
  • SLOW_QUERY_EXPLAIN=True paired with a too-low SLOW_QUERY_MS.
  • POOL_SATURATION_WARN outside the (0, 1) range.
  • LagAwareReadRouter configured with cooldown_seconds=0 (breaker disabled).
  • sql_allowlist not installed, or installed with allow_ddl=True in production.

Run before every deploy and gate the rollout on the exit code.

26. querystats in metrics_response()

The per-template snapshot from dorm.contrib.querystats is now embedded in dorm.contrib.prometheus.metrics_response() when the collector is enabled. No separate endpoint required:

from dorm.contrib import prometheus, querystats

querystats.collector().enable()  # at app startup

return Response(prometheus.metrics_response(), media_type="text/plain")

27. DataLoader prime() / clear_all()

loader.prime(7, fetched_value)   # pre-populate without batching
loader.clear_all()               # drop every cached entry

prime after a fan-out write avoids a redundant SELECT on the follow-up resolver. clear_all is shorthand for clear(None).

28. Async plan-drift APIs

await plan_drift.arecord_baseline("orders.by_customer", sql, params=[1])
result = await plan_drift.acompare("orders.by_customer", sql, params=[1])

Symmetric to the sync forms — same baselines, same CompareResult shape.

29. sql_allowlist capture-mode helpers

from dorm.contrib import sql_allowlist

# Canary phase:
sql_allowlist.install([], raise_on_violation=False, allow_ddl=True)
# ... run real traffic ...
sql_allowlist.dump_captured("allowlist.json")

# Enforcement phase (after curating allowlist.json):
sql_allowlist.uninstall()
sql_allowlist.load_from_file(
    "allowlist.json",
    raise_on_violation=True,
    allow_ddl=False,
)

dump_captured writes a JSON document with allowed + rejected sections; load_from_file reinstalls from the curated payload. allowed_templates() returns the current allow-list snapshot.


See CHANGELOG for the full list of changes. Upgrading 4.1 → 4.2 needs no code changes.