Migrations¶
Models are the source of truth. makemigrations compares what you declare against history and
generates a file with the difference.
uv run snakeorm makemigrations --models myapp.models # writes migrations/0001_initial.py
uv run snakeorm migrate --models myapp.models # applies them
uv run snakeorm status --models myapp.models # which ones are applied
uv run snakeorm rollback --models myapp.models # undoes the last one
Running it from a web framework¶
Nothing to configure. Run the command from your project and it finds the application itself:
cd myproject # where manage.py / main.py / app.py lives
uv run snakeorm tables
uv run snakeorm makemigrations --name add_notes
uv run snakeorm migrate
# myproject/settings.py, main.py, app.py — wherever your entry point already is
from snakeorm.connection import SnakeBackend, SnakeConnectionConfig
from snakeorm.contrib.config import SnakeOrmConfig
SNAKEORM = SnakeOrmConfig(
databases={"default": SnakeConnectionConfig(backend=SnakeBackend.POSTGRES, name="mydb")},
migrations_dir="migrations",
)
It works because the application already said everything the CLI needs. SnakeOrmConfig lives in
snakeorm.contrib.config; it is not a top-level export. Importing that module is also what runs
the @snake_model decorators. So the CLI looks for the entry point the way each framework itself
defines one (manage.py and its DJANGO_SETTINGS_MODULE, main.py, app.py, wsgi.py), imports
it, and takes the config out by type. There is no name to remember and no second file to write.
That the ENGINE comes from there too is the part worth noticing: snakeorm migrate runs on SQLite
and MySQL, not only on Postgres, because the config pairs driver and dialect and the CLI now asks
instead of assuming.
Prefer your framework's own command? Add it in one line
The executable works everywhere and needs nothing. If you would rather type what your framework taught you, the adapters carry no logic — they hand the arguments to the same CLI:
# myapp/management/commands/snakeorm.py -> manage.py snakeorm tables
from snakeorm.cli.hooks import SnakeOrmCommand as Command
# your Flask factory -> flask snakeorm tables
from snakeorm.cli.hooks import flask_command
app.cli.add_command(flask_command())
FastAPI has no adapter, and that is deliberate: it has no command line of its own to hook into
(uvicorn main:app is an argument to another program), so it uses the executable — which
already needs nothing configured.
Your entry module must be importable without doing work
The CLI imports it, and that import is what registers the models. A module that opens
connections, migrates or seeds at import time will do all of that because you asked to list your
tables. Django's settings are constants and FastAPI puts its side effects in lifespan; in
Flask, let the CLI find create_app instead of calling it yourself at the bottom of the file —
the same discipline that stops flask run --reload re-seeding on every save. If you truly must
do work at import, guard it with the SNAKEORM_CLI variable, which is set before importing.
All three engines, on all three axes
Reading a schema is the third axis beside writing the SQL and executing it, and it now has an
implementation per engine like the other two. scaffold and check work on PostgreSQL,
MySQL/MariaDB and SQLite; the CLI picks the one that matches the connection your application
declares, the same way it picks the driver and the dialect.
snakeorm scaffold create --out mirror.py # Postgres, MySQL/MariaDB or SQLite
snakeorm check # drift: your models vs the real schema
snakeorm fresh # wipe and rebuild from the migrations
fresh too: emptying a schema is DDL, so each dialect writes its own — Postgres cascades,
MySQL brackets the drops with its foreign-key switch, SQLite with the pragma.
What a mirror gives back is not the exact inverse of what was written; see db-first.
status, tables, table and advise round out the set: which migrations are applied, what the
models declare, one table in detail, and which foreign keys lack an index.
When you need to say it yourself¶
--models and --dsn override discovery. --models is an import path (myapp.models, not
myapp/models.py) and stays REQUIRED for makemigrations --only, which is the one place it names
something discovery cannot guess: WHICH domain the migration is for. --database picks one
connection by name when the config declares several.
Not every command takes every flag — makemigrations and squash have no --dsn. Ask
snakeorm <command> --help.
With no application to find and no flags, the CLI stops and names the routes it tried. It never falls back to a database nobody named.
What's inside a file¶
Plain, readable Python. No frozen SQL; that's why the same migration works for two engines:
from snakeorm.metadata import SnakeColumnInfo, SnakePrimaryKeyInfo, SnakeTableInfo
from snakeorm.migration import AddColumn, CreateTable
operations = [
CreateTable(
SnakeTableInfo(
name="users",
columns=(
SnakeColumnInfo(name="id", python_type=int, autoincrement=True),
SnakeColumnInfo(name="email", python_type=str, unique=True),
),
primary_key=SnakePrimaryKeyInfo(columns=(...,)),
)
),
]
Each SCHEMA operation knows three things: its forward SQL (up_sql), its backward SQL (down_sql)
and how it mutates the abstract state (apply_to_state). That third one lets makemigrations
reconstruct the schema from history without connecting to the database.
A DATA operation is the other shape, and RunPython further down is one: it keeps
apply_to_state and swaps the two SQL halves for run and unrun, because it has no SQL of its
own to hand over — it takes a session and does the work itself. Its apply_to_state does nothing,
which is the honest answer: moving rows around changes no shape for the next migration to diff
against.
What the autogen detects¶
Tables, columns, types, nullability, defaults, indexes, UNIQUE and CHECK constraints,
foreign keys (including on_delete changes), schemas, views, functions and triggers.
Renaming a column¶
The diff sees a DROP + an ADD, and applying them loses the data. When it detects the pattern
it suggests on the console:
Warning: this could be a RENAME, and as it stands it DELETES the old column's data.
- users: did you rename 'nickname' to 'nick'? Replace its DropColumn + AddColumn
with RenameColumn(users, old_name="nickname", new_name="nick").
It suggests; it doesn't decide. Guessing a rename and getting it wrong would be worse than asking.
In CI¶
uv run snakeorm makemigrations --models myapp.models --check # exits != 0 if a migration is missing
uv run snakeorm check --models myapp.models # code vs the real DATABASE
The first catches "I forgot to generate the migration" (code vs history). The second catches "someone touched the database by hand" (code vs real DB). You need both.
Collapsing the history¶
It generates a migration that replaces that stretch:
| Situation | What it does |
|---|---|
| None of the replaced ones applied | It runs it. Fresh install. |
| All applied | It marks it applied without running it. The DB is already like that. |
| Some yes and some no | It stops and tells you. |
The third case has no answer to guess: running would repeat; marking would skip what's missing. Both corrupt.
Upgrading from a version before type_params
Column metadata changed shape: int_size, max_length, json_storage, precision and
scale are no longer loose fields on SnakeColumnInfo — they travel inside a per-family
type_params object. Migration files that were already generated write them the old way,
so they stop loading.
There is no compatibility shim, and that is deliberate: one path, no legacy. The way out is to squash the history as shown above and regenerate. If your database is already up to date, the resulting migration is marked applied without running anything, so no data is touched.
Data migrations¶
To fill a new column from another one, DDL isn't enough:
from snakeorm import SnakeQuery
from snakeorm.migration import AddColumn, RunPython
def fill(session):
session.update_where(
SnakeQuery(User).filter(User.nickname.is_null()),
[(User.nickname, "")],
)
def undo(session):
...
operations = [AddColumn(...), RunPython(fill, undo)]
forward and backward receive a SnakeSession over the same connection and transaction, so a
mixed migration (schema + data) is still all-or-nothing. They must be module-level functions, not
lambdas: the renderer writes them by reference.
Without backward, the rollback raises an explicit error
An irreversible data migration doesn't undo itself. The error tells you exactly what to add.
Atomicity¶
PostgreSQL and SQLite both have transactional DDL, so each migration is all or nothing. The ORM
doesn't take that for granted: it reads it from supports_transactional_ddl. On an engine without it
(MySQL), the error says how many operations were applied before failing.
Next: the runnable examples — the same API printed against a real database — or straight to the guide, or dialects if you care about what changes between engines.