Migrations Walkthrough¶
Experimental in 1.4.0
cello migrate generates DDL from your models and runs it against your database. Read every generated file before you apply it. See the migrations guide.
cello migrate compares your cello.orm models with the live Postgres schema and writes the difference as plain SQL files. This example drives the whole workflow (sql, make, apply, status) from a script, in a throw-away schema and a temporary directory, and shows an incremental second migration and the handling of destructive changes.
Features Demonstrated¶
python -m cello migrate --experimental sqlpreviews the next migration without writing itmakewritesNNNN_<name>.sql;applyruns pending files in order, one transaction per file;statuslists history and whether the models are in sync- An incremental migration (new columns and a new table) generated from the difference between the models and the live schema
- Destructive changes (
DROP COLUMN,DROP TABLE) written commented out unless--allow-destructiveis given - Targeting a schema through
options=-c search_path=...in the database URL
Complete Source Code¶
examples/migrations_demo_models.py
"""
Models for examples/migrations_demo.py.
Set CELLO_DEMO_STAGE=2 to get the second version of the models (a new field, a
new model and a changed constraint), so the demo can show an incremental
migration.
"""
import os
from cello.orm import (
AutoField, BooleanField, CharField, ForeignKey, IntegerField, Model, TextField,
)
STAGE = int(os.environ.get("CELLO_DEMO_STAGE", "1"))
class Author(Model):
id = AutoField()
name = CharField(max_length=100)
class Meta:
table_name = "ex_authors"
class Book(Model):
id = AutoField()
title = CharField(max_length=200, unique=True)
author = ForeignKey(Author, on_delete="CASCADE")
if STAGE >= 2:
pages = IntegerField(default=0) # new column
blurb = TextField(null=True) # new nullable column
class Meta:
table_name = "ex_books"
if STAGE >= 2:
class Review(Model): # new table
id = AutoField()
book = ForeignKey(Book, on_delete="CASCADE")
stars = IntegerField()
public = BooleanField(default=True)
class Meta:
table_name = "ex_reviews"
examples/migrations_demo.py
#!/usr/bin/env python3
"""
Migrations walkthrough for Cello v1.4.0 (experimental).
Runs `python -m cello migrate --experimental {sql,make,apply,status}` end to
end against a throw-away Postgres schema, using the models in
``migrations_demo_models.py``:
1. preview the first migration (sql)
2. write 0001_initial.sql (make)
3. apply it (apply)
4. show the history (status)
5. change the models (STAGE=2), generate and apply an incremental migration
6. show what a destructive change looks like (a field removed again)
Everything happens in a temporary schema and a temporary migrations directory;
both are removed when the demo ends.
Run:
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/cello_test
python examples/migrations_demo.py --port 8000 # --port is accepted but
# unused: no server runs
The same commands by hand (from the examples/ directory):
export CELLO_DATABASE_URL=postgresql://user:pass@localhost/shop
python -m cello migrate --experimental sql --models migrations_demo_models
python -m cello migrate --experimental make --models migrations_demo_models
python -m cello migrate --experimental apply --models migrations_demo_models
python -m cello migrate --experimental status --models migrations_demo_models
"""
import argparse
import asyncio
import os
import shutil
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path
from urllib.parse import quote
from cello import App, DatabaseConfig
HERE = Path(__file__).resolve().parent
BASE_URL = os.environ.get(
"DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5432/cello_test"
)
def sh(args, env_extra=None, check=True):
"""Run a command, echo it and its output, and return the completed process."""
env = dict(os.environ, PYTHONPATH=str(HERE), **(env_extra or {}))
shown = " ".join(["python -m cello migrate --experimental"] + args)
print(f"\n$ {shown}" + (f" [{' '.join(f'{k}={v}' for k, v in (env_extra or {}).items())}]" if env_extra else ""))
proc = subprocess.run(
[sys.executable, "-m", "cello", "migrate", "--experimental"] + args,
env=env, cwd=HERE, capture_output=True, text=True,
)
out = (proc.stdout + proc.stderr).rstrip()
if out:
print(out)
if check and proc.returncode != 0:
raise SystemExit(f"command failed with exit code {proc.returncode}")
return proc
def main():
parser = argparse.ArgumentParser(description="Cello migrations walkthrough")
parser.add_argument("--port", type=int, default=8000, help="unused (no server runs)")
parser.add_argument("--keep", action="store_true", help="keep the schema and files")
args = parser.parse_args()
# Migrations act on the first schema of the connection's search_path, so a
# unique schema keeps the demo away from your real tables.
schema = "ex_mig_" + uuid.uuid4().hex[:8]
sep = "&" if "?" in BASE_URL else "?"
dsn = f"{BASE_URL}{sep}options=-c%20search_path%3D{quote(schema)}"
mig_dir = tempfile.mkdtemp(prefix="cello_migrations_")
# A small pool creates and (at the end) drops the schema.
admin_app = App()
admin_app.enable_database(DatabaseConfig(url=BASE_URL, pool_size=1))
async def admin_sql(sql):
return await admin_app.database.execute(sql)
asyncio.run(admin_sql(f'CREATE SCHEMA "{schema}"'))
print(f"schema: {schema}\nmigrations dir: {mig_dir}")
common = ["--models", "migrations_demo_models", "--dsn", dsn, "--dir", mig_dir]
try:
print("\n=== 1. Preview the first migration (nothing is written) ===")
sh(["sql"] + common)
print("\n=== 2. Write it ===")
sh(["make", "--name", "initial"] + common)
print("files:", sorted(p.name for p in Path(mig_dir).iterdir()))
print("\n=== 3. Apply it ===")
sh(["apply"] + common)
print("\n=== 4. Status ===")
sh(["status"] + common)
print("\n=== 5. Change the models (STAGE=2): new columns + a new table ===")
stage2 = {"CELLO_DEMO_STAGE": "2"}
sh(["make", "--name", "reviews"] + common, stage2)
print("\n--- generated file ---")
print((Path(mig_dir) / sorted(os.listdir(mig_dir))[-1]).read_text().rstrip())
sh(["apply"] + common, stage2)
sh(["status"] + common, stage2)
print("\n=== 6. Go back to STAGE=1: the removals are destructive ===")
# Without --allow-destructive the DROP statements are only written
# as comments, and `make` writes no file when nothing else changed.
sh(["sql"] + common)
sh(["make", "--name", "undo"] + common)
print("files:", sorted(p.name for p in Path(mig_dir).iterdir()))
finally:
if args.keep:
print(f"\nkept schema {schema} and {mig_dir}")
else:
asyncio.run(admin_sql(f'DROP SCHEMA "{schema}" CASCADE'))
shutil.rmtree(mig_dir, ignore_errors=True)
print(f"\ncleaned up schema {schema} and {mig_dir}")
if __name__ == "__main__":
main()
Running This Example¶
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/cello_test
python examples/migrations_demo.py
The script needs no server (--port is accepted for consistency but unused). It creates a schema named ex_mig_<random>, runs the CLI through subprocess, and drops the schema and the temporary directory at the end (--keep leaves them in place).
The same commands by hand, from the examples/ directory:
export CELLO_DATABASE_URL=postgresql://user:pass@localhost/shop
python -m cello migrate --experimental sql --models migrations_demo_models
python -m cello migrate --experimental make --models migrations_demo_models
python -m cello migrate --experimental apply --models migrations_demo_models
python -m cello migrate --experimental status --models migrations_demo_models
Captured output¶
A real run (the random schema name, directory and timestamps are shortened):
schema: ex_mig_xxxxxxxx
migrations dir: /tmp/cello_migrations_xxxxxxxx
=== 1. Preview the first migration (nothing is written) ===
$ python -m cello migrate --experimental sql --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
-- cello migration 0001_initial
-- EXPERIMENTAL: generated by `python -m cello migrate --experimental make`.
-- Review every statement before applying. Renames are not detected.
-- Generated: 2026-10-11T06:58:18
-- cello:tables ["ex_authors", "ex_books"]
BEGIN;
-- create_table ex_authors
CREATE TABLE "ex_authors" ("id" SERIAL PRIMARY KEY, "name" VARCHAR(100) NOT NULL);
-- create_table ex_books
CREATE TABLE "ex_books" ("id" SERIAL PRIMARY KEY, "title" VARCHAR(200) UNIQUE NOT NULL, "author_id" INTEGER NOT NULL REFERENCES "ex_authors"("id") ON DELETE CASCADE);
COMMIT;
=== 2. Write it ===
$ python -m cello migrate --experimental make --name initial --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
Wrote /tmp/cello_migrations_xxxxxxxx/0001_initial.sql (2 statement(s)). Review it, then run apply.
files: ['0001_initial.sql']
=== 3. Apply it ===
$ python -m cello migrate --experimental apply --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
Applied 0001_initial
=== 4. Status ===
$ python -m cello migrate --experimental status --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
applied 0001_initial (2026-10-11 06:58:18)
models: in sync
=== 5. Change the models (STAGE=2): new columns + a new table ===
$ python -m cello migrate --experimental make --name reviews --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx [CELLO_DEMO_STAGE=2]
Wrote /tmp/cello_migrations_xxxxxxxx/0002_reviews.sql (3 statement(s)). Review it, then run apply.
--- generated file ---
-- cello migration 0002_reviews
-- EXPERIMENTAL: generated by `python -m cello migrate --experimental make`.
-- Review every statement before applying. Renames are not detected.
-- Generated: 2026-10-11T06:58:19
-- cello:tables ["ex_authors", "ex_books", "ex_reviews"]
BEGIN;
-- create_table ex_reviews
CREATE TABLE "ex_reviews" ("id" SERIAL PRIMARY KEY, "book_id" INTEGER NOT NULL REFERENCES "ex_books"("id") ON DELETE CASCADE, "stars" INTEGER NOT NULL, "public" BOOLEAN NOT NULL DEFAULT TRUE);
-- add_column ex_books.pages
ALTER TABLE "ex_books" ADD COLUMN "pages" INTEGER NOT NULL DEFAULT 0;
-- add_column ex_books.blurb
ALTER TABLE "ex_books" ADD COLUMN "blurb" TEXT;
COMMIT;
$ python -m cello migrate --experimental apply --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx [CELLO_DEMO_STAGE=2]
Applied 0002_reviews
$ python -m cello migrate --experimental status --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx [CELLO_DEMO_STAGE=2]
applied 0001_initial (2026-10-11 06:58:18)
applied 0002_reviews (2026-10-11 06:58:19)
models: in sync
=== 6. Go back to STAGE=1: the removals are destructive ===
$ python -m cello migrate --experimental sql --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
-- cello migration 0003_drop_column_ex_books_and_more
-- EXPERIMENTAL: generated by `python -m cello migrate --experimental make`.
-- Review every statement before applying. Renames are not detected.
-- Generated: 2026-10-11T06:58:19
-- cello:tables ["ex_authors", "ex_books", "ex_reviews"]
BEGIN;
-- cello:destructive [disabled: needs --allow-destructive; review, then uncomment] drop_column ex_books.pages: drops the column and its data
-- ALTER TABLE "ex_books" DROP COLUMN "pages";
-- cello:destructive [disabled: needs --allow-destructive; review, then uncomment] drop_column ex_books.blurb: drops the column and its data
-- ALTER TABLE "ex_books" DROP COLUMN "blurb";
-- cello:destructive [disabled: needs --allow-destructive; review, then uncomment] drop_table ex_reviews: drops the table and all of its rows
-- DROP TABLE "ex_reviews";
COMMIT;
$ python -m cello migrate --experimental make --name undo --models migrations_demo_models --dsn postgresql://postgres:postgres@127.0.0.1:5432/cello_test?options=-c%20search_path%3Dex_mig_xxxxxxxx --dir /tmp/cello_migrations_xxxxxxxx
warning: 3 destructive statement(s) written commented-out; review them, then re-run make with --allow-destructive or uncomment by hand
No file written: every change is destructive and --allow-destructive was not given.
ALTER TABLE "ex_books" DROP COLUMN "pages";
ALTER TABLE "ex_books" DROP COLUMN "blurb";
DROP TABLE "ex_reviews";
files: ['0001_initial.sql', '0002_reviews.sql']
cleaned up schema ex_mig_xxxxxxxx and /tmp/cello_migrations_xxxxxxxx
Key Concepts¶
- Plain SQL you can review --
makewrites ordinary.sqlfiles into the migrations directory. Nothing runs until you callapply. - Diffed against the live database --
makecompares the models with what Postgres actually has, so it refuses to run while earlier files are still pending, and writes nothing when the schema already matches. - One transaction per file -- each file runs in its own transaction under an advisory lock, and the applied file's checksum is recorded in
cello_migrations. Editing an applied file makesapplyrefuse to run. - Destructive changes are opt-in -- step 6 removes the added columns and the model again. The generated statements appear as comments under a
cello:destructivemarker, and no file is written. Pass--allow-destructivetomakeandapplyto run them. - Renames are not detected -- a rename looks like a drop plus an add. Edit the file to use
ALTER TABLE ... RENAME COLUMNbefore applying. - Choosing the schema -- migrations act on the first schema of the connection's
search_path, which is how this demo isolates itself. - Programmatic use --
cello.migrate.Migratorofferssql(),make(),apply()andstatus()for scripts.