Manage a baseline
pg_dumpfor Django test databases — turn N-minutemigrateboots into a few-secondpsqlimport.
A reusable Django app that manages a baseline.sql artifact (a
pg_dump of the post-migrate schema + seed data) and loads it
automatically whenever Django creates a test database. migrate then
applies only the small delta of migrations added since the dump was
taken.
In real projects with hundreds of migrations this turns a ~6-minute
migrate into a ~3-second psql import — or sub-second when paired
with a testcontainer that clones from a populated template DB.
A Django suite with hundreds of migrations and/or non-trivial seed
data spends many minutes per test run on migrate. The fix is
well-known in principle:
- Apply migrations once against a clean PG.
pg_dumpthe result.- On every subsequent test run,
psql -f(orCREATE DATABASE ... WITH TEMPLATE) the dump into the test DB, then letmigrateapply only the small delta of migrations added since the dump was taken.
Every Django shop with a heavy migration history rediscovers this
pattern independently. django-pg-baseline packages it as a reusable
app with all the operational bits people forget the first time:
- a deterministic, version-controlled
baseline.sql(with timestamp freezing for diff stability), - a sidecar
baseline.meta.jsonrecording the highest migration name per app, plus git SHA and PG version, - automatic loading on test DB creation when no faster template-clone path is available,
- explicit coordination with pytest-testcontainers-django for the template-clone path,
- a one-shot
manage.py baseline_rebuildthat spins an isolated PG viatestcontainers, runsmigrate, and emits the dump.
- One-line setup: add
"django_pg_baseline"toINSTALLED_APPS, setPG_BASELINE['BASELINE_DIR'], done. - Three usage modes: standalone (host
psql), with testcontainers (template clone), or rebuild-only (CI cron). - Optional pytest plugin for projects that don't want to add the
app to
INSTALLED_APPS. - Deterministic dumps: built-in timestamp freezing produces byte-stable diffs across rebuilds.
- Cross-major PG support: runs
pg_dumpinside the rebuild container to guarantee client/server version match; scrubs known PG17→PG16 incompatibilities. - Stale baseline is fine: if the dump lags behind HEAD, Django's
migrateapplies the delta on top.manage.py baseline_infoshows per-app deltas; the package itself never gates on freshness. - psycopg v2 and v3 compatible. No runtime psycopg dep — uses whichever the host project already pulled in for Django's PG backend.
uv add django-pg-baselinepip install django-pg-baselineThis package depends on Django>=5.0 and testcontainers[postgres].
It does not declare a runtime psycopg dependency — your Django
project already has either psycopg, psycopg-binary, psycopg2, or
psycopg2-binary installed (Django's PG backend requires one), and
forcing a flavor would conflict with that choice.
# settings.py
INSTALLED_APPS = [
...,
"django_pg_baseline",
]
PG_BASELINE = {
"BASELINE_DIR": BASE_DIR / "baseline-sql",
}The directory should be tracked in git — it holds baseline.sql and
baseline.meta.json, both produced by the baseline_rebuild
command.
python manage.py baseline_rebuild
git add baseline-sql/baseline.sql baseline-sql/baseline.meta.json
git commit -m "chore(baseline): refresh after migrations"This spins a fresh Postgres testcontainer, runs migrate, freezes
configured timestamp columns, runs pg_dump inside the container,
scrubs PG-version-specific lines, and writes the dump + meta file.
pytestDjango creates the test DB; the monkey patch loads baseline.sql via
psql; migrate applies any post-baseline delta. That's it.
The simplest case. Useful when:
- the consumer runs tests against a long-lived PG (host PG, a
docker composeservice, a CI service container), psqlis onPATH.
What happens at test time:
AppConfig.ready()installs the_create_test_dbpatch.- Django's runner calls
_create_test_db→CREATE DATABASE test_<name>. - Patch sees
django_migrationsis missing in the new DB →psql -f baseline.sql --single-transaction --quiet -v ON_ERROR_STOP=1. - Django's
migrateapplies any post-baseline delta.
Note: if you use a TEMPLATE DB (set TEST.TEMPLATE in your
DATABASES), the test DB user must be granted the pg_signal_backend
role — Postgres needs zero connections on the source DB before
CREATE DATABASE WITH TEMPLATE is allowed, and the patch terminates
leftover sessions to enforce that.
Faster (sub-second test-DB creation via template clone). Useful when:
- you accept Docker as a test dependency,
- you want the test DB to be a clone of a populated template
rather than a
psqlreload.
Setup is identical to Mode A. Once
pytest-testcontainers-django is installed, it auto-detects this
package via get_baseline_path(), mounts baseline.sql into the PG
container as /docker-entrypoint-initdb.d/01-baseline.sql, and sets
DATABASES['default']['TEST']['TEMPLATE'] so Django runs
CREATE DATABASE … WITH TEMPLATE ….
In Mode B the host psql shell-out is never invoked. We still
own:
- the patch's "kick sessions off template" prelude,
settings.PG_BASELINEandget_baseline_path(),manage.py baseline_rebuild.
python manage.py baseline_rebuild
git add path/to/baseline-sql/
git commit -m "chore(baseline): refresh after migrations …"Recommended downstream wiring: a GitHub Action that runs
baseline_rebuild whenever **/migrations/** changes on the main
branch and opens a PR with the refreshed dump. The package itself
does not enforce any "freshness" policy — when to rebuild is the
project's decision; we just provide the tooling.
PG_BASELINE = {
# REQUIRED. Directory holding baseline.sql + baseline.meta.json.
"BASELINE_DIR": BASE_DIR / "baseline-sql",
# Optional, defaults shown.
"SQL_FILENAME": "baseline.sql",
"META_FILENAME": "baseline.meta.json",
# Which Django connection to load into / dump from.
"DATABASE_ALIAS": "default",
# Auto-install the _create_test_db monkey patch in
# AppConfig.ready(). Set to False for manual control (e.g. only
# under pytest, only on certain CI hosts).
"AUTO_LOAD_ON_TEST_DB": True,
# Image used by `baseline_rebuild`. Override for plpython3u,
# custom locales, extensions, etc.
"REBUILD_IMAGE": "postgres:16",
# Extra args appended to the built-in pg_dump invocation. The
# default invocation already includes --no-owner --no-acl
# --no-privileges --no-comments and --exclude-table-data=django_session.
"PG_DUMP_EXTRA_ARGS": ["--exclude-table-data=audit_log"],
# Stacks ON TOP of the default exclusions. Each entry becomes
# --exclude-table-data=<pattern>. Cleaner than spelling out
# --exclude-table-data=... in PG_DUMP_EXTRA_ARGS.
"PG_DUMP_EXTRA_EXCLUDE_TABLE_DATA": ["django_cache*", "easy_thumbnails_*"],
# Tables/columns whose timestamps are frozen before pg_dump,
# for deterministic diffs across rebuilds.
"FREEZE_TIMESTAMPS": [("django_migrations", ["applied"])],
"FREEZE_TIMESTAMPS_EXTRA": [("django_template", ["creation_date"])],
"FREEZE_TIMESTAMP_VALUE": "2000-01-01 00:00:00+00",
}| Command | What it does |
|---|---|
baseline_load |
Load baseline.sql into the configured DB. Skips when django_migrations already exists, unless --force. |
baseline_info |
Human summary: git SHA, PG version, sql/meta paths, plus per-app deltas. Always exits 0. |
baseline_rebuild |
Full rebuild from scratch. Spins a testcontainers PG, runs migrate against an empty DB, freezes timestamps, runs in-container pg_dump, scrubs, writes meta. Auto-increment IDs (permissions, content_types) end up at whatever values post_migrate assigns this run — typically drifts between rebuilds, producing large diffs in baseline.sql. Use when starting fresh or when the prior dump is no longer loadable. Flags: --image, --baseline-dir. |
baseline_update |
Incremental update preserving IDs. Same as baseline_rebuild, but loads the existing baseline.sql into the testcontainer before migrate. Django's post_migrate then sees existing content_types/permissions via get_or_create and keeps their IDs; only genuinely-new rows (for models added by new migrations) get fresh sequential IDs. Use this for routine refreshes after adding migrations — produces minimal git diffs. Errors out when no prior baseline.sql exists; run baseline_rebuild first. Flags: --image, --baseline-dir. |
The two commands solve different problems:
-
baseline_rebuildanswers the question "what does a fresh database look like after running every migration?" Reproducible from source code alone. The auto-increment IDs are an implementation detail ofpost_migrate's iteration order; they are stable within one rebuild but typically shift between rebuilds (different Python hash seed, different app-registry traversal, third-party app reordering). When this happens,git diff baseline.sqlshows ~1500– 2000 lines of churn for the auth/contenttype tables even when no models actually changed, drowning the meaningful diff. -
baseline_updateanswers the question "what changes when I apply only the new migrations on top of the prior baseline?" The prior dump is loaded first, sodjango_migrationsalready records every migration baked into it;migrateapplies only the delta. Existing permission/content_type rows keep their IDs (Django'supdate_contenttypesandcreate_permissionsuseget_or_create), and any FK references to them in other dump rows stay valid. New rows for newly-introduced models append at the end of the sequence. Net effect: rebuilds with no migration changes produce a byte-identicalbaseline.sql(modulo the freeze-timestamp pass); rebuilds adding migrations produce a minimal diff confined to the actual changes.
Recommended workflow: use baseline_update for routine refreshes
after makemigrations. Reach for baseline_rebuild only when you
genuinely want a clean slate (e.g. you've removed a model and want
its content_type/permission rows pruned, or a Django/Postgres major
upgrade made the prior dump unloadable).
The two commands operate on the same baseline.sql artifact; nothing
in the file format distinguishes one from the other. The choice is
purely about the generation strategy.
If you'd rather not add the app to INSTALLED_APPS, the package
ships a pytest plugin that installs the same monkey patch via
pytest_configure:
# pyproject.toml — pytest auto-discovers the plugin via the
# pytest11 entry point. Nothing else needed.Behaviour matches the INSTALLED_APPS route exactly:
- no-op when
DJANGO_SETTINGS_MODULEis unset, - no-op when
PG_BASELINEis unset, - raises
pytest.UsageErrorwhenBASELINE_DIRis configured butbaseline.sqlis missing (matchingAppConfig.ready()policy — loud failure beats silent slowness in CI).
Use one route or the other, not both. (Both are idempotent; double install is safe but pointless.)
Stable from v0.1 (the contract surface for downstream tooling such
as pytest-testcontainers-django):
from django_pg_baseline import get_baseline_path # Path | NoneReachable via submodules but not yet locked under semver (stabilised at v1.0):
from django_pg_baseline.conf import get_config, BaselineConfig
from django_pg_baseline.patches import install_test_db_patch
from django_pg_baseline.loader import load_baseline, baseline_needed
from django_pg_baseline.freshness import check_freshness, FreshnessReport| Variable | Effect |
|---|---|
DJANGO_PG_BASELINE_SQL_PATH |
Override get_baseline_path() resolution. Points at a dump file directly, bypassing settings.PG_BASELINE['BASELINE_DIR']. Useful for CI pinning a specific baseline. |
The dump captures all data present in the testcontainer after
migrate(). If your data migrations seed users, fixtures, or any
other content that ends up in the dump, that data lands in version
control. Review the dump before committing, especially on the first
rebuild. Use PG_DUMP_EXTRA_EXCLUDE_TABLE_DATA to skip tables whose
row data should not ship (e.g. auth_user when you have real test
passwords). The package does not exclude auth_user by default —
projects that intentionally seed admin fixtures rely on that data
being in the baseline.
| Python | 3.10 | 3.11 | 3.12 | 3.13 |
|---|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
| Django | 3.10 | 3.11 | 3.12 | 3.13 | Status |
|---|---|---|---|---|---|
| 5.0 | ✓ | ✓ | ✓ | — | EOL Apr 2025 — supported on a best-effort basis |
| 5.1 | ✓ | ✓ | ✓ | ✓ | EOL Dec 2025 — supported on a best-effort basis |
| 5.2 LTS | ✓ | ✓ | ✓ | ✓ | Active LTS (extended support to Apr 2028) |
Django 4.2 is out of scope (LTS goes EOL in April 2026 — the project targets current Django).
PostgreSQL 16 and 17. Older PG versions (14, 15) are out of scope:
they're already EOL on the ladder and would complicate
_scrub_dump (the list of cross-major incompatibilities to scrub
grows with every PG release we keep alive).
psycopg2, psycopg2-binary, and psycopg[binary]>=3 all work —
the package uses whichever your Django project already pulled in for
its PG backend. CI tests both psycopg2-binary and psycopg[binary]
in separate matrix cells.
Linux is the supported CI target. macOS works in practice for local
development. Windows is not supported — the package shells out to
psql/pg_dump and assumes POSIX path conventions and a Linux-style
Docker daemon for the rebuild path.
django-pg-baseline is package #3 of the testcontainers-for-Django
family:
pytest-testcontainers— generic pytest plugin, session-scoped Docker container lifecycle. Framework-agnostic.pytest-testcontainers-django— Django bridge on top of #1. Injects env vars before Django imports settings; supports init-script mounts andDATABASES['default']['TEST']['TEMPLATE']for fast test-DB clone.django-pg-baseline(this package) — manages thebaseline.sqlartifact and provides the patch /get_baseline_path()contract that #2 reads.
Each package can be used standalone. Pair #3 with #2 for the
fastest test-DB creation; use #3 alone with a host psql if you
prefer no Docker dependency.
Issues and PRs welcome at https://github.com/iplweb/django-pg-baseline.
Local development:
git clone https://github.com/iplweb/django-pg-baseline
cd django-pg-baseline
uv sync --extra test
pre-commit install
pytestMIT — see LICENSE.