# Squashing a feature branch's migrations

`ferro migrate squash` replaces a range of migrations, from the one you name through the newest, with the one migration `new` would have written across them. A feature branch collects one migration per task. Main stands at `0001_create_author`, the [Migrations](<https://ferro-orm.x54.sh/guide/schema/migrations/index.md>) loop's `Author`, and the teams branch has added four on top of it:

- `0002_add_teams` creates `Team`, with a `tmp_code` column one task needed for a while;
- `0003_team_slug` adds a required `slug`, with the backfill `02_backfill_team.py` written by hand;
- `0004_drop_tmp` drops `tmp_code` again;
- `0005_fix_rows` is a hand-written SQL step, `fix_rows`, that trims team names.

Four migrations, three of them `ALTER TABLE`s on a table the first one created, are the history of the work, not the change main needs. Before the branch's pull request to main, replace them with one. `ferro migrate squash teams --from 0002` writes:

```text
default · migrations
ok Wrote migrations/0002_teams/  (replaces 0002_add_teams … 0005_fix_rows, now deleted)
  01_schema.up.postgres.sql    schema step, up (postgres)    regenerated
  01_schema.down.postgres.sql  schema step, down (postgres)  regenerated
  01_schema.up.sqlite.sql      schema step, up (sqlite)      regenerated
  01_schema.down.sqlite.sql    schema step, down (sqlite)    regenerated
  02_fix_rows.up.sql           SQL step, up                  carried, review it (was 0005_fix_rows/01_fix_rows.up.sql)
  02_fix_rows.down.sql         SQL step, down                carried, review it (was 0005_fix_rows/01_fix_rows.down.sql)
  03_backfill_team.py          data step                     carried, review it (was 0003_team_slug/02_backfill_team.py)
  ir.json                      snapshot                      0005_fix_rows's, chained from 0001_create_author, replaces 0002_add_teams … 0005_fix_rows

  1 new model
    Team
  - team.tmp_code existed only inside the range (0002_add_teams through 0004_drop_tmp): review the carried steps for it
```

`0002_teams` creates `team` with its `slug` in one `CREATE TABLE`, and `tmp_code` never appears. The four directories are gone.

## What is regenerated

The DDL, every file with a dialect suffix, is planned once, from `0001_create_author`'s snapshot to `0005_fix_rows`'s, by the planner `new` uses. A squash writes exactly the files `new` would write across the range.

A squash reads those snapshots and the migrations directory. It never imports the models, so a model change no migration records is still `new`'s to write, and it never opens a database (`--url` is refused). Its `ir.json` is the last snapshot under a new parent, and it records which migrations it replaced: the squash's *witness*.

## What is carried

Every data step and hand-written SQL step of the range keeps its body byte for byte, in chain order, placed as `--replace` places it (a SQL step before the data steps, a data step before the contract). Each is marked `carried, review it` with the file it was.

Read each one. Here `fix_rows` now runs before the backfill it used to follow, and a step written after one task's contract now runs before the one contract.

A backfill still byte for byte as the generator scaffolded it, `todo(...)` and all, that the squashed plan does not scaffold again is dropped with a note. Had `02_backfill_team.py` been left unwritten, the note would read `02_backfill_team.py from 0003_team_slug was the generator's unwritten scaffold and the squashed plan needs no backfill of team.slug: dropped`, since a `slug` on a table the squash creates needs no backfill. A step with any hand-written byte is always carried, and a carried unwritten step still refuses to load until it is written.

## Names that existed only inside the range

A table, column, enum label or constraint the range added and then removed (`team.tmp_code` here) does not exist in the squashed migration, so a carried step that touches it now fails. Where a carried step's declaration proves it, the squash refuses and writes nothing: a `@chunked` step whose query names the dead name in its model, order keys or filter columns or labels. Had `02_backfill_team.py` selected its rows by `tmp_code` (`lambda team: team.tmp_code == None`), the squash would say:

```text
default · migrations
ferro migrate squash: 0003_team_slug/02_backfill_team.py names team.tmp_code, which existed only inside the range (added by 0002_add_teams, removed by 0004_drop_tmp): delete the step, or start the range at 0004_drop_tmp, the migration that removed it (pass --from 0004_drop_tmp). Nothing was written.
```

Nothing was written, and the refusal names both ways on. An `@atomic` step declares nothing the squash can read, and a SQL step is SQL, so neither is ever refused: each dead name gets the note in the first output instead, and reading the carried steps for it is yours.

## The range

- The range is always a tail, `--from` through the newest migration, because the migration after a middle range would chain from a snapshot that no longer exists.
- The squash takes the first migration's number and the name you give it.
- A name that would rewrite a replaced migration's own directory (`ferro migrate squash add_teams --from 0002` writing `0002_add_teams`) is refused, since every reader would then take the squash for the original.
- A range that adds nothing and carries nothing is refused: delete the directories.
- A migration that shares the first number of the range, because two branches each generated it, is refused too: resolve the shared number with [`--replace`](<https://ferro-orm.x54.sh/guide/schema/migrations/#duplicate-migration-numbers-and-replace>) before squashing.

The [CLI reference](<https://ferro-orm.x54.sh/reference/cli/#ferro-migrate-squash-name-from-target>) lists every flag and refusal, and what to do with the hidden directory a squash killed halfway leaves.

## Renames inside the range

A column renamed in one task, whose hint a later task deleted, is still a rename after the squash. Say the branch's first task renamed `nickname` to `handle` and generated `0002_rename_handle`:

```python
from ferro import Field, Model


class Author(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    email: str = Field(unique=True)
    # before: nickname: str | None = Field(default=None, index=True)
    handle: str | None = Field(default=None, index=True, renamed_from="nickname")
```

```python
from typing import Annotated

from ferro import Field, Model


class Author(Model):
    id: Annotated[int | None, Field(primary_key=True)] = None
    name: str
    email: Annotated[str, Field(unique=True)]
    # before: nickname: Annotated[str | None, Field(index=True)] = None
    handle: Annotated[str | None, Field(index=True, renamed_from="nickname")] = None
```

Its migration generated, the hint is inert, so the second task deleted it and added `Team` (`0003_add_teams`):

```python
from ferro import Field, Model


class Author(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    email: str = Field(unique=True)
    handle: str | None = Field(default=None, index=True)


class Team(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    slug: str
```

```python
from typing import Annotated

from ferro import Field, Model


class Author(Model):
    id: Annotated[int | None, Field(primary_key=True)] = None
    name: str
    email: Annotated[str, Field(unique=True)]
    handle: Annotated[str | None, Field(index=True)] = None


class Team(Model):
    id: Annotated[int | None, Field(primary_key=True)] = None
    name: str
    slug: str
```

Planned from `0001`'s snapshot to `0003`'s models alone, `nickname` would be dropped and `handle` added, and every author's nickname lost. The squash composes the hints of every snapshot in the range into its own, so `ferro migrate squash teams --from 0002` writes one rename:

```sql
-- 0002_teams/01_schema.up.postgres.sql
ALTER TABLE "author" RENAME COLUMN "nickname" TO "handle";

ALTER INDEX "idx_author_nickname" RENAME TO "idx_author_handle";

CREATE TABLE IF NOT EXISTS "team" ( "id" serial PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "slug" varchar NOT NULL );
```

The column and its index are renamed, and every author keeps a nickname, now under `handle`. Renames compose:

- `a→b` then `b→c` is `a→c`.
- A renamed column the range later drops is a drop of the old name.
- A column added and then renamed is an add of the new name.
- A rename whose old name a later task adds again cannot be one migration. It is refused, naming the `--from` that starts the range at the task that adds it again, which leaves the rename below the squash.

The example `docs/examples/migrations_squash.py` in the repository runs this end to end, inheritance and revert included.

## Inheritance on databases that ran the originals

Your development database applied `0002_add_teams` through `0005_fix_rows`. After you pull the squash, `ferro migrate status` shows the one migration it has no records for, and what `up` will do with it:

```text
default · sqlite:///./app.db
-- 1 pending  main._ferro_migrations
  ok 0001_create_author  applied  2026-10-15 04:31Z
  -- 0002_teams          pending (inheritable)  run `ferro migrate up`; this database applied 0002_add_teams … 0005_fix_rows and nothing will be run
```

`0002_teams` is pending and *inheritable*. `ferro migrate up` runs nothing:

```text
default · sqlite:///./app.db
inherited 0002_teams: this database had applied 0002_add_teams … 0005_fix_rows; nothing was run
```

That is *inheritance*. The run reads the tracking table and finds a finished record for every step of every migration the squash's witness names, each under the snapshot the witness names. It swaps those records for the squash's own, in one transaction, and `status` shows `0002_teams` as `applied (inherited)`.

- There is no flag and no prompt: the committed squash is the consent.
- Nothing checks the live schema (`ferro migrate drift` is the verb for doubting it). The records already say the database stands at the snapshot the squash ends at, and its rows are kept.
- `down` inherits the same way before it reverts, and an inherited step's down runs as a run's does.

Until that `up`, the squashed migration is pending. `require_applied()` refuses to start with `PendingMigrationsError`, whatever `allow_ahead` says, because a migration the database has no record of is pending, never ahead. Each developer's database, and the branch's integration database, meets the squash once and runs `ferro migrate up`.

A database that applied only part of the range (`0002` through `0004`, not `0005_fix_rows`) cannot be inherited: it does not stand at the squash's end. `up` and `down` refuse it, and `status` reports the same refusal under `needs attention`, naming both ways out:

```text
default · sqlite:///./app.db
ferro migrate: 0002_teams replaces migrations this database applied only part of: it applied 0002_add_teams, 0003_team_slug and 0004_drop_tmp but not 0005_fix_rows, so it cannot inherit 0002_teams. To keep its rows, check out the commit that holds 0005_fix_rows, run `ferro migrate up` there to finish it, then run `ferro migrate up` here, which inherits 0002_teams. Or revert instead: on the commit that applied them run `ferro migrate down --to 0001`, then `ferro migrate up` here; the revert drops what they created and fails on an irreversible step. Nothing was changed.
```

Take the first if you can: finishing the range keeps every row. The second drops the branch's tables and their rows, and stops at an irreversible step.

## Squash timing

A squash rewrites the history of the branch's own migrations, the way a rebase rewrites its commits. Do it once, before the pull request to main, not each time a task lands on the integration branch. The databases that applied the originals are your team's development databases, and each inherits the squash once. A production database never meets a squash, because the migrations it runs are the ones main already holds.

> [!NOTE]
>
> **Older ferro releases**
>
> A ferro release from before `squash` cannot read a database that inherited a squash. Read [squashes and older releases](<https://ferro-orm.x54.sh/guide/schema/deploying/#squashes-and-older-releases>) before you roll one out beside them.

## `squash` vs `--replace`

Both regenerate migrations and carry their hand-written steps; they answer different situations:

|  | `ferro migrate squash NAME --from …` | `ferro migrate new NAME --replace …` |
| :--- | :--- | :--- |
| Plans | from the snapshot below the range to the range's last snapshot | from the new parent's snapshot to the models |
| Replaces | a tail of migrations, `--from` through the newest | the newest migration |
| A database that applied the originals | inherits the squash, running nothing | reverts with the files that went up, then runs the new migration ([duplicate migration numbers](<https://ferro-orm.x54.sh/guide/schema/migrations/#duplicate-migration-numbers-and-replace>)) |

A squash keeps what the range did and shortens it; `--replace` puts your migration back on top of someone else's. The [CLI reference](<https://ferro-orm.x54.sh/reference/cli/#ferro-migrate-squash-name-from-target>) lists every flag of both.

## See also

- [Migrations](<https://ferro-orm.x54.sh/guide/schema/migrations/index.md>): the loop, and `--replace` for two branches that share a number
- [Data steps and backfills](<https://ferro-orm.x54.sh/guide/schema/data-steps/index.md>): the steps a squash carries
- [CLI reference](<https://ferro-orm.x54.sh/reference/cli/#ferro-migrate-squash-name-from-target>): every `squash` flag and refusal
