Skip to content

Schema Management

Your models are the source of truth for your schema. Ferro gives you two doors for getting a database to match them:

  • Auto-migrate changes the database straight from the models when you call connect(). No files, nothing to review.
  • Migrations write each change down as a numbered directory of SQL and Python steps that you review, commit and apply. Ferro writes them for you from the difference between your models and the last migration.

Say you add one field:

from ferro import Field, Model


class Author(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    email: str = Field(unique=True)
    nickname: str | None = Field(default=None, index=True)  # new
from typing import Annotated

from ferro import FerroField, Model


class Author(Model):
    id: Annotated[int | None, FerroField(primary_key=True)] = None
    name: str
    email: Annotated[str, FerroField(unique=True)]
    nickname: Annotated[str | None, FerroField(index=True)] = None  # new

With auto-migrate, the next connect(url, migrate_updates=True) runs ALTER TABLE "author" ADD COLUMN "nickname" varchar and creates its index, and carries on. With migrations, ferro migrate new author_nickname writes those same statements into migrations/0002_author_nickname/, once per database dialect you target, and ferro migrate up runs them when you say so.

The ladder

Rung How you ask for it What it does Reach for it when
1. Auto-create connect(url, auto_migrate=True) Creates missing tables. Never touches a table that exists. Tests, scripts, a first prototype
2. Auto-update connect(url, migrate_updates=True), optionally migrate_destructive=True Also alters existing tables to match the models, as far as an in-place statement can. Development while the schema is still moving
3. Migrations ferro migrate new, then ferro migrate up (pip install "ferro-orm[cli]") Reviewed, numbered files: schema steps, data steps, and a way back down. Covers every change the models can express, on Postgres and SQLite. Any database whose data you would mind losing. Recommended for production.

Rungs 1 and 2 are the first door (Auto-migrate); each flag implies the ones before it. Rung 3 is the second door (Migrations).

Already on Alembic?

Ferro's Alembic bridge is a supported alternative to rung 3 when a project already runs Alembic or keeps SQLAlchemy tables beside its Ferro models. Its revisions come from the same planner auto-migrate uses. It does not write backfills or SQLite table rebuilds; see what each door covers.

One door per database

A database is managed by auto-migrate or by migrations, never both. Once a database has run a migration, it carries the tracking table _ferro_migrations, and connect() refuses every auto-migrate flag on it before running any DDL:

connect(auto_migrate=…) is refused: main is governed by ferro migrations (main._ferro_migrations). Use ferro migrate up, or drop the tracking tables to leave migrations.

(main is SQLite's schema; on Postgres it names the schema the connection works in, such as public.)

The rule is per database, not per project. A throwaway test database built with connect(url, auto_migrate=True) still works in a project that has migrations, because that database has never run one.

You have And you try What happens
A database with migrations applied connect(..., auto_migrate=True) (or either stronger flag), create_tables(), migrate() Refused before any DDL, text above
A database with migrations applied alembic revision --autogenerate over Ferro models Refused, naming ferro migrate new (Alembic)
A database auto-migrate or Alembic built ferro migrate up Refused, naming ferro migrate baseline (adopting migrations)
A database auto-migrate built alembic revision --autogenerate Works: the first revision is empty when the database matches the models, since both doors plan with the same planner

What each door covers

Change Auto-update Migrations Alembic bridge
Add a table, a nullable column, an index, an enum label ✅ ✅ ✅
Add a check, a foreign key, change a type or nullability, on Postgres ✅ ✅ ✅
The same on SQLite (needs a table rebuild) ⚠️ warns, no DDL ✅ generated table rebuild ❌ refused at autogenerate
Rename a column (renamed_from) or an enum label (__ferro_renamed_labels__) ✅ ✅ declared on the model ✅ same hints
Rename a table (__ferro_renamed_from__) ✅ RENAME TABLE under migrate_updates, indexes and checks renamed with it; without migrate_updates the pass warns and creates nothing ✅ ✅
Drop a column with migrate_destructive ✅ marked -- ferro: destructive ✅ marked # ferro: destructive
Drop a table ❌ ✅ marked -- ferro: destructive ✅ marked # ferro: destructive
Add a new required column to a table with rows only with a literal default (backfills existing rows) ✅ expand, backfill, contract generated ⚠️ the plain op, marked # ferro: data-dependent; the backfill is yours to write
Make a nullable column required Postgres: SET NOT NULL, fails if any row is NULL (write it as a migration with a backfill); SQLite: warns, no DDL ✅ expand, backfill, contract generated ⚠️ the plain op, marked # ferro: data-dependent
Remove an enum label rows may hold warns, no DDL ✅ backfill and contract generated ❌ not written; Migrations generate it
Python data steps over the models as they were ❌ ✅ data steps hand-written op.execute(...)
Change a primary key ❌ refused, with the recipe ❌ refused, with the same recipe

Choosing

  • Starting out, or writing tests: auto_migrate=True. Tests keep this posture even in a project with migrations; see Testing migrations.
  • Developing, the schema still moving, the data disposable: migrate_updates=True.
  • The first time you would mind losing the data: ferro migrate init, then ferro migrate new initial. If the database already exists, follow Adopting migrations on an existing database; it runs no DDL.
  • A server: apply migrations in the deploy step and refuse to start behind them; see Deploying migrations.
  • A local-first app that ships a SQLite file to its users: migrations, applied at start-up with await ferro.migrations.up() (Deploying migrations).

Where the old page went

This group replaces the single Schema Migrations page. Its sections now live here:

Old section Now
Three Ways to Manage Schema The ladder
Auto-Migration, migrate_updates, label addition, migrate_destructive, migrate(), safety guidance Auto-migrate
Alembic for Production Alembic
Choosing a Workflow Choosing

See Also