Django
Materialize final Django migration state hermetically, export PostgreSQL DDL, and give one runner ownership of production changes.
Django’s final migration ProjectState is the authoritative desired schema (W). onwardpg materializes that state in a new disposable database and plans the rolling-deploy route from accepted history (H) to that destination. Run makemigrations --check so models and migration state agree. Django must not also execute physical DDL for the same production change.
Install the hermetic exporter
Copy export-schema.sh and export-current-model-state.py into your project’s scripts/ directory. The wrapper:
- creates and force-drops a randomly named database;
- sets
PYTHONDONTWRITEBYTECODE=1so imports do not dirty the checkout; - refuses a
pg_dumpwhose major differs from the server; - asks Django’s
MigrationLoaderfor the finalProjectState, then usesSchemaEditorto materialize those models without executing historical database/data operations; - sends Django chatter to stderr and only deterministic DDL to stdout; and
- removes Django’s runtime migration-recorder table from the desired application schema.
Add an export-only database alias:
# settings.py
import dj_database_url
DATABASES["onward_schema"] = {
**dj_database_url.config(env="DJANGO_SCHEMA_DATABASE_URL"),
"ENGINE": "django.db.backends.postgresql",
}
Point the wrapper at an administrative disposable PostgreSQL control plane, never production:
export ONWARDPG_DJANGO_ADMIN_URL='postgres://.../postgres'
bash scripts/export-schema.sh > /tmp/django-schema.sql
This deliberately does not replay RunPython, RunSQL, or custom migration operations. Those describe history-dependent work, not the final declarative catalog. Product data changes belong in onwardpg’s receipted reconciliation/operation workflow; migration state belongs in Django’s ledger. Django documents the distinction between database and state operations in SeparateDatabaseAndState (migration guide, operations reference).
[targets.app]
schema_command = ["bash", "scripts/export-schema.sh"]
dev_database_env = "ONWARDPG_DEV_DATABASE_URL"
scratch_database_env = "ONWARDPG_DJANGO_ADMIN_URL"
dev_mode = "workspace"
onwardpg config check executes the exporter twice. Byte differences, checkout mutations, command failures, and underlying stderr are reported before planning.
Normal development loop
- Change models and run
python manage.py makemigrations. - Convert the new migration to a state-only production record as described below. This does not hide the field from
W:ProjectStateincludesstate_operations, not the migration’s emptydatabase_operationslist. - Run
onwardpg plan feature-nameand follownext_actions. - Apply only the returned development reconciliation SQL to your dev database.
- Review and commit the Django migration plus the onwardpg bundle.
Django’s one-off default prompt may create an AddField(... preserve_default=False) operation. That default is migration-time assistance, not the final database default. If the final model is required without a default, onwardpg still sees NOT NULL in W and stages nullable expand, reconciliation, and contract.
Give one runner production ownership
Keep Django’s migration graph current without letting it execute the same physical DDL. Wrap the generated operations as state operations with no database operations:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bookings", "0041_previous")]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AddField(
model_name="booking",
name="status",
field=models.TextField(),
),
],
)
]
The release sequence is then unambiguous:
- the migration runner applies onwardpg
expand.sql; - the new Django release deploys while old and new writers overlap;
- writer evidence, receipted reconciliation, and
contract checkauthorizecontract.sql; - the runner applies contract; and
python manage.py migrate --noinputrecords the state-only migration.
Do not run step 5 before the physical plan is known to have completed, or a later Django migration may assume schema that contract has not installed. If contract succeeds but state recording fails, rerun the state-only migration. If expand or contract fails, leave Django state unapplied and resume the onwardpg release. migrate --fake can mark a normal migration applied, but Django explicitly warns that incorrect fake state may require manual recovery; state-only operations make the ownership decision reviewable in source (command reference).
This workflow is forward-only at the database boundary. Reversing Django state does not undo an onwardpg production contract; author a new forward plan for rollback.