Django raw SQL migrations next to the ORM: views, indexes, rollback

Keep Django's ORM migrations. Version the SQL it does not own, views, indexes, reference data, next to them with a manage.py check warning. Real output, one failure, one rollback.

Django migrations are good at what they are for: the schema your models describe. The trouble starts with everything else. A view that joins three tables for a report. An index the ORM will not express. A stored procedure. Reference data that has to exist before the app boots. A second database that Django only reads from. Teams end up with a RunSQL here, a "run this in psql before deploy" note there, and a schema nobody can reconstruct from the repo.

DBLift is a Flyway-style raw-SQL migration runner for Python: Apache 2.0, pip install dblift, no JVM, and you see the SQL before it runs. This post keeps Django's migrations exactly where they are and puts the rest in versioned SQL files that DBLift applies from manage.py. Two history tables, one repository, one deploy step, and a system check that tells you when the SQL side is behind.

The setup

One app, one model. catalog/models.py:

from django.db import models


class Product(models.Model):
    sku = models.CharField(max_length=32, unique=True)
    name = models.CharField(max_length=120)
    price_cents = models.IntegerField()
    active = models.BooleanField(default=True)

Install the Django extra and register the app. The migrations directory is yours to name; here it sits next to manage.py.

pip install "dblift[django]"
# settings.py
INSTALLED_APPS = [
    # ...
    "catalog",
    "dblift.integrations.django",
]

DBLIFT_MIGRATIONS_DIR = BASE_DIR / "migrations"

DBLift reads the connection from DATABASES["default"]. PostgreSQL, MySQL, SQLite, Oracle and SQL Server map straight to a driver; anything else takes an explicit DBLIFT_DATABASE_URL. This walkthrough uses Django's default SQLite file so there is nothing to start.

Run the ORM side as usual:

python manage.py makemigrations catalog
python manage.py migrate

Two SQL files the ORM does not own

A view over active products, with its undo file, and a repeatable view for price bands. The V, U and R prefixes are the Flyway convention: versioned runs once, undo reverses one version, repeatable re-runs whenever its content changes. active = 1 is SQLite; on PostgreSQL write active = true, or WHERE active, which both accept.

-- migrations/V1__active_products_view.sql
CREATE VIEW active_products AS
SELECT id, sku, name, price_cents
FROM catalog_product
WHERE active = 1;
-- migrations/U1__active_products_view.sql
DROP VIEW active_products;
-- migrations/R__price_bands.sql
DROP VIEW IF EXISTS price_bands;
CREATE VIEW price_bands AS
SELECT sku,
       CASE WHEN price_cents < 1000 THEN 'under-10'
            WHEN price_cents < 5000 THEN '10-50'
            ELSE '50-plus' END AS band
FROM catalog_product;

The check tells you before the app does

Nothing has been applied yet. Django's own check now says so:

$ python manage.py check
System check identified some issues:

WARNINGS:
?: (dblift.W001) dblift: 2 pending migration(s): ['1 - active_products_view', ' - price_bands']
	HINT: Apply with `python manage.py dblift_migrate`.

System check identified 1 issue (0 silenced).

runserver prints the same warning. It is a warning, not an error, by design: the command that fixes it must still be able to run. If you want a stale schema to fail the deploy, run dblift_migrate as a deploy step, or register a check of your own that escalates dblift.W001.

The same information without the Django framing:

$ python manage.py dblift_info
dblift: 2 pending migration(s)
  - V1__active_products_view.sql
  - R__price_bands.sql
dblift: 0 failed migration(s)

Apply

$ python manage.py dblift_migrate
Found 1 pending repeatable migration(s)
Found 2 pending migration(s)
Migration lock acquired successfully
Statement executed successfully
Migration V1__active_products_view.sql executed successfully in 1ms
Successfully applied migration V1__active_products_view.sql
Migration R__price_bands.sql executed successfully in 1ms
Successfully applied migration R__price_bands.sql
dblift: migrations applied
$ python manage.py dblift_validate
Migration validation passed
dblift: validation passed

$ python manage.py check
System check identified no issues (0 silenced).

Two history tables now live side by side: django_migrations (19 rows, untouched by any of this) and dblift_schema_history (two rows). The CLI shows the second one in full; the same dblift info you would run in CI:

$ DBLIFT_DB_URL="sqlite:///./db.sqlite3" dblift info
=== Migration Summary ===
Total Migrations: 2
Applied Migrations: 2
Pending Migrations: 0
Failed Migrations: 0

│ Category   │ Version │ Description          │ Type │ Installed On        │ State   │ Undoable │
├────────────┼─────────┼──────────────────────┼──────┼─────────────────────┼─────────┼──────────┤
│ Versioned  │ 1       │ active_products_view │ SQL  │ 2026-09-06 14:07:22 │ Success │   Yes    │
│ Repeatable │         │ price_bands          │ SQL  │ 2026-09-06 14:07:22 │ Success │    No    │

V1 is undoable because U1 exists. The repeatable is not, because repeatables re-run rather than roll back.

A migration that fails

An index on a table name with a typo. This is the case that matters: what does the deploy step do, and what is left in the database.

-- migrations/V2__product_search_index.sql
CREATE INDEX catalog_product_name_idx ON catalog_produkt (name);
$ python manage.py dblift_migrate
Found 1 pending migration(s)
Migration lock acquired successfully
ERROR: Error executing SQL statement: no such table: main.catalog_produkt
ERROR: SQL: CREATE INDEX catalog_product_name_idx ON catalog_produkt (name);
ERROR: Failed to execute statement 1 from V2__product_search_index.sql: no such table: main.catalog_produkt
CommandError: Failed to execute statement 1 in V2__product_search_index.sql: no such table: main.catalog_produkt

Exit code 1, so a deploy pipeline stops here. The history table records version 2 with success = 0. Pending and failed are separate: the failed row is not pending, and dblift_info lists it.

$ python manage.py dblift_info
dblift: 0 pending migration(s)
dblift: 1 failed migration(s)
  - V2__product_search_index.sql

$ python manage.py dblift_validate
ERROR: Found 1 failed migration(s): V2__product_search_index.sql (version: 2)
ERROR: Run 'repair' command to update the status in the history table.

dblift_info is status (pending and failed). dblift_validate still owns the repair path. Put both in CI; do not treat info alone as a substitute for validate.

Fix the typo, clear the failed row, apply again:

$ DBLIFT_DB_URL="sqlite:///./db.sqlite3" dblift repair
Command REPAIR completed successfully (Execution time: 10 ms)

$ python manage.py dblift_migrate
Found 1 pending migration(s)
Migration lock acquired successfully
Statement executed successfully
Migration V2__product_search_index.sql executed successfully in 1ms
Successfully applied migration V2__product_search_index.sql
dblift: migrations applied

On SQLite and PostgreSQL the failed statement ran inside a transaction, so there was nothing half-applied to clean. On MySQL, where DDL commits implicitly, repair clears the history row and the partial change is yours to inspect; that is the same rule as any other SQL runner.

Roll back one version

Undo is a file you write, U<version>__<same description>. Without it, dblift undo refuses rather than guessing:

ERROR: No undo script found for V2__product_search_index.sql

With it, preview first, then run:

-- migrations/U2__product_search_index.sql
DROP INDEX catalog_product_name_idx;
$ DBLIFT_DB_URL="sqlite:///./db.sqlite3" dblift undo --dry-run --show-sql
Found 1 migration(s) to undo
DRY RUN: Would undo the following migrations:
  - V2__product_search_index.sql
SQL Statements:
-- U2__product_search_index.sql
DROP INDEX catalog_product_name_idx;

$ DBLIFT_DB_URL="sqlite:///./db.sqlite3" dblift undo
Found 1 migration(s) to undo
Statement executed successfully
Migration U2__product_search_index.sql executed successfully in 1ms
Successfully undone migration V2__product_search_index.sql

And the check notices immediately:

$ python manage.py check
WARNINGS:
?: (dblift.W001) dblift: 1 pending migration(s): ['2 - product_search_index']
	HINT: Apply with `python manage.py dblift_migrate`.

What this does not do

  • It does not replace makemigrations. Model changes stay in Django migrations; DBLift never generates DDL from models.
  • It does not merge the two histories. django_migrations and dblift_schema_history are separate tables, applied by separate commands. In CI that is two lines, migrate and dblift_migrate, in that order if your SQL depends on ORM tables (it does here).
  • The undo of a Django migration is still migrate catalog 0001; the undo of a DBLift version is its U file. Keep the two in the same pull request when they depend on each other.

Where to go next

The Django guide has the settings table for the five mapped backends and DBLIFT_DATABASE_URL for the rest. If your SQL files already exist under Flyway, the history imports as is. Try it on the view or index you already maintain by hand; a SQLite file and one migration takes about five minutes.

DBLift is information technology / developer tools software. Contact: contact@dblift.com.