# Upgrading between versions

This guide lists what upgrading Ferro asks of your code, release by release from `0.11` to `0.26`; the [changelog](<https://ferro-orm.x54.sh/changelog/index.md>) is the exhaustive record. After an upgrade, a query reads like this, inside a session:

```python
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:

```python
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)
```

```python
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](<https://ferro-orm.x54.sh/guide/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](<#upgrading-to-026>) |
| `0.24.x` | `0.26` | [Upgrading to 0.26](<#upgrading-to-026>) and [Upgrading to 0.25](<#upgrading-to-025>) |
| `0.15.x` | `0.26` | Every `0.16` to `0.26` section, and the [no-action table](<#releases-that-need-no-action>) |
| `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](<#upgrading-to-012>) 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:

```bash
uv run pytest -W error::DeprecationWarning
```

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.query` is now typed: it takes `type[M]` and returns `Query[M]`, so a type checker may flag a call that passes a class that is not a `Model`.
- The undocumented module `ferro.migrations.chunked` is now the private `ferro.migrations._chunk_loop`. `from ferro.migrations import chunked` still imports the [`chunked`](<https://ferro-orm.x54.sh/api/migrations/#ferro.migrations.chunked>) decorator, unchanged.
- The `RuntimeError` an 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=...)` and `bulk_create()` list their columns in the model's field order. Before, a `bytes` column came last, a fetched record's `save()` followed the order its `SELECT` returned, and `save(only=...)` or `save(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](<https://ferro-orm.x54.sh/guide/connections/#logging>) or `.sql()`) needs its expected text updated. `update()` is unchanged: it lists the columns in the order the call passes them, a `bytes` column included.
- A `SecretStr` or `SecretBytes` field now stores its real value, and a fetched record loads it back as a `SecretStr` or `SecretBytes`. `0.25` wrote the mask `**********` in its place on `save()`, `bulk_create()` and `update()`, and loaded the `str` `**********` back. A row `0.25` saved holds the mask, and the real value cannot be recovered from it: write the value again from its source. A `SecretBytes` column is now binary (`BYTEA` on Postgres, `BLOB` on SQLite); `0.25` created it as `varchar`. Change such a column to the binary type with a migration (`ferro migrate new` writes it) before `0.26` saves to it: on Postgres a `SecretBytes` value saved into the `varchar` column is stored as its hex text (`\xff006b` for `b"\xff\x00k"`) and loads back as those characters.

## Upgrading to 0.25

`0.25` adds [`ferro migrate squash`](<https://ferro-orm.x54.sh/reference/cli/#ferro-migrate-squash-name-from-target>), 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.25` writes uses a newer snapshot format. A `0.24` checkout refuses the first migration that carries one, so finish rolling out `0.25` before anyone runs `ferro migrate new`.
- **Inherited rows.** A database that inherited a squash holds rows with `origin = inherited`. A `0.24` runner fails every verb on them with `the 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](<https://ferro-orm.x54.sh/guide/schema/deploying/index.md>) lists the three ways back from the second. Reference: [Squashing a feature branch's migrations](<https://ferro-orm.x54.sh/guide/schema/squash/index.md>).

## 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 every `warning:` 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](<https://ferro-orm.x54.sh/reference/cli/#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_updates` the pass warns and leaves it; under `migrate_destructive` it drops it on Postgres. `ferro migrate drift` lists it and `ferro migrate baseline` refuses it. Say a hand-set default sits on `seen_at`:

  ```python
  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 DEFAULT` on Postgres), or run the pass with `migrate_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 plain `MigrationRefused` from the loser. The loser now gets `AlreadyTrackedError`, a subclass, so `except MigrationRefused` still works and you can catch the narrower type. See [An app that migrates at start-up](<https://ferro-orm.x54.sh/guide/schema/adopting-migrations/#an-app-that-migrates-at-start-up>).

Also new, and opt-in: [`Field(retired=True)`](<https://ferro-orm.x54.sh/guide/schema/renames-and-retirements/#retiring-a-column>) and retired enum labels, which let a column or a label leave in two releases, and [`allow_ahead="expand-only"`](<https://ferro-orm.x54.sh/guide/schema/deploying/index.md>), 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](<https://ferro-orm.x54.sh/guide/schema/deploying/index.md>) says how.

> [!NOTE]
>
> **Automate this: copy this prompt to your coding agent**
>
> ```text
> 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](<https://ferro-orm.x54.sh/guide/schema/overview/index.md>)). 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()` and `migrate()` raise before any DDL on a database that carries the `_ferro_migrations` tracking 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](<https://ferro-orm.x54.sh/guide/schema/overview/#one-door-per-database>).
- **Postgres passes wait under timeouts.** Each DDL statement waits up to `ddl_lock_timeout` (default `5s`) for its table lock and is retried up to ten times. A pass waits up to `lock_timeout` (default `30s`) for another pass or run that holds the run lock, then fails. Set both in the [project configuration](<https://ferro-orm.x54.sh/reference/configuration/#ddl_lock_timeout>) if your deploys hold locks longer.
- **`migrate()` and `create_tables()` return a report.** They returned `None`; they now return a `PassReport` of the statements the pass ran. Code that ignored the return value needs no change:

  ```python
  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 or `KeyboardInterrupt` now rolls back and releases its connection; before, the connection stayed pinned to the dead transaction:

  ```python
  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() == 0
  ```

  The cancelled task left no `Ghost` row behind.

The `ferro` command is in an extra: `pip install "ferro-orm[cli]"`. Reference: [Configuration](<https://ferro-orm.x54.sh/reference/configuration/index.md>) and [CLI](<https://ferro-orm.x54.sh/reference/cli/index.md>).

## 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:

```python
accounts = await Account.select().order_by(lambda account: account.seen_at).all()
```

```python
accounts = await Account.select().order_by(lambda account: account.seen_at, nulls="first").all()
assert [account.name for account in accounts] == [
    "Grace Hopper",
    "Alan Turing",
    "Ada Lovelace",
    "Linus Torvalds",
]
```

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:

```python
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`.

> [!NOTE]
>
> **Automate this: copy this prompt to your coding agent**
>
> ```text
> 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](<https://ferro-orm.x54.sh/guide/schema/auto-migrate/#evolving-enums-label-addition>).

## 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`](<https://ferro-orm.x54.sh/guide/schema/auto-migrate/#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:

```python
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")
```

```python
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:

```python
id: int | None = Field(default=None, primary_key=True)
email: str = Field(unique=True)
```

```python
id: Annotated[int | None, Field(primary_key=True)] = None
email: Annotated[str, Field(unique=True)]
```

If the pair of columns together must be unique, declare a [composite unique](<https://ferro-orm.x54.sh/guide/models-and-fields/#composite-uniques>). Reference: [Models and fields](<https://ferro-orm.x54.sh/guide/models-and-fields/index.md>).

## 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](<#schema-emission-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:

```python
prefs: dict = Field(default_factory=dict, db_type="json")
```

```python
prefs: Annotated[dict, Field(db_type="json")] = Field(default_factory=dict)
```

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](<https://ferro-orm.x54.sh/guide/models-and-fields/#json-storage-json-and-jsonb>).

> [!NOTE]
>
> **Automate this: copy this prompt to your coding agent**
>
> ```text
> 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.

> [!NOTE]
>
> **Automate this: copy this prompt to your coding agent**
>
> ```text
> 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")`):

```python
adults = await Account.where(Account.age >= 40).all()
admins = await Account.where(Account.role == "admin").all()
```

```python
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](<https://ferro-orm.x54.sh/concepts/query-typing/index.md>).

### 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`:

```python
import ferro

accounts = await Account.where(lambda account: account.active == True).all()
await ferro.execute("UPDATE account SET active = 1")
```

```python
import ferro

async with ferro.engines.session():
    accounts = await Account.where(lambda account: account.active == True).all()
    await ferro.execute("UPDATE account SET active = 1")
    assert len(accounts) == 3
```

The session routes both calls to its connection, and the query returns the three active accounts. Reference: [Identity map](<https://ferro-orm.x54.sh/concepts/identity-map/index.md>) and [Sessions](<https://ferro-orm.x54.sh/guide/connections/#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`](<https://ferro-orm.x54.sh/api/exceptions/index.md>) and the stored row stays as it was:

```python
await Account.create(id=1, name="Ada", email="ada@example.com", age=1)
```

```python
account = await Account.upsert(id=1, name="Ada Lovelace", email="ada@example.com", age=37)
assert account.age == 37
```

```python
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](<https://ferro-orm.x54.sh/guide/mutations/index.md>).

### `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 `UniqueViolationError` instead of overwriting the stored row.
- Saving a *persisted* instance whose row was deleted underneath raises `ModelDoesNotExist` instead of silently re-inserting it.

Where you used `save()` on purpose to insert or update, say so:

```python
account = Account(id=2, name="Grace Hopper", email="grace@example.com", age=46)
await account.save()  # inserted or replaced, whichever applied
```

```python
account = Account(id=2, name="Grace Hopper", email="grace@example.com", age=46)
await account.save(on_conflict="update")
grace = await Account.where(lambda account: account.id == 2).first()
assert grace.age == 46
```

The save updated Grace's row. The conflict target is the primary key only, and the whole row is written on update; see [Upsert](<https://ferro-orm.x54.sh/guide/mutations/#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:

```python
await Account.where(lambda account: account.active == True).limit(2).delete()
```

```python
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](<https://ferro-orm.x54.sh/guide/mutations/index.md>).

### Typed exceptions

Database failures raise a hierarchy rooted at [`ferro.FerroError`](<https://ferro-orm.x54.sh/api/exceptions/index.md>) instead of bare `RuntimeError` or `ConnectionError`. Broad handlers keep working during the move (`FerroError` is still an `Exception`), but match on the type:

```python
try:
    await account.save()
except RuntimeError as error:
    if "unique" in str(error).lower():  # matching driver text
        ...
```

```python
from ferro import UniqueViolationError

try:
    await Account(id=1, name="Dup", email="dup@example.com", age=1).save()
except UniqueViolationError as error:
    assert error.driver_message  # error.sqlstate and error.constraint carry detail
else:
    raise AssertionError("a taken primary key was accepted")
```

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](<https://ferro-orm.x54.sh/api/exceptions/index.md>).

### 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:

```python
seen_at: datetime | None = Field(default=None, db_type="timestamp")
```

```python
seen_at: Annotated[datetime | None, Field(db_type="timestamp")] = None
```

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`:

```sql
ALTER TABLE "account"
    ALTER COLUMN "seen_at" TYPE timestamptz
    USING "seen_at" AT TIME ZONE '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:

```python
role: Role = Field(default=Role.MEMBER, db_type="text", db_check=True)
```

```python
role: Annotated[Role, Field(db_type="text", db_check=True)] = Role.MEMBER
```

The column stays text, and `db_check=True` keeps its `CHECK` over the labels. To convert it, run this in a reviewed migration:

```sql
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:

  ```sql
  ALTER TABLE "account" DROP CONSTRAINT account_email_key;
  ```
- **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 explicit `NOT 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](<#upgrading-to-014>).

> [!NOTE]
>
> **Automate this: copy this prompt to your coding agent**
>
> ```text
> 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`:

```python
from ferro.migrations.alembic import _build_sa_table, _map_to_sa_type
```

```python
from ferro.migrations import get_metadata

target_metadata = get_metadata()
assert target_metadata is not None
```

`target_metadata` is the SQLAlchemy metadata Alembic compares against. Reference: [Alembic](<https://ferro-orm.x54.sh/guide/schema/alembic/index.md>).

### 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](<#predicates-operator-style-and-col-are-gone>) |
| Unqualified operations outside an active session | `async with ferro.engines.session("name")` | Removed in `0.14`, see [Session routing](<#connection-routing-requires-a-session>) |
| `ferro.migrations.alembic._build_sa_table` | `ferro.migrations.get_metadata()` | Removed in `0.14`, see [Alembic metadata](<#alembic-metadata-from-get_metadata>) |
| `ferro.migrations.alembic._map_to_sa_type` | `ferro.migrations.get_metadata()` | Removed in `0.14`, see [Alembic metadata](<#alembic-metadata-from-get_metadata>) |
