besu push applies a schema snapshot directly, with no migration file and no history. It introspects the database, diffs it against the snapshot, and runs the difference, refusing anything that would lose data unless --destructive is given.

Usage: besu push [OPTIONS] --database <DATABASE> --schema <SCHEMA>

Options:
      --database <DATABASE>  The database to change, for example `postgres://user:pass@host/db` [env: DATABASE_URL=]
      --schema <SCHEMA>      The schema snapshot to apply
      --destructive          Also drop columns and tables that the snapshot no longer declares
      --dry-run              Print the planned changes, with their safety, instead of applying them
  -h, --help                 Print help

--database also reads DATABASE_URL.

Running it

besu push --database postgres://user:pass@host/db --schema schema.json
CREATE TABLE "users" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "email" TEXT NOT NULL);
applied 1 statement(s) to postgres

Every statement is printed after the run, followed by the summary. An up-to-date schema prints schema is up to date and exits zero.

The library equivalent is Database::push_schema, or Database::push for table definitions:

// From a snapshot.
let result = db.push_schema(&schema).await?;

// From the derive, refusing destructive changes.
let result = db.push([users.def(), posts.def()]).await?;

--dry-run

--dry-run connects, introspects and diffs, then prints one line per change instead of running anything. Each line is prefixed with its safety:

[safe] create table users
[risky] add column users.bio
[DESTRUCTIVE] drop column users.legacy

It is a real preview of the plan push would apply, using the same normalise_type folding, so a type the dialect cannot distinguish does not appear. It prints change descriptions with their safety, not SQL — besu generate is what writes statements.

Safety

push classifies every change and refuses the whole plan when any of them is Destructive:

SafetyExamples
Safecreate a table, add a nullable column, add an enum type
Riskyadd an index, add a constraint, change a column's type, append an enum value
Destructivedrop a column, table, index, unique, check or enum value

Risky changes run. They can be slow, or fail on existing rows, but they do not silently throw data away. A refusal lists every destructive change and stops before the first statement:

refusing to apply 1 change(s) that would lose data:
  - drop column users.legacy
call `push_destructive` if you really mean it

The CLI's equivalent is --destructive; the library calls it push_destructive or push_schema_destructive.

What it ignores

push manages tables, columns, indexes, uniques, foreign keys, checks, PostgreSQL enum types and — when the declaration names them — views and sequences. Everything else the IR cannot express is left exactly as it is:

  • row-level security policies, see row-level security;
  • extensions, see extensions;
  • triggers, expression indexes, INCLUDE indexes and exclusion constraints.

Views and sequences are opt-in, and this is the one asymmetry worth memorising. A declaration that names no views cannot distinguish "I did not mention views" from "I want them all gone", and the first is the common case — push(&[TableDef]) cannot express a view at all. So an empty list means "views are not mine", and an existing view is left alone. Name at least one view (or sequence) in the snapshot and the whole list is managed, including drops. This is the same shape as enums, which are only dropped when nothing declares them.

Without that rule, adding a view as a migration would make every later push(&[TableDef]) refuse the plan as destructive — which is exactly what happened before it was added.

Gotchas

  • push is not transactional. Statements run one at a time, so a failure halfway leaves the earlier ones applied.
  • There is no journal. push does not record what it did, so besu migrate afterwards has no idea the schema already moved.
  • A rename is a drop plus an add unless declared. Add #[besu(renamed_from = "old")] and the plan becomes one ALTER TABLE ... RENAME COLUMN; without it, push refuses.
  • SQLite rebuilds a table to change a column. Rows are copied across, but a trigger is not, because the IR does not model one.
  • Type widening can be invisible. SQLite folds integer widths; PostgreSQL folds unsigned integers into their signed storage. A change the dialect cannot see is not reported.
  • A check written by hand is not declared by the derive. Pushing table definitions against a table with a check reports a DropCheck, which is destructive, so push refuses.