Migrating from Django ORM¶
dorm's surface area is intentionally close to Django's, so most code ports with renames + import changes. This page collects the differences you'll actually hit.
Imports¶
# Django
from django.db import models, transaction
class User(models.Model):
name = models.CharField(max_length=100)
# dorm
import dorm
class User(dorm.Model):
name = dorm.CharField(max_length=100)
dorm.transaction.atomic matches django.db.transaction.atomic
(both as context manager and decorator).
Settings¶
There's no INSTALLED_APPS = ["django.contrib.auth", ...]-style setup.
Either:
- Drop a
settings.pynext to your app packages and letdormautodiscover, or - Call
dorm.configure(DATABASES={...}, INSTALLED_APPS=["myapp"])programmatically.
dorm doesn't ship auth, admin, staticfiles, or any of Django's
batteries — bring your own.
Field cheat sheet¶
| Django | dorm |
|---|---|
models.CharField(max_length=N) |
dorm.CharField(max_length=N) |
models.TextField() |
dorm.TextField() |
models.IntegerField() |
dorm.IntegerField() |
models.BigIntegerField() |
dorm.BigIntegerField() |
models.DecimalField(...) |
dorm.DecimalField(...) |
models.BooleanField() |
dorm.BooleanField() |
models.DateField() / DateTimeField() |
same |
models.JSONField() |
dorm.JSONField() |
models.UUIDField() |
dorm.UUIDField() |
models.EmailField() |
dorm.EmailField() (validates on assignment) |
models.ForeignKey(To, on_delete=CASCADE) |
dorm.ForeignKey(To, on_delete=dorm.CASCADE) |
models.OneToOneField(...) |
dorm.OneToOneField(...) |
models.ManyToManyField(...) |
dorm.ManyToManyField(...) |
ArrayField (postgres contrib) |
dorm.ArrayField(base_field) |
BinaryField |
dorm.BinaryField() |
models.SlugField |
dorm.SlugField() |
auto_now=True / auto_now_add=True |
same on DateTimeField |
default=, null=, blank= |
same |
validators=[...] |
same |
QuerySet cheat sheet¶
| Django | dorm | Notes |
|---|---|---|
qs.filter(x=1) |
qs.filter(x=1) |
identical |
qs.exclude(x=1) |
qs.exclude(x=1) |
identical |
qs.get(pk=1) |
qs.get(pk=1) |
raises Model.DoesNotExist |
qs.aget(pk=1) (Django 4.2+) |
qs.aget(pk=1) |
identical |
qs.values("a", "b") |
qs.values("a", "b") |
returns chainable QS of dicts |
qs.count() |
qs.count() |
identical |
qs.aggregate(Sum(...)) |
qs.aggregate(Sum(...)) |
identical |
qs.bulk_create(objs) |
qs.bulk_create(objs) |
identical |
qs.bulk_update(objs, fields) |
qs.bulk_update(objs, fields) |
dorm uses one CASE WHEN per batch |
qs.iterator(chunk_size=N) |
qs.iterator(chunk_size=N) |
server-side cursor on PG |
qs.explain() |
qs.explain(analyze=True) |
dorm extra: print plan |
qs.using("replica") |
qs.using("replica") |
identical |
qs.select_for_update() |
qs.select_for_update() |
identical |
Q(a=1) | Q(b=2) |
Q(a=1) | Q(b=2) |
identical |
F("col") |
F("col") |
identical |
Methods you have in dorm and not in Django (yet):
qs.aexplain(analyze=True)— async EXPLAIN.await qs— every QuerySet is awaitable; equivalent to materializing via[x async for x in qs].
Migrations¶
makemigrations, migrate, showmigrations, squashmigrations
behave like their Django siblings. New in dorm:
dorm migrate --dry-run— print SQL without executing.dorm dbcheck— diff each model against the live schema.dorm sql users.User— print theCREATE TABLEfor a model.
dorm migrate --fake and dorm migrate --fake-initial (3.0+)
record migrations as applied without running their operations —
useful when adopting dorm against a hand-managed legacy database.
If you really need to mark a single legacy migration
applied without running it, --fake does exactly that.
You don't need asgiref (3.0+)¶
Django ships asgiref.sync because the Django ORM was sync-only for
years — every model call inside an async view had to be wrapped in
sync_to_async(...) to avoid blocking the event loop. dorm has a
native async path from day one, so the bridge is unnecessary.
| Django (sync ORM) | dorm (async-native) |
|---|---|
await sync_to_async(User.objects.get)(pk=1) |
await User.objects.aget(pk=1) |
await sync_to_async(list)(qs) |
[u async for u in qs.aiterator()] or await qs |
await sync_to_async(User.objects.create)(...) |
await User.objects.acreate(...) |
await sync_to_async(user.save)() |
await user.asave() |
await sync_to_async(qs.update)(...) |
await qs.aupdate(...) |
with transaction.atomic(): ... |
async with aatomic(): ... |
Every queryset / manager method in dorm has an a* counterpart that
runs through the async backend wrapper — no thread pool, no
sync-async bridge, no per-call Token allocation. Don't import
asgiref for ORM code. If you find yourself reaching for
sync_to_async around a Model.objects call, switch to the
matching a* method instead.
To catch this at dev / test time, opt into the async-guard:
# conftest.py or app startup (development only)
from dorm.contrib.asyncguard import enable_async_guard
enable_async_guard(mode="warn") # WARNING per offending call site
# enable_async_guard(mode="raise") # raise on every offender
The guard hooks pre_query and walks the call stack — sync ORM
calls inside a running event loop trigger the configured action,
async calls stay silent.
What's missing on purpose¶
- No admin site. dorm is an ORM, not a CMS framework.
- No request/response middleware. dorm has no HTTP layer.
- Timezone-aware datetimes ship in 3.0+: set
settings.USE_TZ = Trueto enable Django ≥4-compatible behaviour (naive→aware conversion, UTC normalisation on insert,TIMESTAMP WITH TIME ZONEon PG). DefaultFalsekeeps pre-3.0+ behaviour. - Optional
dorm.contrib.auth(3.0+). User / Group / Permission models with stdlib PBKDF2 hashing. Stateless reset tokens land in 3.0+ (dorm.contrib.auth.tokens.PasswordResetTokenGenerator) for the password-reset / email-verification flow. Meta.permissions = [...](3.0+) — declare custom permissions on a model and surface them in theauth_permissiontable viadorm.contrib.auth.management.sync_permissions().Meta.proxy = True(3.0+) — proxy models share the parent's table; the autodetector skips them somakemigrationsdoesn't emit a phantomCreateModel.Model.from_db(db, field_names, values)(3.0+) — Django-parity hook for custom hydration. Stamps the resulting instance's_state.dbwith the alias the row came from.QuerySet.dates(field, kind)/datetimes(field, kind)(3.0+) — returnlist[date]/list[datetime]of distinct truncated values, suitable for archive listings.dorm.transaction.savepoint()/savepoint_commit()/savepoint_rollback()(3.0+) — manual savepoints inside anatomic()block. Mirror Django'sdjango.db.transaction.savepointfamily.- JSONField PG operators (3.0+):
__contained_by,__has_key,__has_keys,__has_any_keys,__overlap,__len. Same spelling as Django'scontrib.postgres. GenericForeignKeylives indorm.contrib.contenttypes, same shape as Django's.- Optional encryption (3.0+) via
dorm.contrib.encrypted(EncryptedCharField/EncryptedTextField). AES-GCM, deterministic mode for equality lookups, key rotation. Requirespip install 'djanorm[encrypted]'. - Optional Prometheus exporter (3.0+) via
dorm.contrib.prometheus— counters + histograms in plain text-exposition format, no third-party scraper SDK. - Multi-tenant
dorm.contrib.tenants(3.0+) — PostgreSQLsearch_pathswitching viaTenantContext/aTenantContextcontext managers; per-tenant migration runner lands with v3.1. - MySQL / MariaDB scaffold (3.0+).
ENGINE = "mysql"parses throughparse_database_urland the connection wrapper raisesImproperlyConfiguredpointing at the v3.1 implementation milestone. Lets users pin on a forward-compatible config string today.
What's better than Django¶
- Async pool with retry on transient errors and slow-query detection — works with FastAPI / Starlette out of the box.
Field[T]generics — your IDE knowsuser.nameisstrand flagsuser.naem. Thedjanorm-mypyplugin extends this tofilter()kwargs and lookup suffixes at compile time.DormSchemafor FastAPI — single-source-of-truth schemas withclass Meta: model = User, including nested relations.- Tiny dependency footprint:
psycopg+aiosqlite, optionallypydantic. No Django. - Production hardening built in — circuit breaker, query budget, lag-aware routing, outbox, sharding, idempotency keys.
Quick equivalence table (4.0)¶
Everyday imports¶
| Django | dorm |
|---|---|
from django.db import models |
import dorm |
models.Model |
dorm.Model |
models.CharField / IntegerField / etc. |
dorm.CharField / etc. |
models.Q, models.F, models.Subquery, models.Exists |
dorm.Q, dorm.F, dorm.Subquery, dorm.Exists |
models.Count, Sum, Avg, ... |
dorm.Count, dorm.Sum, dorm.Avg, ... |
models.OuterRef |
dorm.OuterRef |
models.FilteredRelation |
dorm.FilteredRelation |
models.UniqueConstraint, CheckConstraint |
dorm.UniqueConstraint, dorm.CheckConstraint |
models.Index |
dorm.Index |
from django.db import transaction |
from dorm import transaction |
transaction.atomic() |
dorm.transaction.atomic() |
from django.db import connection |
from dorm.db.connection import get_connection |
contrib.postgres¶
| Django | dorm |
|---|---|
contrib.postgres.fields.ArrayField |
dorm.ArrayField |
contrib.postgres.fields.JSONField |
dorm.JSONField (also on SQLite) |
contrib.postgres.fields.HStoreField |
dorm.HStoreField (4.0+; TEXT fallback on SQLite) |
contrib.postgres.fields.RangeField |
dorm.RangeField and subclasses |
contrib.postgres.search.SearchVector |
dorm.search.SearchVector |
contrib.postgres.search.SearchQuery |
dorm.search.SearchQuery |
contrib.postgres.search.SearchRank |
dorm.search.SearchRank |
contrib.postgres.search.SearchHeadline |
dorm.search.SearchHeadline |
contrib.postgres.search.TrigramSimilarity |
dorm.search.TrigramSimilarity (4.0+) |
contrib.postgres.aggregates.StringAgg |
dorm.StringAgg |
contrib.postgres.aggregates.ArrayAgg |
dorm.ArrayAgg |
contrib.postgres.aggregates.BoolAnd/BoolOr |
dorm.BoolAnd / dorm.BoolOr |
Choices / Enums¶
# Django
class Status(models.TextChoices):
ACTIVE = "active", "Active"
ARCHIVED = "archived", "Archived"
class Article(models.Model):
status = models.CharField(max_length=10, choices=Status.choices)
# dorm — use enum.Enum + EnumField
import enum
class Status(enum.Enum):
ACTIVE = "active"
ARCHIVED = "archived"
class Article(dorm.Model):
status = dorm.EnumField(Status, default=Status.ACTIVE)
# Or PG native ENUM (4.0+):
# status = dorm.EnumField(Status, native=True, type_name="article_status")
Forms¶
Django ships ModelForm. dorm does not ship forms by design —
the target is 99% FastAPI / Litestar / aiohttp where validation
flows through Pydantic. For input/output schemas use
dorm.contrib.pydantic:
from dorm.contrib.pydantic import (
schema_for, create_schema_for, update_schema_for,
)
AuthorOut = schema_for(Author)
AuthorCreate = create_schema_for(Author) # POST body
AuthorUpdate = update_schema_for(Author) # PATCH body, all optional
Admin¶
Django ships contrib.admin. dorm does not ship admin built-in.
FastAPI deployments typically use sqladmin or a custom dashboard.
To generate JSON Schema from your models (input for external admin
tools) use dorm export-json-schema --out schemas/.
select_for_update / signals / migrations¶
Same API as Django. Migration ops new in 4.0 that Django doesn't
have: AddFieldOnline, BackfillBatch, SetNotNullOnline,
CreateMaterializedView, CreatePartitionedTable, CreatePGEnum.
Multi-tenancy¶
| Django | dorm |
|---|---|
django-tenants (3rd party, schema) |
dorm.contrib.tenants (built-in, schema) |
| Manager middleware with manual filter | dorm.contrib.tenants_row.TenantModel (4.0+) |
GIS¶
| Django | dorm |
|---|---|
contrib.gis.db.models.PointField |
dorm.contrib.gis.PointField (4.0+) |
contrib.gis.db.models.PolygonField |
dorm.contrib.gis.PolygonField (4.0+) |
__intersects, __within, __contains, __distance_lte |
same names, in dorm.contrib.gis |
Dev tooling¶
| Django | dorm |
|---|---|
django-stubs (mypy plugin) |
djanorm-mypy (sibling package) |
pytest-django |
pytest-djanorm (sibling package) |