Upgrading between versions¶
This guide lists what upgrading Ferro asks of your code, release by release from 0.11 to 0.26; the changelog is the exhaustive record. After an upgrade, a query reads like this, inside a session:
from ferro import engines
async with engines.session():
adults = await Account.where(lambda account: account.age >= 40).order_by("name").all()
assert [account.name for account in adults] == ["Alan Turing", "Grace Hopper"]
The query returns the two accounts aged 40 or over, in name order. Every recipe on this page reads one model, Account, declared here once:
class Role(StrEnum):
ADMIN = "admin"
MEMBER = "member"
class Account(Model):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(unique=True)
name: str
role: Role = Role.MEMBER
age: int
active: bool = True
seen_at: datetime | None = None
prefs: dict = Field(default_factory=dict)
class Role(StrEnum):
ADMIN = "admin"
MEMBER = "member"
class Account(Model):
id: Annotated[int | None, Field(primary_key=True)] = None
email: Annotated[str, Field(unique=True)]
name: str
role: Role = Role.MEMBER
age: int
active: bool = True
seen_at: datetime | None = None
prefs: dict = Field(default_factory=dict)
Every block below that calls a model runs inside a session on four accounts, as the first block does; see Connections › Sessions. Each section shows only what changed.
Which sections apply¶
Sections run newest first. From version X, read every section for a release newer than X, up to the release you are moving to, top to bottom within that span:
| You are on | Moving to | Read |
|---|---|---|
0.25.x |
0.26 |
Upgrading to 0.26 |
0.24.x |
0.26 |
Upgrading to 0.26 and Upgrading to 0.25 |
0.15.x |
0.26 |
Every 0.16 to 0.26 section, and the no-action table |
0.14.x |
0.26 |
The 0.15 section as well |
0.12.x or 0.13.x |
0.26 |
The 0.14 section as well |
0.11.x or earlier |
0.26 |
Every section |
0.11.x or earlier |
0.13 |
Upgrading to 0.12 only |
Each section with code to change includes a copy/paste prompt for a coding agent. If you cross more than one boundary, run the prompts newest first, one at a time, and run your tests between them.
Before you start¶
Turn deprecation warnings into failures on a throwaway branch, so nothing that still warns slips through:
The run catches every change a release announced with a DeprecationWarning. It does not catch the silent behaviour changes in 0.14 (create() and save() stop upserting, and limit() or offset() on a mutation raise), the NULL ordering change in 0.21, or the Postgres timeouts of 0.22: those emit no warning, so read their sections.
If you are moving from 0.12 or earlier straight past 0.14, the deprecated APIs are already removed there and fail with AttributeError or RuntimeError, not a warning, so the run above catches nothing. Run your suite on 0.13 first, or apply the prompts before you upgrade.
Upgrading to 0.26¶
Five changes in 0.26 can reach code that runs today. Two show up in a type checker or an import, two in text you compare, and one in the values a secret column stores:
Session.queryis now typed: it takestype[M]and returnsQuery[M], so a type checker may flag a call that passes a class that is not aModel.- The undocumented module
ferro.migrations.chunkedis now the privateferro.migrations._chunk_loop.from ferro.migrations import chunkedstill imports thechunkeddecorator, unchanged. - The
RuntimeErroran operation raises with no database route ends differently. It said "Implicit default-connection routing was removed in v0.13 (deprecated since v0.12); see the sessions migration guide." and now says "Implicit default-connection routing was removed in v0.14 (deprecated since v0.12); see the upgrade guide." Code that matches the message on its old ending needs the new one; a match on its start,No database route, still holds. save()of a new or fetched record,save(only=...),save(exclude=...)andbulk_create()list their columns in the model's field order. Before, abytescolumn came last, a fetched record'ssave()followed the order itsSELECTreturned, andsave(only=...)orsave(exclude=...)could change order from one run to the next. The order changes no value written; the statement text changes, so a test or a log check that compares captured SQL (the statement log or.sql()) needs its expected text updated.update()is unchanged: it lists the columns in the order the call passes them, abytescolumn included.- A
SecretStrorSecretBytesfield now stores its real value, and a fetched record loads it back as aSecretStrorSecretBytes.0.25wrote the mask**********in its place onsave(),bulk_create()andupdate(), and loaded thestr**********back. A row0.25saved holds the mask, and the real value cannot be recovered from it: write the value again from its source. ASecretBytescolumn is now binary (BYTEAon Postgres,BLOBon SQLite);0.25created it asvarchar. Change such a column to the binary type with a migration (ferro migrate newwrites it) before0.26saves to it: on Postgres aSecretBytesvalue saved into thevarcharcolumn is stored as its hex text (\xff006bforb"\xff\x00k") and loads back as those characters.
Upgrading to 0.25¶
0.25 adds ferro migrate squash, which asks nothing of you. It also writes two things 0.24 cannot read, which matters only while both versions share one migrations directory and one database, as in a rolling deploy:
- Schema snapshots. Every snapshot
0.25writes uses a newer snapshot format. A0.24checkout refuses the first migration that carries one, so finish rolling out0.25before anyone runsferro migrate new. - Inherited rows. A database that inherited a squash holds rows with
origin = inherited. A0.24runner fails every verb on them withthe tracking table holds a row ferro cannot read (origin). A squash is made before a branch merges, so only development databases meet this.
Deploying migrations lists the three ways back from the second. Reference: Squashing a feature branch's migrations.
Upgrading to 0.24¶
0.24 rewrites the output of ferro migrate. Every verb but init now opens with one line naming the database it works on (default · sqlite:///./app.db), a terminal gets a headed layout with colour, and a pipe or a CI log gets a plain form with ASCII markers. A script that reads the output needs a look in three places:
- The first line is new. Skip it, or read the exit code instead of the text.
- The one-line results scripts grep for (
ok: models match,no drift against,nothing to apply), every refusal sentence and everywarning:line are unchanged. - A connection error masks the password as
***; it used to print<redacted>.
rich now ships in the cli extra, so pip install "ferro-orm[cli]" brings it. Reference: What the output looks like.
Upgrading to 0.23¶
0.23 makes a server default that no model declares count as drift, and makes the migrations door tighter about concurrent deploys. Two things ask something of you:
-
A hand-set server default is reported on every start. Ferro keeps no server default of its own, so a column default someone added with raw SQL is now a foreign default. Under
migrate_updatesthe pass warns and leaves it; undermigrate_destructiveit drops it on Postgres.ferro migrate driftlists it andferro migrate baselinerefuses it. Say a hand-set default sits onseen_at:await ferro.connect("sqlite:dev.db", migrate_updates=True) report = await ferro.migrate() [warning] = report.warnings assert "account.seen_at has default CURRENT_TIMESTAMP" in str(warning)The pass reports one warning and changes nothing. To silence it, drop the default yourself (
ALTER TABLE "account" ALTER COLUMN "seen_at" DROP DEFAULTon Postgres), or run the pass withmigrate_destructive=True. On SQLite only a table rebuild drops a default, so the warning names the hand-written step that does it. -
A start-up
baseline()can branch on a type. Two services that baseline one database at the same moment used to get a plainMigrationRefusedfrom the loser. The loser now getsAlreadyTrackedError, a subclass, soexcept MigrationRefusedstill works and you can catch the narrower type. See An app that migrates at start-up.
Also new, and opt-in: Field(retired=True) and retired enum labels, which let a column or a label leave in two releases, and allow_ahead="expand-only", which lets an older release start beside a newer schema. A database whose last migration ran on 0.22 has its steps classed on the first run of 0.23; Deploying migrations says how.
Automate this: copy this prompt to your coding agent
You are helping upgrade a Python codebase from Ferro ORM 0.22.x to 0.23.
Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-023
1. Server defaults: search the repo's SQL and migration files for
`SET DEFAULT` and `DEFAULT` clauses on columns no Ferro model declares a
default for. List each as file:line. Do not drop any: I decide between
dropping the default and keeping it.
2. Start-up code that calls ferro.migrations.baseline(): if it catches
MigrationRefused and matches the message text to detect "already
adopted", change it to catch ferro.migrations.AlreadyTrackedError.
Upgrading to 0.22¶
0.22 adds Ferro's own migrations door: the ferro command, a project configuration file and numbered migration directories (see Schema management). Nothing about adopting it is forced, but four things change for code that stays on auto-migrate:
- A database that has run a migration refuses auto-migrate.
connect(auto_migrate=…),create_tables()andmigrate()raise before any DDL on a database that carries the_ferro_migrationstracking table, and the message names the two ways out. A database that never ran a migration, such as a test database, is unaffected. See One door per database. - Postgres passes wait under timeouts. Each DDL statement waits up to
ddl_lock_timeout(default5s) for its table lock and is retried up to ten times. A pass waits up tolock_timeout(default30s) for another pass or run that holds the run lock, then fails. Set both in the project configuration if your deploys hold locks longer. -
migrate()andcreate_tables()return a report. They returnedNone; they now return aPassReportof the statements the pass ran. Code that ignored the return value needs no change:import ferro report = await ferro.migrate() assert report.statements == () and report.warnings == ()The database already matched the models, so the pass ran nothing and warned of nothing.
-
A cancelled transaction rolls back. A
transaction()interrupted by task cancellation orKeyboardInterruptnow rolls back and releases its connection; before, the connection stayed pinned to the dead transaction:import asyncio from ferro import transaction async def work(): async with transaction(): await Account.create(name="Ghost", email="ghost@example.com", age=1) await asyncio.sleep(10) task = asyncio.ensure_future(work()) await asyncio.sleep(0.3) task.cancel() try: await task except asyncio.CancelledError: pass assert await Account.where(lambda account: account.name == "Ghost").count() == 0The cancelled task left no
Ghostrow behind.
The ferro command is in an extra: pip install "ferro-orm[cli]". Reference: Configuration and CLI.
Upgrading to 0.21¶
0.21 changes how NULL sorts and how a datetime, date, time, UUID or decimal in a query matches what save() stored. Both change results without an error.
NULL ordering¶
An order_by() with no nulls= now puts NULL last on every database. It followed each database's own default before: Postgres sorted NULL first under DESC, and SQLite sorted it first under ASC. Name the placement you want, or ask for the database's own:
Grace Hopper has never been seen, so her seen_at is NULL and she sorts first, ahead of the other three in date order. nulls="native" keeps each database's own behaviour, which differs between Postgres and SQLite for "desc", so a query that must return the same pages on both should name "first" or "last".
Query literals¶
A datetime, date, time, UUID or Decimal in where(), after() or update() is now sent in the form save() writes. On SQLite a datetime is stored as text, and the old literal (2026-03-01T15:00:00+00:00) never equalled the stored one (2026-03-01T15:00:00Z), so an equality or a keyset page could miss its row. Pass the value itself, and drop any hand-formatted string you used to dodge the miss:
seen = datetime(2026, 3, 1, 15, 0, tzinfo=UTC)
by_value = await Account.where(lambda account: account.seen_at == seen).all()
assert [account.name for account in by_value] == ["Ada Lovelace"]
by_text = await Account.where(lambda account: account.seen_at == seen.isoformat()).all()
assert by_text == []
The value matches Ada's row. A string from isoformat() still misses, because the query sends a string as it is and the stored text ends in Z.
Automate this: copy this prompt to your coding agent
You are helping upgrade a Python codebase from Ferro ORM 0.20.x to 0.21.
Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-021
1. Find every order_by() over a nullable column that omits `nulls=`. List
each as file:line with the column, and ask me whether NULL should sort
first, last, or follow the database ("native"). Do not guess.
2. Find every where()/after()/update() that passes a datetime, date, time,
UUID or Decimal, or a string built from one by isoformat()/str(). Pass the
value itself. Query lambdas name their parameter after the model in
lowercase singular: `lambda account: account.seen_at == seen`.
Upgrading to 0.18¶
0.18 makes a grown enum reach an existing Postgres database. Under migrate_updates=True, the pass appends each label the model declares and the live type lacks (ALTER TYPE … ADD VALUE), and warns of each live label the model no longer declares. Under plain auto_migrate=True an existing enum type is never changed, so a new member passes every test that builds a fresh schema and fails in production with invalid input value for enum. If your enums evolve, connect with migrate_updates=True or generate the change as a migration. SQLite stores enums as text and needs nothing. Reference: Evolving enums.
Upgrading to 0.17.1¶
0.17.1 makes the create pass leave existing tables alone: a plain auto_migrate=True no longer adds an index to a table that already exists. Add it under migrate_updates=True, or in a migration. The same release makes migrate_updates read live foreign keys, so a changed on_delete is now acted on: Postgres rebuilds the constraint, and SQLite warns, since it cannot alter one in place. Reference: Applying column changes with migrate_updates.
Upgrading to 0.16.3¶
0.16.3 refuses a model that declares more than one primary_key=True field. The model used to save by its first key and raise when you traversed a relation; it now raises a TypeError naming the fields when the class is defined:
try:
class Account(Model):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(primary_key=True) # a second primary key
except TypeError as error:
assert "declares multiple primary-key columns" in str(error)
else:
raise AssertionError("a model with two primary keys was accepted")
try:
class Account(Model):
id: Annotated[int | None, Field(primary_key=True)] = None
email: Annotated[str, Field(primary_key=True)] # a second primary key
except TypeError as error:
assert "declares multiple primary-key columns" in str(error)
else:
raise AssertionError("a model with two primary keys was accepted")
The class is refused at definition. Keep one primary key and let the other column be unique, as Account does:
If the pair of columns together must be unique, declare a composite unique. Reference: Models and fields.
Releases that need no action¶
These releases from 0.13 onward change nothing you have to do. Releases with a section above or below ask something of you.
| Release | What it adds | Action |
|---|---|---|
0.21.2 |
Alembic revisions create enum types a revision only adds a column of | None |
0.21.1 |
Alembic downgrades drop the enum types their create_table made |
None |
0.20.0 |
order_by(..., nulls=) |
None; the default moved in 0.21 |
0.19.0 |
Named table checks (__ferro_checks__); Postgres 18's RESTRICT error is a ForeignKeyViolationError |
None |
0.17.0 |
.exists() on a reverse foreign key and ~ negation of any predicate |
None |
0.16.2 |
A self-referential foreign key no longer breaks CREATE TABLE ordering |
None |
0.16.1 |
bulk_create() splits batches under the backend's bind-parameter limit |
None |
0.16.0 |
Aggregates and grouped aggregates | None |
0.13.0 |
Ferro warns and skips, rather than reinterpreting, a Postgres column whose timestamp or timestamptz storage differs from the model |
None; the 0.14 schema defaults build on it |
Upgrading to 0.15¶
0.15 adds JSONB support and makes JSONB the default storage for derived json-family fields (dict, list or a nested model with no db_type) on PostgreSQL. Your model definitions do not change; your PostgreSQL columns might. SQLite is unaffected, because both JSON tokens store the same there.
On the first connect with migrate_updates=True, each existing plain-json column with a derived declaration is rewritten in place (ALTER … TYPE jsonb USING …). The values survive, but jsonb does not preserve the order of a dict's keys. If your code iterates a field's keys in insertion order, or compares stored JSON text byte for byte, keep plain json before you upgrade:
The column stays plain json, and 0.15 writes no migration for it. db_type="jsonb" stays valid and states the default. Alembic autogenerate proposes the same type changes; review the migration instead. Reference: JSON storage.
Automate this: copy this prompt to your coding agent
You are helping upgrade a Python codebase from Ferro ORM 0.14.x to 0.15.
Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-015
The one breaking change: derived dict/list/nested-model fields now store as
JSONB on PostgreSQL (previously plain json). On the first connect with
migrate_updates=True, each existing plain-json column with a derived
declaration is rewritten in place with ALTER ... TYPE jsonb USING ... —
values survive, but jsonb does not preserve dict key order.
For each model field whose annotation is dict/list/a nested Pydantic model
and which has NO explicit db_type:
1. If the code iterates that field's keys relying on insertion order, or
compares stored JSON text byte-for-byte, add db_type="json" to keep
plain-json storage (zero migration).
2. Otherwise leave it — the one-time ALTER upgrades it to JSONB.
List every affected field as file:line with your keep/upgrade call and let
me review before connecting to production with migrate_updates=True.
Alembic users: autogenerate will propose the same type changes; review the
migration instead.
Upgrading to 0.14¶
0.14 makes the mutation surface strict, so no write silently does more or different work than you asked. It routes every database failure through a typed exception hierarchy, derives column types and names in one place for the runtime emitter and the Alembic bridge, and removes the operator-style predicates, the col() bridge and implicit connection routing that 0.12 deprecated. Your model definitions do not change.
Automate this: copy this prompt to your coding agent
You are helping upgrade a Python codebase from Ferro ORM 0.13.x to 0.14.
Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-014
Work in two passes. Do NOT open a PR until I have reviewed Pass 2.
PASS 1 — mechanical replacements (safe to apply directly):
1. Query predicates: replace operator-style `where(Model.field == value)` and any
`col(...)` bridges with lambda predicates named after the model in lowercase
singular, e.g. `where(lambda account: account.field == value)`. `order_by`
takes a lambda or a validated column-name string. Operator style and
`col()` were removed in 0.14.
2. Connection routing: any ORM or raw call that ran outside a session
(`Account.all()`, `ferro.execute(...)` with no active session) must run
inside `async with ferro.engines.session("name"):`. Unsessioned calls now
raise.
3. Exceptions: replace `except RuntimeError` / `except ConnectionError` handlers
around Ferro calls with the typed hierarchy from `ferro` (e.g.
`UniqueViolationError`, `OperationalError`, `InterfaceError`). Match on type,
not on driver-message text.
PASS 2 — semantic changes (DO NOT rewrite; list each site as file:line and ask me):
4. `Model.create(...)` and `instance.save()` no longer upsert. For each call site,
tell me whether the code RELIED on the old overwrite-on-conflict behavior. If it
did, I decide between `Model.upsert(...)` / `save(on_conflict="update")` and
catching `UniqueViolationError`. Do not guess.
5. `.limit()` / `.offset()` chained into `.update()` / `.delete()` now raise. List
each site — bounding a mutation requires fetching primary keys first, and only I
know whether the bound was intentional.
6. Schema-emission defaults changed for derived types (plain `datetime` ->
timestamptz, Enum -> native enum, `datetime.time` -> time). Auto-migrate refuses
to rewrite existing columns and warns. For each affected model, list it and ask
me to choose keep (`db_type=...`) vs a reviewed migration.
VERIFY after Pass 1:
- Run the test suite.
- Run `pytest -W error::DeprecationWarning`. This catches the deprecation-based
changes ONLY — items 4, 5, and 6 are silent and will not appear here.
- Grep helper: grep -rn "\.limit(\|\.offset(" --include="*.py" src/ | grep "delete()\|update("
Report a summary: what you changed in Pass 1, and the Pass 2 sites awaiting my decision.
Operator predicates and col()¶
where() accepts only lambda predicates. Operator-style predicates (Account.age >= 18) and the col() bridge, both deprecated in 0.12, are removed. Class attributes are no longer replaced with a field proxy, so Account.age at class scope raises AttributeError under normal Pydantic semantics. order_by() takes a lambda (order_by(lambda account: account.seen_at, "desc")) or a validated column-name string (order_by("seen_at", "desc")):
adults = await Account.where(lambda account: account.age >= 40).order_by("name").all()
assert [account.name for account in adults] == ["Alan Turing", "Grace Hopper"]
admins = await Account.where(lambda account: account.role == Role.ADMIN).all()
assert [account.name for account in admins] == ["Ada Lovelace"]
Both queries return the same rows they always did; only the predicate spelling changed. Reference: Typed query predicates.
Session routing¶
Unqualified operations (Account.all(), ferro.execute(...)) used to fall back to an implicit default connection. That fallback is removed. Wrap request- or task-scoped work in a session, so routing, the identity map and transactions are explicit and isolated under concurrency. A call with no active session and no using= raises RuntimeError; an explicit using= that names a different connection than the ambient session raises ValueError:
The session routes both calls to its connection, and the query returns the three active accounts. Reference: Identity map and Sessions.
create() on a primary-key conflict¶
create() used to compile to INSERT … ON CONFLICT (pk) DO UPDATE, so a duplicate primary key silently replaced the existing row. It is a plain INSERT now: a duplicate raises UniqueViolationError and the stored row stays as it was:
from ferro import UniqueViolationError
try:
await Account.create(id=1, name="Ada", email="ada@example.com", age=1)
except UniqueViolationError:
ada = await Account.where(lambda account: account.id == 1).first()
assert ada.age == 36 # the stored row is untouched
else:
raise AssertionError("create() overwrote the stored row")
upsert() replaces the row; create() raises and leaves Ada at 36. Reference: Mutations.
save() and upserts¶
save() now tracks whether the instance has been persisted: a new instance is inserted, and a fetched or previously saved instance is updated by primary key. Two consequences for code written against the old behaviour:
- Saving a new instance whose primary key is taken raises
UniqueViolationErrorinstead of overwriting the stored row. - Saving a persisted instance whose row was deleted underneath raises
ModelDoesNotExistinstead of silently re-inserting it.
Where you used save() on purpose to insert or update, say so:
The save updated Grace's row. The conflict target is the primary key only, and the whole row is written on update; see Upsert.
limit() and offset() on mutations¶
Portable SQL has no DELETE … LIMIT. The pagination used to be silently dropped, so the mutation touched every matching row. update() and delete() now raise ValueError when the query carries limit() or offset(). To mutate a bounded subset, fetch primary keys first:
try:
await Account.where(lambda account: account.active == True).limit(2).delete()
except ValueError:
pass # the bounded delete is refused
else:
raise AssertionError("delete() accepted limit()")
batch = await Account.where(lambda account: account.active == True).order_by("id").limit(2).all()
ids = [account.id for account in batch]
await Account.where(lambda account: account.id.in_(ids)).delete()
remaining = await Account.select().order_by("id").all()
assert [account.name for account in remaining] == ["Alan Turing", "Linus Torvalds"]
The delete removed Ada and Grace, the first two active accounts, and no others. Reference: Mutations.
Typed exceptions¶
Database failures raise a hierarchy rooted at ferro.FerroError instead of bare RuntimeError or ConnectionError. Broad handlers keep working during the move (FerroError is still an Exception), but match on the type:
The handler runs on the type, with the driver's text kept as driver_message. connect() failures are ferro.OperationalError (server or environment) or ferro.InterfaceError (bad scheme or configuration) instead of ConnectionError. Reference: Exceptions.
Schema-emission defaults¶
0.14 also derives column types and artifact names in one place for the runtime emitter and the Alembic bridge. Three storage defaults change for derived types (fields with no explicit db_type). Auto-migrate never rewrites an existing column silently: where an older emitter built your database, Ferro warns and skips, and you choose between keeping the old storage and converting with a reviewed migration (a hand-written step in ferro migrate, or an Alembic revision).
Plain datetime fields are timezone-aware¶
The Alembic bridge used to map a plain datetime field to a naive timestamp; the runtime already used timestamptz. Both now emit timestamptz. If your schema has naive timestamp columns, auto-migrate refuses the conversion and warns. Keep the column naive by declaring it:
The column stays timestamp and the warning goes away. To convert it instead, run this in a reviewed migration, with your source time zone in place of UTC:
The statement reads each stored value as UTC and rewrites the column as timestamptz.
Enum fields are native Postgres enums¶
The runtime emitter used to store an Enum field as varchar; the Alembic bridge already emitted a native enum type. Both now emit the native type, created idempotently (on SQLite the column is varchar(<longest label>)). If your schema has varchar enum columns, auto-migrate refuses the conversion and warns. Keep the text storage, with its check, by declaring it:
The column stays text, and db_check=True keeps its CHECK over the labels. To convert it, run this in a reviewed migration:
CREATE TYPE "role" AS ENUM ('admin', 'member');
ALTER TABLE "account"
ALTER COLUMN "role" TYPE "role" USING "role"::"role";
The statement creates the enum type and casts each stored label into it.
datetime.time fields store as time¶
The runtime emitter used to store a datetime.time field as varchar (the bridge already said TIME). Both now emit time. A time annotation accepts no text db_type, so there is no declaration that keeps the old column: auto-migrate refuses the conversion and warns, and you convert with a reviewed migration that casts explicitly, as in ALTER COLUMN "starts" TYPE time USING "starts"::time.
Constraint and index names¶
- Foreign keys are named (
fk_<table>_<col>_<to_table>) in the DDL of both emitters. Existing databases need no action: Alembic autogenerate compares foreign keys by signature, not name. -
Single-column uniques become named unique indexes (
uq_<table>_<col>), the shape composite uniques always had. On an existing database auto-migrate adds the named index (CREATE UNIQUE INDEX IF NOT EXISTS, additive and idempotent); the old inline artifact (on Postgres,account_email_key) stays as a redundant duplicate until you drop it: -
Identifiers longer than 63 characters truncate the same way on both emitters. PostgreSQL used to truncate some of them silently.
- SQLite declared types use SQLAlchemy's spellings (
DATETIME,DATE,TIME,CHAR(32),JSON,NUMERIC), and primary-key columns carry an explicitNOT NULL. Existing SQLite databases need no action, because type affinity makes the storage identical; only new tables show the new spellings.
Changed surfaces¶
| Surface | 0.13 behaviour |
0.14 behaviour |
What you do |
|---|---|---|---|
Model.create(**fields) on an existing primary key |
Replaced the row | Raises UniqueViolationError |
Model.upsert(...) if you wanted replace |
instance.save() on a new instance with a taken primary key |
Replaced the row | Raises UniqueViolationError |
save(on_conflict="update") |
instance.save() after the row was deleted |
Re-inserted it | Raises ModelDoesNotExist |
Catch it and create() again if intended |
query.limit(...).update() or .delete() |
Ignored the pagination | Raises ValueError |
Fetch primary keys, mutate by that set |
| Database failures | RuntimeError with driver text |
Typed FerroError subclasses |
Catch by type |
Plain datetime field (Alembic bridge) |
Naive timestamp |
timestamptz |
Keep with db_type="timestamp", or convert with USING … AT TIME ZONE |
| Enum field (runtime emitter) | varchar |
Native Postgres enum | Keep with db_type="text", or convert with USING col::<enum> |
datetime.time field (runtime emitter) |
varchar |
time |
Convert with a reviewed USING cast |
| Foreign keys | Anonymous | Named fk_<table>_<col>_<to_table> |
Nothing; names are not compared |
| Single-column unique | Inline column UNIQUE |
Named uq_<table>_<col> unique index |
Optionally drop the old inline constraint |
Upgrading to 0.12¶
0.12 is the first release built on Ferro's IR-first architecture: query execution, schema and migration planning, codecs, hydration and connection routing all flow through one shared intermediate representation instead of several independent code paths. One source of truth removes whole classes of drift bugs, and gives predictable schema diffs, identical bind and null semantics across SQLite and PostgreSQL, and explicit session-scoped runtime state. Your model definitions do not change; a few APIs are called differently.
0.12 introduced these as deprecation warnings; the removals landed in 0.14, see Upgrading to 0.14.
Automate this: copy this prompt to your coding agent
You are helping upgrade a Python codebase from Ferro ORM 0.11.x to 0.12.
Reference: https://ferro-orm.x54.sh/upgrading/#upgrading-to-012
All 0.12 changes are deprecation-based and safe to apply mechanically. Apply them,
then verify.
1. Query predicates: replace operator-style `where(Model.field == value)` with
lambda predicates named after the model in lowercase singular:
`where(lambda account: account.field == value)`. (These were removed
outright in 0.14 — if you are upgrading past 0.12, prefer lambdas now.)
2. Connection routing: wrap request/task-scoped ORM and raw operations in
`async with ferro.engines.session("name"):` instead of relying on implicit
default-connection routing.
3. Alembic metadata: replace
`from ferro.migrations.alembic import _build_sa_table, _map_to_sa_type`
with `from ferro.migrations import get_metadata; target_metadata = get_metadata()`
in your Alembic env.py.
VERIFY:
- Run the test suite.
- Run `pytest -W error::DeprecationWarning`; a clean run means no deprecated
0.12-era surfaces remain on the paths you exercise.
Alembic metadata from get_metadata()¶
The private helpers ferro.migrations.alembic._build_sa_table and ferro.migrations.alembic._map_to_sa_type were removed in 0.14. Schema metadata comes from the public get_metadata() entry point; use it in your Alembic env.py:
target_metadata is the SQLAlchemy metadata Alembic compares against. Reference: Alembic.
Deprecations of 0.12¶
Deprecated in 0.12 |
Replacement | Status |
|---|---|---|
Model.where(Model.field OP value) and col() |
where(lambda account: …) |
Removed in 0.14, see Operator predicates |
| Unqualified operations outside an active session | async with ferro.engines.session("name") |
Removed in 0.14, see Session routing |
ferro.migrations.alembic._build_sa_table |
ferro.migrations.get_metadata() |
Removed in 0.14, see Alembic metadata |
ferro.migrations.alembic._map_to_sa_type |
ferro.migrations.get_metadata() |
Removed in 0.14, see Alembic metadata |