v2.0.0
Changelog
All notable changes to djanorm are documented here. The format follows
Keep a Changelog and the project
follows Semantic Versioning.
[Unreleased]
Security
- SQLite
journal_modewhitelist —DATABASES["default"]["OPTIONS"] ["journal_mode"]is now validated against the documented set
(DELETE,TRUNCATE,PERSIST,MEMORY,WAL,OFF)
before being spliced intoPRAGMA journal_mode = .... Previously
any string was interpolated verbatim — a misconfigured value such as
"WAL; DROP TABLE dorm_migrations; --"would have executed as DDL.
Defence-in-depth: the value comes from a trustedsettings.py, but
configs populated from environment variables / vault secrets now fail
fast withImproperlyConfiguredinstead of running arbitrary SQL.
Fixed
- Async
execute_script()deadlock insideaatomic()— the
async SQLite wrapper held its outer_lockfor the entire
aatomicblock, butexecute_scripttried to re-acquire the
same (non-reentrant) lock, hanging the coroutine forever. It now
goes through_operation_connand reuses the already-held atomic
connection. (execute_scriptis called by user code that runs
RunSQLmigrations or raw DDL.) - Sync
execute_script()redundant commit —sqlite3's
executescript()already commits implicitly, so the explicit
conn.commit()afterwards was a no-op round-trip. Removed; both
sync and asyncexecute_scriptnow document SQLite's behaviour
of committing the surrounding transaction.
Added
dormCLI:initsubcommand to scaffoldsettings.py(and optionally an
app folder via--app NAME);helpsubcommand showing all available
commands.python -m dorm <command>is now a valid entry point alongside thedorm
console script.- QuerySet is awaitable:
rows = await Author.objects.values("name").filter(...)
materializes the queryset without needing a terminalavalues()/alist(). - Async parity:
abulk_update()on QuerySet and Manager (mirrors
bulk_update);_aprefetch_m2mand_aprefetch_reverse_fkso the
async prefetch path now covers M2M and reverse-FK relations (it
previously only handled forward FK). atomicandaatomicwork as decorators in addition to context managers
(e.g.@dorm.transaction.atomicor@dorm.transaction.aatomic("alias")).- PostgreSQL settings:
POOL_TIMEOUT(seconds to wait for a free pool
connection, default 30.0) andPOOL_CHECK(defaultTrue; setFalse
to skip the per-checkoutSELECT 1health probe on hot paths). - SQLite settings:
OPTIONS["journal_mode"]to opt into WAL or other
journal modes (default keeps SQLite's default DELETE journal). - SQL logging:
dorm.db.backends.<vendor>loggers emit each statement at
DEBUGand queries aboveDORM_SLOW_QUERY_MS(default 500ms) atWARNING. - Migration locking:
dorm migrateacquires an advisory lock on PostgreSQL
(and a write lock on SQLite) so concurrent invocations across processes
serialize instead of racing. - Identifier validation:
Meta.db_table,db_column, M2Mdb_table, and
related_nameare validated against a safe-identifier regex at model
attach time, raisingImproperlyConfiguredfor unsafe names.
Changed
bulk_update()rewritten as a singleUPDATE ... SET col = CASE pk WHEN ... ENDper batch (1 query, not N). Same change applies to
abulk_update(). Thebatch_sizeparameter is now actually honored
(it was previously ignored).- M2M prefetch is one JOIN, not two SELECTs.
prefetch_related("tags")
now issues a single query that joins the through table to the target
table, rather than fetching the through rows and then the targets in a
second pass. aiosqliteconnection thread is marked daemon before start so the
Python interpreter can exit even if the user forgets to await
connection.close()(Python 3.13+ joins non-daemon threads before
atexit, otherwise hangs).- The
dormCLI no longer fails silently when no apps are detected; it
emits a warning to stderr explaining the autodiscovery rules. _load_settingsnow puts both the directory containingsettings.py
and its parent onsys.path, supporting flat layouts (apps next to
settings) and dotted-package layouts (myproj/settings.pywith
INSTALLED_APPS=["myproj.app"]) without extra config.- App import errors during
_load_appsare surfaced to stderr instead of
being silently swallowed; an app whosemodels.pyhas a real import
problem now produces a clear warning.
Fixed
- PostgreSQL
execute_insertno longer hardcodesRETURNING id.
Models with a custom PK column name (e.g.db_column="user_id") used
to fail on PG; the backend now honorsmeta.pk.column. _ado_insert(async insert) used to include M2M fields in the INSERT
column list (theircolumnisNone, which producedINSERT INTO t ("title", "None")and a SQL error). It now skips column-less fields
and applies field defaults the same way the sync path does.asyncio.get_event_loop()replaced byasyncio.get_running_loop()
in the async backends (the former is deprecated in Python 3.12+
and slated for removal in 3.16+).- Async pool / connection cleanup on event-loop change: when the running
loop changes betweenasyncio.run()calls, the stale wrapper
reference is dropped instead of being awaited on the new loop
(prevents fragile cross-loop cleanup). _to_pyformatno longer rewrites$Noccurrences inside SQL string
literals or quoted identifiers — it now parses tokens correctly,
so user-supplied data containing$Nis no longer corrupted.- The forced
PRAGMA journal_mode = WALon SQLite is gone. SQLite's
defaultDELETEjournal mode is now used unless you opt into WAL via
DATABASES["default"]["OPTIONS"]["journal_mode"] = "WAL". (No more
surprisedb.sqlite3-shm/db.sqlite3-walfiles.)
Performance
- Single-query
bulk_update/abulk_update: with 1000 rows the round-trip
count drops from 1000 to 1. - Single-query M2M prefetch:
prefetch_related("tags")issues 2 SELECTs
total (base + JOIN), down from 3 (base + through + targets). POOL_CHECK=Falseremoves theSELECT 1probe from each PG pool
checkout, saving ~0.1–1 ms per query on hot paths.
Docs
- README sections expanded for: async cancellation behavior, mixing sync
and async on SQLite, atomic-as-decorator form, awaiting a queryset,
POOL_CHECKsetting, web framework integration (FastAPI / Starlette /
Flask), batch sizing guidance, and a "Production deployment" section
covering logging, migration safety, pool sizing, and shutdown.
Operational tooling
dorm migrate --dry-runprints the exact SQL that would run
without touching the database. Recorder is not updated, so the
next plainmigratere-detects the same pending migrations.
Pre-deploy review gate for SREs / DBAs.QuerySet.explain(analyze=True)/aexplain(). Returns the
database's query plan as a string —EXPLAIN (ANALYZE, BUFFERS)
on PG,EXPLAIN QUERY PLANon SQLite. Diagnose slow production
queries without leaving Python.dorm sql <Model>(or--all) prints theCREATE TABLEDDL
for one or more models. Useful for sharing schema with DBAs or for
diffing against production by hand.
New field types
ArrayField(base_field)for native PostgreSQL array columns.
Accepts list / tuple / iterator inputs;db_typeraises
NotImplementedErroron SQLite so the limitation surfaces at
migrate time rather than at first query.
New lookups
array_contains(@>),array_overlap(&&),
json_has_key(?),json_has_any(?|),
json_has_all(?&) — vendor-specific membership / key
checks for PG arrays and JSONB columns. The pre-existing
__containslookup stays string-LIKE for back-compat; reach
for the explicit array/json names when the column type demands it.
Build / CI
- Coverage gate in CI:
--cov-fail-under=73so accidental
drops break the build. Raise the threshold whenever you add tests. - Dependabot config (
.github/dependabot.yml): weekly grouped
PRs for pip + GitHub Actions versions. docsextra + GitHub Pages workflow (mkdocs-material+
mkdocstrings) —mkdocs servefor local preview, automatic
deploy togh-pageson every push tomain.
Docs
- API reference site (
docs/index.md,docs/api/*.md,
mkdocs.yml) auto-generates from package docstrings. docs/migration-from-django.md— cheat sheet for users
porting code from Django ORM to dorm.- README sections: Secrets management (env vars / pydantic-settings
/ AWS Secrets Manager), OpenTelemetry integration snippet for the
query observability hooks. - Bilingual documentation site (English + Spanish) via
mkdocs-static-i18nwith thesuffixlayout (foo.en.md/
foo.es.md). New full-length guides shipped in both languages:
Getting started, Tutorial, Models & fields, Querying, Async
patterns, Migrations, Transactions, FastAPI integration, CLI
reference, Production deployment, Cookbook, Troubleshooting, and
Migration from Django ORM. The auto-generated API reference stays
English-only (docstrings) with Spanish stubs that link back.
Production deployment helpers
- Health check.
dorm.health_check(alias)and
dorm.ahealth_check(alias)runSELECT 1against the configured
backend and return a JSON-shaped status dict suitable for
Kubernetes / ALB / Render readiness probes. Never raises — health
endpoints have to answer the orchestrator even when the DB is down. - Pool stats.
wrapper.pool_stats()returns{vendor, open, min_size, max_size, pool_size, pool_available, requests_waiting, ...}for ad-hoc inspection or Prometheus exporters. Sync and async
PG wrappers expose the full psycopg-pool stats; SQLite returns a
minimal shim for API parity. - PG connection lifecycle settings. New
MAX_IDLE(default 10 min)
andMAX_LIFETIME(default 1 hour) on eachDATABASESentry —
passes through to psycopg-pool so long-lived workers don't pile up
stale conns behind PgBouncer / RDS Proxy. - Multi-DB / read replicas. New
DATABASE_ROUTERSsetting; each
router is an object with optionaldb_for_read(model, **hints)/
db_for_write(model, **hints)methods.Manager.get_queryset()
consults routers when no explicitusing=is set, so existing
call sites pick up replica routing with zero changes. - Server-side cursors for streaming on PG.
iterator(chunk_size=N)
/aiterator(chunk_size=N)now use a server-side named cursor on
PostgreSQL (so multi-million-row scans don't load the whole result
set into client memory) andcursor.arraysizeon SQLite. Without
chunk_size, the previous all-rows-then-iterate path is preserved. - Async cancellation safety test. New regression test exercising
asyncio.wait_formid-query: the pool's ctx-manager returns the
connection, no leaks even when a coroutine is cancelled. - Tutorial doc.
docs/tutorial.mdwalks a new user from install
to a working FastAPI/usersAPI in 5 minutes — a learning
on-ramp that the long reference README didn't provide.
CI
- PG version matrix. A second job runs the suite against PostgreSQL
13 / 14 / 15 / 17 (in addition to 16 in the Python matrix), catching
version-specific quirks in advisory locks, IDENTITY columns and
syntax.
Production hardening
- Transient-error retry. PostgreSQL execute paths automatically retry
OperationalError/InterfaceError(network blips, server
restart, RDS failover) up toDORM_RETRY_ATTEMPTS(default 3) with
exponential backoff (DORM_RETRY_BACKOFFseconds, default 0.1s).
Retries are disabled while inside a transaction so committed work is
never re-applied. SQLite retries on "database is locked" too. Helpers
with_transient_retry/awith_transient_retryare exposed in
dorm.db.utilsfor user-driven retry of arbitrary code. - Query observability hooks. New
dorm.pre_queryand
dorm.post_querySignalinstances fire around every SQL
statement.post_queryreceivers also seeelapsed_msand
error(orNone), which is enough to wire OpenTelemetry,
Datadog, Prometheus, or any custom metric / tracing backend without
patching dorm internals. - Lifecycle INFO logs. Pool open and close events log at INFO on
dorm.db.lifecycle.postgresql(db, host, pool sizes, timeout,
check flag). Per-query DEBUG and slow-query WARNING channels are
unchanged.
Pydantic / FastAPI
- Nested relations in
DormSchema.Meta.nestednow accepts a
mapping{field_name: SubSchema}: ForeignKey / OneToOne fields
serialize as the sub-schema (Type | Noneif nullable),
ManyToManyField serializes aslist[SubSchema]. Lets a FastAPI
response_modeldeliver embedded objects directly, no manual
validators needed.
CLI
dorm dbcheck. Compares each model's column set with the live
database schema and prints drift (missing tables, columns missing in
the DB, columns missing in the model). Exits non-zero when drift is
found, so it doubles as a pre-deploy gate.
Versioning
- README adds a Versioning and deprecation policy section: SemVer
scope, deprecation cycle, stable / unstable surfaces.
Type safety
Fieldis now generic in the stored Python type (Field[str],
Field[int],Field[datetime], …). Each concrete subclass declares
its T parameter, so static type checkers (mypy / pyright / ty) see
user.name(wherename = CharField(...)) asstrrather
thanAny. Same idea SQLAlchemy 2.0 used withMapped[T].
Runtime is unchanged.ManagerDescriptoris generic in the model type, so
Author.objectsis staticallyBaseManager[Author]and the
whole queryset chain preserves the row type:
Author.objects.filter(...).first()isAuthor | None._ForeignKeyIdDescriptor— a typed read/write descriptor is
installed for the underlying<fk>_idslot when a ForeignKey is
attached.obj.author_idis now strictlyint | Noneinstead of
Any, and writing through it invalidates the FK's cached related
instance so the nextobj.authorre-fetches with the new pk.
(For full static type-safety on_idaccess, also add a class-level
author_id: int | Noneannotation — runtime descriptors aren't
visible to type checkers.)
FastAPI / Pydantic interop
- New module
dorm.contrib.pydantic:DormSchema—BaseModelsubclass with a Django-REST-style
class Metathat auto-fills fields from a dorm Model.Meta
acceptsmodel,fields(orexclude), andoptional.
Anything declared in the class body — overrides, extra fields,
@field_validatordecorators — wins over the Meta-derived
defaults.from_attributes=Trueis set automatically so FastAPI
can use a dorm instance as aresponse_modeldirectly.schema_for(model_cls, *, name, exclude, only, optional, base)—
one-line auto-generation when you don't need a class block. The
returned class has fields built at runtime, so type checkers see
it astype[BaseModel]. UseDormSchemafor typing-sensitive code.- M2M fields are excluded (no row-level column); FK / O2O serialize
as the underlying PK column type.
- New optional extra
pydantic(pip install 'djanorm[pydantic]').
Noemail-validatordependency — dorm validates the email format
itself (see below).
Validation
-
EmailFieldnow rejects invalid addresses on construction.
Previously the regex check only ran frommodel.full_clean(),
whichsave()/objects.create()do not call — so
Customer.objects.create(email="example")happily wrote a row
with a bogus value. The check moved intoEmailField.to_python
(invoked by__set__and byModel.__init__), so:Customer(email="example") # ValidationError now Customer.objects.create(email="example") # ValidationError now customer.email = "example" # ValidationError now
Reads from the database go through
from_db_value(direct dict
write) and are not re-validated, so historical bad rows still
load. -
Model.__init__no longer swallows ValidationError. The
previousexcept Exceptionaround field assignment is now
narrowed toexcept FieldDoesNotExist, so format errors raised
byto_python(EmailField etc.) propagate to the caller instead
of being silently dropped.
Build / CI
aiosqliteupper-bound:<0.23. The daemon-thread fix relies on a
private aiosqlite attribute that may move in future versions; bump the
cap deliberately after re-verifying.- GitHub Actions
test.ymlnow starts a real Postgres service container
and exposesDORM_TEST_POSTGRES_*env vars; conftest prefers that
service over testcontainers in CI, eliminating the "PG tests silently
skipped" blind spot. Tests run withpytest -n 4; each xdist worker
gets its own Postgres database to avoid cross-worker collisions.