MongoDB migrations in Python turn changes to collections, indexes and document fields into versioned PyMongo scripts. With DBLift, each file has a version, successful execution is recorded in a history collection, and a migration lock coordinates runs.
This tutorial creates an orders collection and indexes, backfills a missing status field, handles a partial failure, and writes an explicit undo. Run it against a disposable database. For connection and authentication settings, use the MongoDB engine reference.
The examples were checked with DBLift 4.8.0 and MongoDB 7.0.40 on 27 September 2026. Terminal excerpts omit headers, timestamps and some table columns.
What needs a migration in MongoDB?
Flexible documents still have an application schema: which fields a reader expects, which values are allowed, and which indexes queries need. MongoDB's schema versioning patterns explain how old and new document shapes can coexist. A backfill is useful when existing documents need the new shape; it need not happen in one large update during deployment.
Here, a migration means evolving documents and indexes within a database. Moving a cluster to Atlas or upgrading the PyMongo driver is a separate operation. DBLift's MongoDB runner uses Python files and PyMongo calls rather than SQL DDL.
Install and point at a database
pip install "dblift[mongodb]"
The database name is required, because a MongoDB URL does not have to carry one. Put both in a file next to your migrations folder.
database:
type: mongodb
url: "mongodb://localhost:27017/shop"
database: shop
migrations:
directories:
- "./migrations"
- Atlas works with the same two keys: a
mongodb+srv://URL, and the database name. - Credentials belong in the environment, not in the file. The URL can reference them, or DBLift reads
DBLIFT_DB_PASSWORD. - There is no schema key. Collections are schemaless, so DBLift has nothing to name.
A migration is a Python file with one function
The name carries the version and the description, exactly as it does for SQL: V1__create_orders_collection.py. The file defines one function, and DBLift hands it a context whose db attribute is a pymongo Database.
from dblift.api import MigrationContext
def migrate(context: MigrationContext) -> None:
if context.dry_run:
context.log.info("[dry-run] would create orders with a unique index on order_number")
return
orders = context.db.create_collection("orders")
orders.create_index("order_number", unique=True)
orders.create_index([("customer_id", 1), ("created_at", -1)])
The dry-run guard is yours to write. DBLift 4.8.0's
migrate --dry-runlists pending Python files without executing their bodies. It does not preview the documents a PyMongo call would change. Keep thecontext.dry_runguard for callers that execute your function in simulation mode; DBLift cannot intercept arbitrary SDK writes.
The rest of the context is what you would expect: raw_client is the MongoClient if you need another database, log writes into DBLift's output, and placeholders holds your configured values without substituting them anywhere.
Preview, then apply
Save V1 above and the V2 and U2 files shown below before running these commands. Validation reads the files and the history collection and says whether they agree. On an empty database that is a short conversation.
$ dblift validate
Migration validation passed
$ dblift migrate --dry-run
Found 2 pending migration(s)
DRY RUN: Would execute the following migrations:
- V1__create_orders_collection.py
- V2__orders_status_backfill.py
Apply up to a version, or apply everything pending. Here the collection goes in first.
$ dblift migrate --target-version 1
Found 1 pending migration(s)
Migration lock acquired successfully
Migration V1__create_orders_collection.py executed successfully in 26ms
Successfully applied migration V1__create_orders_collection.py
The lock is a single document in a dblift_migration_lock collection, taken with an upsert, so two deploys racing each other do not both run the migration. The history document is written after the function returns without raising.
Seed three disposable orders before the backfill. Two lack a status; the third already has one and should survive an undo unchanged. Adapt the URL if your test database is elsewhere.
python - <<'PY'
from pymongo import MongoClient
with MongoClient("mongodb://localhost:27017/shop") as client:
client.shop.orders.insert_many([
{"order_number": "A-1001"},
{"order_number": "A-1002"},
{"order_number": "A-1003", "status": "open"},
])
PY
Backfill a field
Most MongoDB migrations are not structural. They add a field to documents that predate it, rename a key, or move a value between collections. That is application logic, and it belongs in a versioned file for the same reason a SQL UPDATE does.
from dblift.api import MigrationContext
def migrate(context: MigrationContext) -> None:
orders = context.db["orders"]
missing = orders.count_documents({"status": {"$exists": False}})
context.log.info(f"{missing} orders without a status")
if context.dry_run:
return
result = orders.update_many(
{"status": {"$exists": False}},
{"$set": {"status": "open", "_dblift_status_v2": True}},
)
context.log.info(f"backfilled {result.modified_count} orders")
The $exists: false filter means a retry skips documents already handled. The marker records which documents this migration changed; an existing order with status: "open" receives no marker. The count runs when the function is invoked, not during the CLI's file-list dry run. On large collections, use bounded batches and plan for application writes during the backfill.
$ dblift migrate
Found 1 pending migration(s)
Migration lock acquired successfully
2 orders without a status
backfilled 2 orders
Migration V2__orders_status_backfill.py executed successfully in 4ms
Successfully applied migration V2__orders_status_backfill.py
Afterwards, info reads the history collection and the folder together.
$ dblift info
│ Category │ Version │ Description │ Type │ State │ Undoable │
│ Versioned │ 1 │ create_orders_collection │ Python │ Success │ No │
│ Versioned │ 2 │ orders_status_backfill │ Python │ Success │ Yes │
No automatic transaction around a DBLift MongoDB migration. MongoDB itself supports transactions in supported deployments, but this runner does not wrap your PyMongo operations in one. A migration that raises half-way leaves earlier writes in place. Keep each file focused, make retries idempotent, and rehearse against a copy before production.
When a migration fails half-way
Here is a file that writes to one document and then raises, standing in for a network error or a typo on the second statement.
from dblift.api import MigrationContext
def migrate(context: MigrationContext) -> None:
if context.dry_run:
return
orders = context.db["orders"]
orders.update_many({"order_number": "A-1001"}, {"$set": {"archived": False}})
raise RuntimeError("simulated failure after the first write")
$ dblift migrate
Found 1 pending migration(s)
Migration lock acquired successfully
ERROR: Python migration failed V3__orders_archive_flag.py: RuntimeError: simulated failure after the first write
$ dblift info
│ Versioned │ 3 │ orders_archive_flag │ Python │ Failed │ No │
$ dblift validate
ERROR: Found 1 failed migration(s): V3__orders_archive_flag.py (version: 3)
ERROR: Run 'repair' command to update the status in the history table.
Three things are true at this point, and the tool tells you all three. The first order now carries the flag and the second does not; on PostgreSQL the transaction would have taken that write back, and here nothing does. The history holds a document for version 3 with success set to false. Validation fails until the failed migration history is repaired.
$ dblift repair
Repairing 1 migration history issue(s).
Removed failed migration entry: V3__orders_archive_flag.py - migration can now be retried
Repair forgets the failed attempt; it does not undo the write. That is why the backfill earlier filtered on $exists: a file written that way can be fixed and run again without touching the documents it already handled. Write the retry into the migration, because MongoDB will not do it for you.
Roll it back
DBLift does not invent a reversal. A rollback is a second file with the same version and a U prefix. Here the marker limits the undo to documents changed by V2, and the status check preserves documents whose status has since changed.
from dblift.api import MigrationContext
def migrate(context: MigrationContext) -> None:
if context.dry_run:
return
context.db["orders"].update_many(
{"_dblift_status_v2": True, "status": "open"},
{"$unset": {"status": "", "_dblift_status_v2": ""}},
)
This is a targeted reversal, not a full before-image restore. An application could change a status and later set it back to open; the marker cannot detect that history. Coordinate writes or retain before-images when that distinction matters. Keep markers on changed documents for review rather than silently deleting them.
The Undoable column comes from this file existing. Version 1 has none: dropping a collection is not a rollback anyone should get by default. Preview the undo the same way as the forward run.
$ dblift undo --dry-run
Found 1 migration(s) to undo
DRY RUN: Would undo the following migrations:
- V2__orders_status_backfill.py
Apply the undo when you have reviewed its filter and checked for application writes:
$ dblift undo
Found 1 migration(s) to undo
Successfully undone migration V2__orders_status_backfill.py
In the untouched seed data, A-1001 and A-1002 lose their added status and marker; A-1003 keeps its pre-existing open status. This undoes V2 only, not the write left behind by the failed V3 example.
What the history collection holds
Everything above is recorded in a collection named dblift_schema_history, one document per applied file. It is the same shape DBLift writes into a table on PostgreSQL, so tooling that reads one can read the other.
{
"installed_rank": 2,
"version": "2",
"description": "orders_status_backfill",
"type": "PYTHON",
"script": "V2__orders_status_backfill.py",
"checksum": -2133867829,
"installed_by": "cyrille",
"execution_time": 4,
"success": true
}
The checksum is what turns an edited migration into a validation failure. Change a file after it has been applied and validate says so, on MongoDB exactly as on SQL.
CI, Atlas, and the rest of the stack
- In GitHub Actions, the official Action installs the package and runs one command. Pass
extras: mongodbso pymongo is present. - Against Atlas, the URL carries TLS, replica set and auth source options; DBLift passes it to pymongo untouched.
- The Python client works the same way:
DBLiftClientreads the same YAML and exposesvalidate(),migrate(),info()andundo(). - Azure Cosmos DB follows the same model with a different
dbobject, and relational engines accept.pyfiles next to.sqlones when a change needs Python.
Where this fits
If your MongoDB changes are already scripts in a folder that someone runs by hand, this is those scripts with a version, a history, a lock, and a preview. If you have never migrated MongoDB at all, start with the index you keep meaning to add.
- Python migrations, the full contract
- MongoDB engine reference
- dblift/dblift on GitHub