Configuration¶
A project commits one file that tells Ferro's tooling where its models are and which dialects its migrations target. ferro migrate init writes it; every ferro migrate verb, ferro.migrations.*, the Alembic bridge's get_metadata(), and connect() with an auto-migrate flag read it through one class, ferro.FerroSettings.
The two file forms¶
The same keys, at the top level of a ferro.toml or under [tool.ferro] in pyproject.toml:
A project has one or the other, never both: a directory holding a ferro.toml and a pyproject.toml with [tool.ferro] is refused, and so is a file that carries ferro keys both at its top level and under [tool.ferro]. Ferro reads one file and never merges two.
Several databases¶
A project whose models live in separate databases, each with its own migration history, declares one table per database. Nothing is inherited between them:
A model belongs to the database whose models list holds its module (or a parent package); a model no database claims is refused, and so is a foreign key from one database's model to another's. Each database's migrations live in migrations/<name>/ unless directory says otherwise, and directories may not overlap. Commands name the database with --database, and code with database="billing".
With one database, its name is default and its directory migrations/.
Keys¶
Per database¶
| Key | Type | Default | What it is |
|---|---|---|---|
models |
list of dotted modules | required | The modules that define this database's models. They are imported with the config file's directory, then python_path, first on sys.path. An import that registers no model is refused (no models is never read as "drop every table"). |
dialects |
list of "postgres", "sqlite" |
required | The target dialects: each DDL step is rendered once per dialect. |
url_env |
string | "DATABASE_URL" |
The environment variable that holds the database URL. The URL itself is never in the file; --url overrides the variable. |
directory |
path | migrations (one database), migrations/<name> (several) |
Where the migrations live, relative to the config file. |
tracking_schema |
string | none | Postgres only: the schema that holds the tracking tables, when not the connection's current schema. Refused unless dialects includes "postgres". |
ddl_lock_timeout |
duration | "5s" |
See below. |
lock_timeout |
duration | "30s" |
See below. |
ddl_lock_timeout¶
How long a DDL statement Ferro runs on Postgres, in a migration step or in an auto-migrate pass, waits for its table lock before giving up: "500ms", "5s", "1m", or "0" to wait without a limit (and without retries). A statement that gives up is retried with its step from the first statement, up to ten attempts, before the step fails naming ddl_lock_timeout. It does not apply to data steps, and it is not the wait for the run lock (lock_timeout).
connect() with an auto-migrate flag names no database, so when several databases are configured they must all set the same ddl_lock_timeout; different values are refused, naming each one.
lock_timeout¶
How long a run waits for the run lock while another run holds it: "500ms", "30s", "1m", a number of seconds, or "0" to wait without a limit. It is the default of --lock-timeout on ferro migrate up, down, baseline and rerecord (and of lock_timeout= on their Python calls); the flag wins. An auto-migrate pass (connect(auto_migrate=…), ferro.create_tables(), ferro.migrate()) waits for it too, so a replica booting while another one's pass runs waits instead of failing:
connect(auto_migrate=…) gave up waiting for the run lock on public: another ferro migration run or auto-migrate pass held it longer than lock_timeout (30s). Nothing was applied. Wait for that run to finish and try again, or raise lock_timeout; `ferro migrate status` shows a migration run while it holds the lock.
Unlike the flag, whose 0 refuses at once, the setting's "0" waits without a limit. As with ddl_lock_timeout, every configured database must set the same lock_timeout for an auto-migrate pass.
Top level¶
| Key | Type | Default | What it is |
|---|---|---|---|
python_path |
list of paths | [] |
Directories put on sys.path (after the config file's directory) before importing models, relative to the config file; for a src/ layout, python_path = ["src"]. Shared by every database. |
databases |
table of tables | none | One table per database, as above. With it, no per-database key may sit at the top level. |
unmanaged_tables is reserved and refused until it ships (#486).
Lookup order¶
FerroSettings(config=path), or--config PATHon the command line.- The
FERRO_CONFIGenvironment variable. - The nearest directory, walking up from the working directory, that holds a
ferro.tomlor apyproject.tomlwith a[tool.ferro]table.
The file found is used alone: nothing is layered on it, no environment variable overrides a key, and no .env file is read. These keys decide what a migration contains, so they come from the committed file only. No file is not an error for FerroSettings(): it returns an empty object listing the directories it searched, and refuses only when a database is asked for.
from ferro import FerroSettings
settings = FerroSettings()
db = settings.database() # the one configured; name it when there are several
db.directory # <config dir>/migrations
db.url_for() # $DATABASE_URL (or the url_env variable)
db.import_models() # imports the models modules, returns this database's models
Each FerroSettings() reads the file again; nothing is cached. The file is read by pydantic-settings' TomlConfigSettingsSource, the only source FerroSettings keeps.
Refusals¶
Every refusal names the file and the fix. The CLI reference quotes three of them exactly; the rest:
| What | The refusal says |
|---|---|
| No config file found | where it searched, and to create a ferro.toml, add [tool.ferro], or point FERRO_CONFIG / --config at one |
--config / FERRO_CONFIG names a missing file |
the resolved path, and to point it at a ferro.toml or a pyproject.toml with [tool.ferro] |
| A file that is not valid TOML | the parser's error, and to fix the file |
models or dialects missing |
the table, and the line to add (models = ["myapp.models"], dialects = ["postgres"]) |
| An unknown key | the table, and the keys allowed there |
url in the file |
that the URL is never in the file: set url_env, or pass --url |
A per-database key beside a databases table |
to move it into [databases.<name>] |
python_path inside a database table |
to move it to the top level |
tracking_schema without "postgres" in dialects |
to add "postgres" or remove tracking_schema |
An unparseable ddl_lock_timeout |
the accepted forms: "500ms", "5s", "1m", or "0" |
An unparseable lock_timeout |
the accepted forms: "500ms", "30s", "1m", a number of seconds, or "0" |
Databases that set different ddl_lock_timeout or lock_timeout values, for an auto-migrate pass |
each database's value, and to set the same one on every database |
Several databases and no --database |
the configured names |
| Overlapping migration directories | both databases, and to set directory so neither is inside the other |
| A model no database claims | its module, and to add it (or a parent package) to a database's models |
| A foreign key across databases | both models and databases |
$DATABASE_URL (or the url_env variable) unset |
to export it or pass --url |
See Also¶
- CLI reference — the commands that read this file
- Migrations —
ferro migrate init - Multiple Databases