This release is a pre-release and may not be stable for production use.
MongoMig
Alembic-style schema evolution and migrations for MongoDB.
Built for Python services (FastAPI, Flask, workers) on PyMongo, Motor or Beanie.
Status: alpha. The core workflow works end to end: models → diff → autogenerate → review → upgrade/downgrade. Expect rough edges; feedback is welcome.
$ mongomig diff
USERS
+ status: string = 'active' REQUIRES_DATA_MIGRATION backfill existing documents with 'active'
+ age: int | null = None SAFE defaults to None: existing documents need no backfill
- first_name WARNING removed from the model; existing data is kept (not deleted)
+ given_name: string MANUAL_REVIEW required, with no default: choose a value for existing documents
+ index users_email_unique (email ↑, unique) WARNING fails if existing documents contain duplicates
Possible rename: users.first_name → given_name (85% similar). If so, pass --rename users.first_name:given_name
$ mongomig revision --autogenerate -m "evolve user schema" --rename users.first_name:given_name
Generated b91baf4fd592 → migrations/versions/20260925_1115_b91baf4fd592_evolve_user_schema.py
$ mongomig upgrade
Running upgrade 13a4cc638050 -> b91baf4fd592, evolve user schema
• rename_field users: 4,218,901 modified (4,218,901 matched, 4219 batches)
• backfill users: 4,218,901 modified (4,218,901 matched, 4219 batches)
• index users.users_email_unique ready
✓ done in 8:42
Applied 1 revision(s).
Install
pip install --pre mongomig # add [beanie] for Beanie support
Requires Python 3.11+ and MongoDB 6.0+.
Quick start
mongomig init # creates mongomig.yaml + migrations/
# register your models in migrations/env.py (see "Registering your models")
export MONGODB_URI="mongodb://localhost:27017"
mongomig revision --autogenerate -m "initial" # new project: generate from your models
# ...or, for an existing database:
mongomig baseline # snapshot current models, no data changes
mongomig upgrade # apply pending revisions
mongomig current # what's applied vs pending
The workflow
1. Change your models
2. mongomig diff see what changed (offline, deterministic)
3. mongomig revision --autogenerate -m "..." generate the migration + update the snapshot
4. Review the file (search for TODO(review)), commit both files
5. CI: mongomig diff --check fails if a model change has no migration
6. Deploy: mongomig upgrade
diff compares your models with migrations/schema_snapshot.json, the committed record of what
the models looked like at the last migration. It does not compare against the live
database, so results are the same on every machine and in CI, and messy legacy data never
changes what gets generated. Use mongomig inspect to look at the real data.
mongomig init creates:
mongomig.yaml # connection + execution settings (no secrets!)
migrations/
├── env.py # Python: points MongoMig at your models
├── schema_snapshot.json # last known expected schema (used by autogenerate)
└── versions/ # one file per revision
Configuration
database:
uri: ${MONGODB_URI} # ${VAR} and ${VAR:-default} are expanded
name: ${MONGODB_DATABASE:-app}
Per-environment overrides live in mongomig.<env>.yaml and are merged on top:
mongomig --env production current # or MONGOMIG_ENV=production
MongoMig never prints passwords or full connection strings. It warns you if a password is written directly into the config file.
Writing migrations
"""add user status"""
revision = "d03be90b1aee"
down_revision = "a1f3c9d20b44"
reversible = True
def upgrade(ctx):
ctx.ops.create_index("users", "email", unique=True, name="users_email_unique")
ctx.ops.backfill("users", {"status": {"$exists": False}}, {"$set": {"status": "active"}})
def downgrade(ctx):
ctx.ops.unset_field("users", "status")
ctx.ops.drop_index("users", "users_email_unique")
ctx.ops operations are idempotent (re-running a half-finished migration is safe) and
data changes are batched in _id order, with retries on transient errors and progress
output:
| Operation | Notes |
|---|---|
create_index(coll, keys, name=None, **opts) |
keys: "email", ["a", "b"], [("a", 1), ("b", -1)]; opts: unique, sparse, partialFilterExpression, expireAfterSeconds, collation, hidden… |
drop_index(coll, name) |
skipped if missing |
create_collection(name, validator=None, …) / drop_collection / rename_collection |
create is skipped if the collection exists |
set_validator(coll, validator, level="moderate") / remove_validator(coll) |
$jsonSchema validators |
backfill(coll, filter, update, batch_size=None) |
update may be an aggregation pipeline |
unset_field(coll, field, filter=None) / rename_field(coll, old, new) |
batched |
For anything else, ctx.collection("users") is a plain PyMongo collection, and
ctx.unsafe_db is the raw database.
Declare reversible = False (and optionally omit downgrade) when a migration can't be
undone. downgrade then refuses to pass through it unless you add --force.
What autogenerate writes
Every change is classified, and only changes that autogenerate can do correctly become live code:
| Change | Classification | Generated |
|---|---|---|
New optional field, or default None |
SAFE | nothing (old documents read fine) |
| New required field with a default | REQUIRES_DATA_MIGRATION | backfill with the default (downgrade: unset_field) |
Renamed field (--rename coll.old:new) |
REQUIRES_DATA_MIGRATION | rename_field both ways |
| New/removed/changed index | SAFE / WARNING | create_index / drop_index both ways |
Validator added/changed (validator="auto") |
WARNING | set_validator / remove_validator |
| New required field, no default (or computed default) | MANUAL_REVIEW | commented TODO(review) backfill |
| Unsafe type change (e.g. string → int) | MANUAL_REVIEW | commented TODO(review) $convert backfill |
| Field or collection removed from models | WARNING | commented unset_field / drop_collection: data is never deleted automatically |
Renames are never guessed. MongoMig suggests them ("Possible rename: first_name → given_name")
and you confirm with --rename.
Registering your models
MongoMig learns what your collections should look like from your models, in the same way
Alembic learns from SQLAlchemy's MetaData. You can register plain Pydantic models with a
decorator:
# app/models.py
from pydantic import BaseModel
from mongomig import collection, Index
@collection("users", indexes=[Index("email", unique=True)], validator="auto")
class User(BaseModel):
name: str
email: str
age: int | None = None
Or register them explicitly in migrations/env.py, which keeps MongoMig out of your model
modules:
from mongomig import MongoMetadata, Index
from app.models import User
target_metadata = MongoMetadata.default(storage="python")
target_metadata.register(User, "users", indexes=[Index("email", unique=True)])
Beanie documents need no extra declarations, because the collection name, Indexed(...)
fields and Settings.indexes are read from the class:
target_metadata.register_beanie(User, Order)
validator="auto" makes MongoMig manage a $jsonSchema validator generated from the model
(with validationLevel: moderate by default). None (the default) leaves validators alone.
Storage profile
The BSON type that ends up in MongoDB depends on how your app writes documents:
storage= |
Your code | datetime |
UUID |
Decimal |
ObjectId |
|---|---|---|---|---|---|
"python" |
coll.insert_one(m.model_dump()) |
date | binData | decimal | objectId |
"json" |
m.model_dump(mode="json") / FastAPI jsonable_encoder |
string | string | string | string |
"beanie" |
Beanie (automatic for register_beanie) |
date | binData | decimal | objectId |
Also available: by_alias, exclude_none, exclude_unset (match your model_dump options)
and type_overrides={MyType: "string"}.
mongomig models shows the resulting schema. It also warns about types PyMongo cannot store
with your profile, such as date, Decimal, Enum members without use_enum_values, or
UUID without a uuidRepresentation:
$ mongomig models
users app.models.User
_id objectId
name string
email string
age int | null default=None
indexes: email_1 (email ↑, unique)
Inspecting your data
$ mongomig inspect users
Collection: users
Documents: 10,000 random sample of ~4,982,133 (0.41s)
Fields (nested presence is relative to the parent object):
field presence types
_id 100.00% objectId
name 100.00% string
age 63.20% int 97.2% · string 2.8%
profile 41.10% object
verified 100.00% bool
Indexes:
_id_ (_id ↑)
users_email_unique (email ↑, unique)
Validator: none
Sampling options are --sample-size N (the default comes from config), --sample-percent P
and --full-scan. Sampled results always say so, because documents outside the sample may
differ.
Revisions, branches, merges
Revision order comes from down_revision, not from the file name. Revision ids are random,
so two developers working on separate branches never get the same id. If both branches add a
revision, mongomig heads shows two heads, and mongomig revision refuses to continue until
you choose a parent with --head. mongomig merge joins the heads again.
Revision ids can be shortened to a unique prefix of 4 or more characters, the same way git handles commit hashes.
Commands
| Command | Needs MongoDB | Description |
|---|---|---|
init |
no | Create config and migrations directory |
diff [--check] [--rename C.OLD:NEW] |
no | Model changes since the last migration; --check exits 1 if any |
revision -m MSG [--autogenerate] [--rename C.OLD:NEW] [--head REV] |
no | Create a revision: empty, or generated from model changes |
baseline [-m MSG] |
no | Adopt MongoMig on an existing database: snapshot the models, no data changes |
heads |
no | Show head revision(s) |
history |
no | List revisions, newest first |
merge [REVS...] [-m MSG] |
no | Join several heads into one |
current [--check] |
yes | Applied vs pending (read-only); --check exits 1 if not up to date |
upgrade [TARGET] [--steps N] |
yes | Apply pending revisions. TARGET: head (default), heads, or a revision |
downgrade [TARGET] [--steps N] [--yes] [--force] |
yes | Revert one step (default), back to TARGET, or base |
stamp REV... |
yes | Mark revisions as applied without running them (baselines, checksum repair) |
models |
no | The schema your registered models declare, plus storage warnings |
inspect [COLL...] [--sample-size N | --sample-percent P | --full-scan] |
yes | The schema actually stored: fields, types, presence, indexes, validator |
Global options: --config PATH, --env NAME, --json, --verbose, --version.
Exit codes: 0 success · 1 validation · 2 execution/connection · 3 configuration ·
4 revision conflict · 5 lock · 6 checksum mismatch.
Safety
- Locking:
upgrade,downgradeandstamptake a distributed lock (__mongomig_lock) with a heartbeat, so two runners never migrate at the same time. Use--lock-timeout SECONDSto wait for another run instead of failing. - Checksums: each applied revision stores a checksum of its file. If an already-applied
file is edited,
upgradestops with exit code 6. - Failures are recorded (
mongomig currentshows them) and the nextupgraderetries the failed revision. Errors name the revision, operation, collection and how many documents were already processed. - Tracking records who ran each migration, where, and at which git commit.
FastAPI
from contextlib import asynccontextmanager
from fastapi import FastAPI
from mongomig import aupgrade_to_head
@asynccontextmanager
async def lifespan(app: FastAPI):
await aupgrade_to_head() # every worker may call this; one migrates, the others wait
yield
app = FastAPI(lifespan=lifespan)
For production, run mongomig upgrade as a separate deploy step (CI job, Kubernetes Job)
before rolling out the new app version. The lifespan hook suits development and small
deployments. mongomig current --check works well as a readiness guard. The same API is
available synchronously: mongomig.upgrade(), mongomig.downgrade(), mongomig.upgrade_to_head().
Roadmap
- M1 Foundation: config, CLI, revision files, revision graph, tracking
- M2 Migration engine:
upgrade,downgrade, locking,merge, checksums, FastAPI lifespan helper - M3 Schema engine:
@collectionmodels, Beanie support,inspect, snapshots - M4 Autogenerate:
diff,revision --autogenerate, index/validator diff - M5 Production safety:
--dry-run,plan, impact analysis, backups for destructive ops - M6 Release:
validate,doctor, docs, PyPI
Development
make install # uv venv + editable install with dev extras
make mongo-up # MongoDB 7 single-node replica set in Docker
make check # ruff + mypy --strict + pytest
Integration tests are skipped automatically when MongoDB isn't running. To test against another
MongoDB version, run MONGO_VERSION=8.0 make mongo-up.
Releasing
- Bump
src/mongomig/_version.pyand updateCHANGELOG.md, then merge tomain. - On GitHub, create a Release with tag
v<version>(e.g.v0.1.0) and publish it. .github/workflows/release.ymlchecks that the tag matches the version, builds and smoke-tests the wheel, then publishes to PyPI through Trusted Publishing.
License
MIT
Release files for mongomig 0.1.0a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mongomig-0.1.0a1.tar.gz | 102.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mongomig-0.1.0a1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 198.4 kB
Release files / mongomig-0.1.0a1.tar.gz
| Download URL | mongomig-0.1.0a1.tar.gz |
|---|---|
| Size | 102.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8ae3874f21b032e818ec963d0f36032f7cf817cdb2e72683eebd35317f6b5cc8
|
|
BLAKE2b-256 checksum How to use checksums |
a317b4ba727d69a9d00a3b6190eca97a78cf97c0e5f198957ebb45a86ab02c4e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / mongomig-0.1.0a1-py3-none-any.whl
| Download URL | mongomig-0.1.0a1-py3-none-any.whl |
|---|---|
| Size | 96.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ca2d9c4a7514482e67f4682295ff4049d1510b18bd229e0c26fd0a0d4cc151f3
|
|
BLAKE2b-256 checksum How to use checksums |
2e8a4f46b132221d14fa1898abaa802cd531aba5b3071c17622a31ec3faabaa8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log