2025, Nov 11 09:00

Fix Django migrations on a secondary SQLite database: run migrate and sqlmigrate with --database

Using multiple databases in Django? Reads work but schema changes don’t on SQLite. Use --database with migrate and sqlmigrate to apply schema updates.

Connecting a second SQLite database to a Django project is straightforward, but migrations can look deceptively “successful” while changing nothing at all. If your app reads and writes data to the secondary database just fine, yet schema changes never land, you are likely running migrations against the wrong database. Here is a clear walkthrough of the scenario and the precise fix.

Reproducing the setup and the symptom

The project uses two databases: the default one for Django internals and a separate SQLite file for an app called archiver. A router directs reads and writes for models in that app to the archiver database. The models were generated via inspectdb, adjusted, and the first migration was marked as applied with --fake. Everything appears wired correctly because reads and writes through the Django shell hit the archiver database. The issue arises when a new migration removes a column: Django reports success, but the column remains in the SQLite file, and the file timestamp does not change.

Below is a minimal version of the router and settings used in this setup. The logic remains the same; names are adjusted for clarity.

# archiver/routers.py
class ArchiveRouterV2:
    def db_for_read(self, model, **hints):
        if model._meta.app_label in ['archiver']:
            return 'archiver'
        return None
    def db_for_write(self, model, **hints):
        if model._meta.app_label in ['archiver']:
            return 'archiver'
        return None
    def allow_migrate(self, db, app_label, model_name=None, **hints):
        if app_label in ['archiver']:
            return db == 'archiver'
        return None
# settings.py (excerpt)
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
USER_PATH = Path(os.getenv('USER_DIR', './user'))
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    },
    'archiver': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': USER_PATH / 'data/app.db',
    }
}
DATABASE_ROUTERS = ['archiver.routers.ArchiveRouterV2']

Data access works as expected, confirming routing. For example, the following save lands in the archiver database:

>>> rec = State(account="test", name="test", value="test")
>>> rec.save()

After removing an unused field from the Post model, the migration is created:

from django.db import migrations
class Migration(migrations.Migration):
    dependencies = [
        ('archiver', '0001_initial'),
    ]
    operations = [
        migrations.RemoveField(
            model_name='post',
            name='note',
        ),
    ]

Running migrate prints an OK status, but the column persists in the file.

Why nothing changes

The sqlmigrate output tells the story. Asking Django for the SQL that would run on the archiver database produces the correct ALTER TABLE statement. Doing the same without specifying a database yields a no-op.

$ python manage.py sqlmigrate archiver 0002 --database=archiver
BEGIN;
--
-- Remove field note from post
--
ALTER TABLE "posts" DROP COLUMN "note";
COMMIT;
$ python manage.py sqlmigrate archiver 0002
BEGIN;
--
-- Remove field note from post
--
-- (no-op)
COMMIT;

By default, migrate targets the default database. In this scenario, that produces empty SQL for the archiver app, which explains the “applied” message without any actual schema change in the secondary SQLite file.

The fix

Be explicit about the target database when running migrations for the non-default connection. The following command applies the change to the right database and updates the file as expected:

python manage.py migrate archiver --database=archiver

Why this matters

Multi-database projects can lull you into a false sense of security because reads and writes follow the router, while schema operations do not automatically switch databases in manage.py commands. That asymmetry is easy to miss, especially with SQLite where a no-op migration on the default database completes silently.

Practical takeaways

When managing schema changes for a non-default database, always pass --database to migrate and sqlmigrate. If a migration appears to apply but nothing changes on disk, inspect the SQL for the intended database and confirm the command targets the same alias. This habit prevents silent no-ops and keeps your models and secondary SQLite file in sync.

The article is based on a question from StackOverflow by s427 and an answer by s427.