TortoiseORM with asyncpg + aerich migrations, K8s init container pattern.
┌─────────────────────────────────────────────────────┐
│ Pod │
│ ┌─────────────────────────────────────────────┐ │
│ │ init container: scripts/entrypoint.sh │ │
│ │ 1. wait for postgres (psql loop) │ │
│ │ 2. aerich table missing? → aerich_check.py │ │
│ │ 3. aerich upgrade (apply pending or no-op) │ │
│ │ 4. exit 0 │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ only starts after exit 0 │
│ ┌──────────────────▼──────────────────────────┐ │
│ │ app container: uvicorn │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
cp .env.sample .env # fill in DB_* vars (defaults work for docker-compose)
uv sync
# Start a throwaway local postgres
docker compose up -d# Generates migrations/models/0_*_init.py AND applies it to your local DB
uv run aerich init-db
# Verify
uv run aerich upgrade # → "No upgrade items found" means success
# Commit the generated migration
git add migrations/
git commit -m "init: aerich init-db"aerich init-db must only ever run locally. It:
- Generates
0_*_init.pywith CREATE TABLE DDL - Creates the
aerichtracking table in your local DB - Records
0_*_init.pyas applied
It cannot run in the container — migration files are baked into the image,
so aerich sees the migrations/models/ directory already exists and refuses.
See The container fresh-DB trick below.
# 1. Edit app/models.py
# e.g. bio = fields.TextField(null=True)
# 2. Diff models.py against aerich tracking table → generate migration file
uv run aerich migrate --name "add_user_bio"
# → creates migrations/models/1_*_add_user_bio.py
# 3. Apply locally to verify before committing
uv run aerich upgrade
# → ALTER TABLE "users" ADD COLUMN "bio" TEXT
# → if this fails, fix the migration before committing
# 4. Commit model + migration together
git add app/models.py migrations/
git commit -m "feat: add user bio column"Never commit a migration you haven't verified locally with aerich upgrade.
docker build -t tortoise-play:latest .
kubectl apply -f k8s/ # first deploy
kubectl rollout restart deployment/tortoise-app # subsequent deploysThe init container runs scripts/entrypoint.sh before the app starts:
aerich table exists in postgres?
NO → aerich_check.py: CREATE TABLE aerich (empty)
aerich upgrade: apply ALL migrations from 0 onward
YES → (skip aerich_check.py)
aerich upgrade: apply any new migrations, or no-op
aerich init-db (CLI and Python API) checks whether migrations/models/
exists and raises FileExistsError if it does — which always happens in the
container because migration files are baked into the image.
app/aerich_check.py works around this by splitting the two jobs that
init-db normally does:
| Job | Who does it |
|---|---|
Create aerich table |
aerich_check.py via generate_schemas(safe=True) with only aerich.models in scope — no directory operations |
| Apply migrations 0, 1, 2… | aerich upgrade — handles an empty aerich table by applying all files |
# app/aerich_check.py (simplified)
config = {
"connections": TORTOISE_ORM["connections"],
"apps": {
"aerich": {
"models": ["aerich.models"], # only the tracking model
"default_connection": "default",
}
},
}
await Tortoise.init(config=config)
await Tortoise.generate_schemas(safe=True) # CREATE TABLE IF NOT EXISTS aerich
await Tortoise.close_connections()
# → aerich upgrade then applies everything from migration 0 onward| Local | K8s init container | |
|---|---|---|
aerich init-db |
One-time bootstrap | Never — use aerich_check.py instead |
aerich migrate |
Generate diff file | Never |
aerich upgrade |
Verify before commit | Every deploy (no-op if up to date) |
| Migration files | Generated here, committed | Baked into image, read-only |
tortiseorm-play/
├── app/
│ ├── config.py # assembles DB_URL from DB_* env vars + TORTOISE_ORM dict
│ ├── models.py # edit this to add/change fields
│ ├── aerich_check.py # fresh-DB bootstrap for the init container
│ └── main.py # FastAPI app — generate_schemas=False always
├── migrations/
│ └── models/
│ ├── 0_*_init.py # generated + committed locally
│ └── 1_*_add_user_bio.py # generated + committed locally
├── scripts/
│ └── entrypoint.sh # init container script
├── k8s/
│ ├── 01-postgres.yaml # Deployment + ClusterIP
│ └── 02-app.yaml # init container → app + NodePort 30080
├── docker-compose.yml # local postgres only
├── .env / .env.sample # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
├── Dockerfile # COPY app/ + COPY migrations/ — both required
└── pyproject.toml # deps + [tool.aerich] section
| Do | Don't |
|---|---|
Run aerich upgrade locally before committing |
Commit migrations you haven't verified |
Commit migrations/ and models.py together |
Commit them separately |
Add columns with null=True |
Add NOT NULL column with no default on an existing table |
| One concern per migration file | Mix unrelated model changes in one migration |
Keep generate_schemas=False in register_tortoise |
Let the app auto-create schema on startup |
Run aerich init-db locally once |
Run aerich init-db inside any container |