Skip to content

What's new in 4.0

Major release. Direct jump from 3.3 — everything that was scheduled for 3.4 (framework-agnostic helpers, PG helpers, sibling packages djanorm-mypy and pytest-djanorm) ships in this release together with the seven headline features that justify the major bump.

On top of the existing SearchVector / SearchQuery / SearchRank / SearchHeadline:

from dorm.search import (
    TrigramSimilarity, TrigramWordSimilarity, search_index,
)

# Trigram-based fuzzy ranking (requires pg_trgm extension)
qs = (
    Author.objects
    .annotate(score=TrigramSimilarity("name", "alise"))
    .order_by("-score")
)

# Functional GIN index for __search lookups
from dorm.migrations.operations import RunSQL
operations = [
    RunSQL(
        search_index("articles", "title", "body"),
        reverse_sql='DROP INDEX IF EXISTS ix_articles_search'
    ),
]

2. Online (zero-downtime) migrations

from dorm.migrations.operations import (
    AddFieldOnline, BackfillBatch, SetNotNullOnline,
)

operations = [
    AddFieldOnline(
        "Order",
        "currency",
        dorm.CharField(max_length=3, null=False, default="USD"),
    ),
    BackfillBatch(
        table="orders",
        update_sql=(
            'UPDATE "orders" SET "currency" = \'USD\' '
            'WHERE "id" BETWEEN %s AND %s AND "currency" IS NULL'
        ),
        batch_size=10_000,
        sleep_seconds=0.05,
    ),
    SetNotNullOnline("Order", "currency"),
]

PG ≥ 12 path: never rewrites the table.

3. Enriched OpenTelemetry instrumentation

from dorm.contrib.otel import instrument
instrument(tracer_name="myapp.dorm")

Every span carries SemConv 1.20+ attributes: db.operation, db.sql.table / db.collection.name, db.dorm.alias. Span name uses "<OPERATION> <table>" so tracing UIs sort by hot tables out of the box.

4. Recursive CTEs (tree walks)

from dorm.tree import descendants, ancestors, descendants_cte

rows = descendants(Category, parent_field="parent_id", root_pk=42)

# Or compose into a manual queryset:
cte = descendants_cte(
    Category,
    parent_field="parent_id",
    root_pk=42,
    cycle_field="path",  # cycle protection (PG-only)
)
qs = Category.objects.with_cte(descendants=cte).raw(
    'SELECT id, name FROM descendants ORDER BY id'
)

5. Row-level multi-tenancy

import dorm
from dorm.contrib.tenants_row import TenantModel, current_tenant

class Note(TenantModel):
    title = dorm.CharField(max_length=200)

with current_tenant(request.user.tenant_id):
    Note.objects.create(title="hi")          # tenant_id auto-filled
    notes = list(Note.objects.all())         # auto-filtered

all_notes = list(Note.unscoped.all())        # escape hatch

Works on every backend (PG, MySQL, SQLite, libsql, DuckDB). NoActiveTenantError when no tenant is active — no silent fallback to "every tenant".

6. DuckDB backend

The first OLAP/analytics backend in the family.

import dorm

dorm.configure(
    DATABASES={"default": {"ENGINE": "duckdb", "NAME": "analytics.duckdb"}},
    INSTALLED_APPS=["dashboards"],
)

Capabilities: - Atomic transactions (no SAVEPOINT — nested atomic() degrades to a no-op boundary; outer rollback discards everything). - Streaming iterator via execute_streaming. - information_schema introspection (dorm diff works). - Async wrapper runs sync calls in a thread executor.

Install: pip install 'djanorm[duckdb]'.

7. HStoreField + native PG ENUM

import enum, dorm

class Status(enum.Enum):
    ACTIVE = "active"
    ARCHIVED = "archived"

class Article(dorm.Model):
    metadata = dorm.HStoreField(null=True, blank=True)
    status = dorm.EnumField(Status, native=True, type_name="article_status")

Migrations:

from dorm.migrations.operations import (
    CreatePGEnum, DropPGEnum, AddPGEnumValue, CreateModel, RunSQL,
)

operations = [
    RunSQL("CREATE EXTENSION IF NOT EXISTS hstore",
           reverse_sql="DROP EXTENSION IF EXISTS hstore"),
    CreatePGEnum("article_status", ["active", "archived"]),
    CreateModel("Article", fields=[...]),
]

# Later, to append a value:
operations = [AddPGEnumValue("article_status", "deleted")]

HStoreField falls back to JSON-encoded TEXT on SQLite. EnumField(native=True) falls back to VARCHAR on non-PG backends.

Sibling packages

See sibling packages:

  • djanorm-mypy — mypy plugin validating filter() kwargs, lookup suffixes, synthesising pk / id.
  • pytest-djanorm — fixtures transactional_db, atransactional_db, pg_container, nplusone_guard.

Extracted to standalone packages so the main wheel never pulls dev tooling at runtime.

Migrating from 3.3.0

Zero mandatory changes. Every addition is opt-in:

  • pip install --upgrade djanorm — runtime stays identical.
  • For DuckDB: pip install 'djanorm[duckdb]'.
  • For mypy/pytest tooling: pip install djanorm-mypy pytest-djanorm.
  • For v4 helpers: from dorm.tree import ..., from dorm.contrib.tenants_row import ..., etc.