DBDiff is an automated database schema and data diff tool for MySQL, Postgres & SQLite. It compares two databases, local or remote, and produces a migration file of the differences automatically.
When used alongside a compatible database migration tool, it can help enable database version control within your team or enterprise.
- Compares two databases (local or remote) and generates SQL migrations automatically
- Supports MySQL, PostgreSQL, and SQLite via
--driver - Connect via DSN URLs (
--server1-url,--server2-url,--db-url) — works with any connection string - Supabase-ready via
--supabaseone-flag shorthand (not required when using DSN URLs) - Diffs tables, views, materialized views, triggers, stored procedures/functions, enum types, composite types, domains, sequences, row level security policies, and data — with deterministic, predictable output
- Up and down SQL generated in the same file
- Built-in migration runner:
migration:up,down,status,validate,repair,baseline - Works with Flyway, Liquibase, Laravel Migrations, and more
- Ignore specific tables or fields via a YAML config file
- Unicode / UTF-8 aware
- Fast — tested on databases with millions of rows; schema diffs use a constant number of round-trips regardless of table count
- Runs on Windows, Linux and macOS (command-line / Terminal)
Other versions may work but are not actively tested. PRs to add official support are welcome.
| Version | Status |
|---|---|
| MySQL 8.0.x | ✅ Supported |
| MySQL 8.4.x (LTS) | ✅ Supported |
| MySQL 9.3.x (Innovation) | ✅ Supported |
| MySQL 9.6.x (Innovation) | ✅ Supported |
Use --driver=pgsql (or driver: pgsql in your .dbdiff config).
| Version | Status |
|---|---|
| PostgreSQL 14.x | ✅ Supported |
| PostgreSQL 15.x | ✅ Supported |
| PostgreSQL 16.x (LTS) | ✅ Supported |
| PostgreSQL 17.x | ✅ Supported |
| PostgreSQL 18.x | ✅ Supported |
When pg_dump and pg_restore are on PATH and at least as new as the
server, DBDiff uses them to render CREATE TABLE and everything attached to
it — indexes, constraints, identity sequence options, collations, storage,
compression and comments. They are PostgreSQL's own tooling, maintained in
lockstep with the server, so the DDL is what the server itself would produce.
Measured against the 90-case corpus in
@akalforge/pg-conformance
by convergence — build the case, reproduce it, replay it, compare catalog
fingerprints:
| renderer | reproduces |
|---|---|
| built-in | 68 / 90 |
with pg_dump |
81 / 90 |
Measured on PostgreSQL 16. The figures move by a case or two with the server:
the built-in renderer reproduces 67 on PostgreSQL 18, where LIKE ... INCLUDING ALL copies the named NOT NULL constraints that release introduced. And
pg_dump only counts when it is at least as new as the server — an older one is
not used at all, so those runs score as built-in.
Nothing is required. pg_dump is not bundled — the released binaries are
static PHP and cannot carry it — so when it is absent, or older than the
server, DBDiff falls back to its built-in renderer and says why.
Partitioned tables always use the built-in renderer, whatever is installed, and
that is deliberate rather than a limitation of the integration. pg_dump writes
a partitioned parent's primary key as ALTER TABLE ONLY parent ADD CONSTRAINT,
which reaches the partitions existing at the moment it runs and no others —
correct in pg_dump's own output order, and silently wrong as soon as anything
reorders the statements, which a tool applying a migration grouped by object
kind reasonably does. The built-in renderer puts the key inside CREATE TABLE,
where the partitions inherit it however the statements are ordered. It costs one
case in the table above — an expression partition key — and buys DDL that does
not depend on being applied in the order it was written.
A migration produced this way records it, so two machines emitting different SQL is explainable from the file:
-- DBDiff migration
-- Version: 20260901000613
-- Generated: 2026-09-01 00:06:13
-- Renderer: pg_dumpSet DBDIFF_PG_DUMP_RENDERER=off to pin a run to the built-in renderer — useful
when you need byte-identical output across machines regardless of what is
installed. DBDIFF_PG_DUMP and DBDIFF_PG_RESTORE override the binary paths.
Use --driver=sqlite. The file path is passed as the database name:
./dbdiff --driver=sqlite server1./path/to/source.db:server1./path/to/target.dbSQLite 3.x is supported (any version supported by the installed pdo_sqlite PHP extension).
--supabase sets driver=pgsql and enables SSL automatically:
./dbdiff --supabase --server1=user:[email protected]:5432 server1.mydb:server1.mydbRow level security is diffed as a first-class object — policies and each table's
ENABLE/FORCE ROW LEVEL SECURITY flags — which matters here because RLS is how
Supabase enforces per-row access. A policy dropped or changed between two
environments shows up in the migration instead of passing silently.
DBDiff reads the public schema, so policies and objects Supabase keeps in
auth, storage and its other managed schemas are outside the diff. Those are
managed by Supabase itself rather than by your migrations.
The databases below work with DBDiff's existing drivers with no code changes. Unless otherwise noted, these have not been tested by the core team. PRs to add official support are welcome.
| Database | Notes |
|---|---|
| MariaDB 10.x / 11.x | MySQL wire protocol; minor DDL dialect differences |
| AWS Aurora MySQL | Standard MySQL protocol |
| PlanetScale | MySQL-compatible SaaS |
| Vitess / VTGate | MySQL wire protocol via VTGate |
| Percona XtraDB Cluster | MySQL-compatible; Galera replication metadata ignored |
| TiDB | MySQL-compatible; default port 4000 |
| Dolt | MySQL-compatible, version-controlled; CI-tested |
| Database | Notes |
|---|---|
| AWS Aurora PostgreSQL | Standard pgsql connection |
| AWS RDS PostgreSQL | Standard pgsql connection |
| Neon | Standard pgsql; supports branch diffing (see below) |
| AlloyDB (Google Cloud) | Google's Postgres-compatible offering |
| CockroachDB | Postgres wire protocol; some DDL differences |
| YugabyteDB | Postgres-compatible YSQL layer |
| Multigres | Transparent Postgres proxy; no changes needed |
| TimescaleDB | Postgres extension; hypertable DDL diffs natively |
| pgvector | vector(N) columns and HNSW/IVFFlat indexes diff natively |
Neon's copy-on-write branching lets you diff any two branches directly:
./dbdiff \
--server1-url postgres://user:[email protected]/mydb \
--server2-url postgres://user:[email protected]/mydb \
--format=flyway --description=my_featureDolt is a MySQL-compatible database with Git-style branching. Each branch is exposed as a separate database:
./dbdiff server1.main:server1.feature_add_usersThe quickest way to get started is to download a pre-built release directly from GitHub Releases — no PHP, Node, or Composer required:
| Method | Available on Releases? | Best for |
|---|---|---|
| Pre-built binary | ✅ Yes | Quickest start — zero dependencies |
| PHAR | ✅ Yes | Single portable file; requires PHP ≥ 8.1 |
| npm | ✅ Yes (via registry) | Node.js projects or CI pipelines |
| Docker / Podman | — | Isolated environments, CI, or no local PHP |
| Composer (source) | — | Contributing to DBDiff or PHP integration |
PHP requirement: Pre-built binaries, npm packages, and Docker/Podman images bundle PHP 8.3 — no system PHP needed. The PHAR and Composer installs require PHP ≥ 8.1 on your system.
Download from GitHub Releases. No PHP, Node, or Composer required.
| Platform | Asset |
|---|---|
| Linux x64 (glibc) | dbdiff-linux-x64 |
| Linux x64 (Alpine / musl) | dbdiff-linux-x64-musl |
| Linux arm64 (glibc) | dbdiff-linux-arm64 |
| Linux arm64 (Alpine / musl) | dbdiff-linux-arm64-musl |
| macOS Apple Silicon | dbdiff-darwin-arm64 |
| macOS Intel | dbdiff-darwin-x64 |
| Windows x64 | dbdiff-win32-x64.exe |
| Windows arm64 | dbdiff-win32-arm64.exe |
After downloading, make it executable (Linux/macOS) and optionally move it to your PATH:
chmod +x dbdiff-linux-x64
sudo mv dbdiff-linux-x64 /usr/local/bin/dbdiff
dbdiff --versionnpm install -g @dbdiff/cli
dbdiff --versionThe correct platform binary is selected automatically at install time. Supported: Linux x64/arm64 (glibc + musl), macOS x64/arm64, Windows x64/arm64.
Packages are also published to GitHub Packages as a mirror. If npmjs.org is unavailable:
npm install -g @dbdiff/cli --registry=https://npm.pkg.github.comDownload dbdiff.phar from GitHub Releases. Requires PHP ≥ 8.1.
chmod +x dbdiff.phar
sudo mv dbdiff.phar /usr/local/bin/dbdiff
dbdiff --versionTo build a PHAR locally from source, see Building a PHAR.
Pre-built multi-arch images (linux/amd64 + linux/arm64) are published to GHCR on every release. Both Docker and Podman are fully supported — use whichever is available on your system. No local PHP installation is required.
Docker:
docker pull ghcr.io/dbdiff/dbdiff
docker run --rm ghcr.io/dbdiff/dbdiff --version
docker run --rm ghcr.io/dbdiff/dbdiff --driver=mysql \
--server1=user:pass@host:3306 server1.mydb:server1.mydbPodman (drop-in replacement — commands are identical):
podman pull ghcr.io/dbdiff/dbdiff
podman run --rm ghcr.io/dbdiff/dbdiff --version
podman run --rm ghcr.io/dbdiff/dbdiff --driver=mysql \
--server1=user:pass@host:3306 server1.mydb:server1.mydbPodman on Linux runs rootless by default — no daemon required. Install via your package manager:
sudo apt install podman(Debian/Ubuntu) orbrew install podman(macOS).
| Tag pattern | Registry | Description |
|---|---|---|
latest, {version}, slim-{version} |
GHCR | Slim — PHAR + PHP Alpine (~120 MB). For production use / CI. |
full, full-{version} |
GHCR | Full — Composer source install (~600 MB). For development and cross-version testing. |
# Slim image (requires dist/dbdiff.phar — run `vendor/bin/box compile` first)
# Replace 'docker' with 'podman' if using Podman
docker build -f docker/Dockerfile.slim -t dbdiff:slim .
docker run --rm dbdiff:slim --version
# Full image (Composer install from source — no PHAR needed)
docker build -f docker/Dockerfile -t dbdiff:full .See DOCKER.md for cross-version testing, Podman setup, and start.sh flags.
git clone https://github.com/DBDiff/DBDiff.git
cd DBDiff
composer install --optimize-autoloaderOr as a project dependency:
composer require "dbdiff/dbdiff:@dev"Or globally:
composer global require "dbdiff/dbdiff:@dev"After installing from source, continue with Setup.
For source installs (git clone / Composer) only. Binaries, PHAR, npm, and Docker do not require these steps.
- Create a
.dbdiffconfig file — see File Examples - Run:
./dbdiff server1.db1:server1.db2
Expected output:
ℹ Now calculating schema diff for table `foo`
ℹ Now generating UP migration
ℹ Writing migration file to /path/to/dbdiff/migration.sql
✔ Completed
Flags always override settings in .dbdiff.
| Flag | Description |
|---|---|
--server1=user:pass@host:port |
Source connection. Omit if using only one server. |
--server2=user:pass@host:port |
Target connection (if different from server1). |
--server1-url=<dsn> |
Full DSN URL for source (e.g. postgres://user:pass@host:5432/db). |
--server2-url=<dsn> |
Full DSN URL for target. Supported schemes: mysql://, pgsql://, postgres://, postgresql://, sqlite://. |
--driver=mysql|pgsql|sqlite |
Database driver. Defaults to mysql. |
--supabase |
Shorthand for --driver=pgsql + SSL. |
--format=native|flyway|liquibase-xml|liquibase-yaml|laravel |
Output format. Defaults to native. |
--description=<slug> |
Slug used in generated filenames. |
--template=<path> |
Custom output template. |
--type=schema|data|all |
What to diff. Defaults to schema. |
--include=up|down|both |
Directions to include. Defaults to up. (all is accepted as an alias for both.) |
--nocomments |
Strip comment headers from output. |
--config=<file> |
Config file path. Defaults to .dbdiff. |
--output=<path> |
Output file path. Defaults to migration.sql. |
--memory-limit=<value> |
PHP memory limit for this run (e.g. 512M, 1G, 2G, -1 for unlimited). Overrides the 1G default and any memory_limit setting in your config file. |
--tables=<list> |
Comma-separated table include list (supports globs: *, ?). Only these tables are diffed. Example: --tables=users,orders,wp_* |
--ignore-tables=<list> |
Comma-separated table exclude list (supports globs: *, ?). Example: --ignore-tables=cache_*,temp_* |
--allow-destructive |
Generate the migration even when it contains data-losing changes. See Destructive Change Protection. |
--debug |
Enable verbose error output. |
server1.db1:server2.db2 |
Databases to compare. Or a single table: server1.db1.table1:server2.db2.table1. |
UP only by default: The generated file includes only the UP (forward) migration by default. To also generate the DOWN (rollback) section, pass
--include=all. Example:dbdiff diff --include=all ...
DSN URLs vs
--serverflags: Use--server1-url/--server2-urlwhen you have a connection string (common with Supabase, Neon, Railway, etc.). Use--server1/--server2when specifying credentials separately.
Passwords with special characters: Embed the password percent-encoded in the URL. Use
dbdiff url:encodeto safely encode any password (seeurl:encodebelow). If dbdiff is not yet installed,scripts/encode-password.shworks without any dependencies.
Memory: The CLI sets a default PHP memory limit of 1G. Diffing very large databases may need more — pass
--memory-limit=2Gon the command line or addmemory_limit: 2Gto your.dbdiff/dbdiff.ymlconfig. The CLI flag always wins over the config file.
Percent-encodes a raw password for safe embedding in any --server-url connection string.
dbdiff url:encode '<raw password>'Capture the result directly into a connection flag:
PASS=$(dbdiff url:encode 'my$ecret#pass@word%123')
dbdiff diff \
--server1-url="postgres://user:${PASS}@db.xxxx.supabase.co:5432/postgres" \
--server2-url="postgres://user:[email protected]:5432/postgres"Accepts stdin too, for use in pipelines:
echo 'my$ecret#pass' | dbdiff url:encodeAll characters except RFC 3986 unreserved characters (A–Z a–z 0–9 - _ . ~) are encoded. This is the safe, zero-guesswork approach for any password — including ones containing @, #, ?, /, +, and literal %.
If dbdiff is not yet installed (e.g. you are setting up CI), use the included bash script instead — no Python, Node, or PHP required:
PASS=$(scripts/encode-password.sh 'my$ecret#pass@word%123')DBDiff includes a built-in migration runner. All migration:* commands accept:
| Flag | Description |
|---|---|
--db-url=<dsn> |
Full DSN URL for the target database. |
--migrations-dir=<path> |
Override the migrations directory. |
--config=<file> |
Path to dbdiff.yml. |
| Command | Description | Extra flags |
|---|---|---|
migration:new <name> |
Scaffold a new migration file. | --format=supabase — plain .sql (no DOWN); auto-set inside Supabase projects |
migration:up |
Apply all pending migrations. | --target=<version> — stop after this version |
migration:down |
Roll back applied migration(s). | --last=<n> (default 1), --target=<version> |
migration:status |
Show applied vs pending migrations. Adds a Supa? column inside Supabase projects. | — |
migration:validate |
Verify on-disk checksums match the history table. | — |
migration:repair |
Remove failed entries so they can be retried. | --force — skip confirmation |
migration:baseline |
Mark current DB state as the migration baseline. | --baseline-version=<YYYYMMDDHHmmss>, --description=<text>, --force |
DBDiff supports two on-disk formats in the same directory:
| Format | File pattern | Best for |
|---|---|---|
| Native (default) | {version}_{name}.up.sql + optional .down.sql |
New projects, rollback support |
| Supabase | {version}_{name}.sql (UP only) |
Existing supabase/migrations/ directories |
If both formats exist for the same version timestamp, the native .up.sql file takes precedence.
When DBDiff is run inside (or below) a directory that contains supabase/config.toml, it automatically:
- Sets the migrations directory to
supabase/migrations/(no--migrations-dirneeded) - Defaults
migration:newto Supabase format — creating a plain.sqlfile - Shows a Supa? column in
migration:status, indicating which migrations Supabase's ownschema_migrationstable considers applied
Pass --format=native to migration:new to override the auto-detected format.
./dbdiff server1.db1:server2.db2./dbdiff server1.dev.table1:server2.prod.table1 --nocomments --type=data./dbdiff --format=flyway --description=add_users --include=all \
server1.db1:server2.db2 --output=./sql/./dbdiff --driver=pgsql --server1=user:pass@localhost:5432 server1.staging:server1.production./dbdiff --supabase --server1=postgres:[email protected]:5432 \
server1.staging:server1.production./dbdiff --driver=sqlite server1./var/db/v1.db:server1./var/db/v2.db./dbdiff diff \
--server1-url='postgres://user:[email protected]:5432/postgres' \
--server2-url='postgres://user:[email protected]:5432/postgres'If your password contains special characters, use dbdiff url:encode (see url:encode in the Command-Line API section):
PASS=$(dbdiff url:encode 'my$ecret#pass@word%123')
./dbdiff diff \
--server1-url="postgres://user:${PASS}@db.xxxx.supabase.co:5432/postgres" \
--server2-url='postgres://user:[email protected]:5432/postgres'# Scaffold a new migration (DBDiff native format)
./dbdiff migration:new create_users_table
# Scaffold a Supabase-format migration (plain .sql, no DOWN file)
./dbdiff migration:new create_users_table --format=supabase
# Inside a Supabase project, format and directory are auto-detected:
cd my-supabase-project # contains supabase/config.toml
./dbdiff migration:new create_users_table # → supabase/migrations/{ts}_create_users_table.sql
# Apply all pending migrations
./dbdiff migration:up --db-url='postgres://user:pass@localhost:5432/mydb'
# Check status (adds a Supa? column inside Supabase projects)
./dbdiff migration:status --db-url='postgres://user:pass@localhost:5432/mydb'
# Roll back the last migration
./dbdiff migration:down --db-url='postgres://user:pass@localhost:5432/mydb'
# Validate checksums
./dbdiff migration:validate --db-url='postgres://user:pass@localhost:5432/mydb'When inside a Supabase project (supabase/config.toml present) and the local stack is
running (supabase start), --server1-url is resolved automatically from
supabase status:
# Only supply the remote (production) URL — local stack fills in automatically
./dbdiff diff --server2-url='postgres://user:[email protected]:5432/postgres'A single dbdiff.yml file in your project root configures both the diff command and the migration runner. Copy dbdiff.yml.example to get started.
Auto-detected filenames, in priority order:
| Filename | Notes |
|---|---|
.dbdiff |
Legacy — still supported for backwards compatibility |
dbdiff.yml |
Recommended — YAML syntax highlighting, single file for everything |
.dbdiff.yml |
Hidden-file variant |
dbdiff.yaml |
.yaml extension variant |
You can also pass any filename explicitly: ./dbdiff --config=myconfig.yml server1.db:server2.db
# ── Diff command (./dbdiff server1.db:server2.db) ─────────────────────────
server1:
user: user
password: password
port: 3306 # MySQL: 3306 | PostgreSQL: 5432
host: localhost
server2:
user: user
password: password
port: 3306
host: host2
driver: mysql # mysql | pgsql | sqlite
type: all
include: all
nocomments: true
# ── Filtering ─────────────────────────────────────────────────────────────
# Include list — only these tables are diffed (supports globs: *, ?).
# When set, only matching tables are included. Omit to diff all tables.
# tables:
# - users
# - orders
# - wp_*
# Exclude list — skip these tables entirely (supports globs).
tablesToIgnore:
- table1
- table2
# Exclude from data diff only — schema is still diffed (supports globs).
# tablesDataToIgnore:
# - audit_log
# - event_stream
# Per-table column exclusion (keys support globs: *, ?).
fieldsToIgnore:
table1:
- field1
- field2
# Per-table row filtering — skip rows matching a column-value regex.
# rowsToIgnore:
# wp_options:
# - { column: option_name, pattern: "_transient_.*" }
# - { column: option_name, pattern: "_site_transient_.*" }
# sessions:
# - { column: status, pattern: "expired|archived" }
# Per-table scope override: schema, data, or all (default).
# tableScope:
# audit_log: schema
# config: data
# ── Migration runner (dbdiff migration:up) ────────────────────────────────
database:
driver: mysql
host: localhost
port: 3306
name: mydb
user: root
password: secret
migrations:
dir: ./migrations
history_table: _dbdiff_migrationsDBDiff offers fine-grained control over what enters the diff. All list values support glob patterns (* matches any characters, ? matches a single character).
| Dimension | Config key | CLI flag | Scope |
|---|---|---|---|
| Table include list | tables |
--tables |
schema + data |
| Table exclude list | tablesToIgnore |
--ignore-tables |
schema + data |
| Data-only exclude | tablesDataToIgnore |
— | data only |
| Column exclusion | fieldsToIgnore |
— | schema + data |
| Row filtering | rowsToIgnore |
— | data only |
| Per-table scope | tableScope |
— | override |
Priority: include list is applied first (only matching tables pass), then the exclude list narrows further. tablesDataToIgnore removes tables from data comparison only. tableScope can override a table to schema, data, or all.
Glob examples: wp_* matches all WordPress tables, *_backup matches any table ending in _backup, log_? matches log_a through log_z.
# Only diff tables matching these patterns
tables:
- users
- orders
- wp_*
# Skip these tables entirely
tablesToIgnore:
- cache_*
- _dbdiff_migrations
# Skip data diff for these (schema is still compared)
tablesDataToIgnore:
- audit_log
# Exclude columns per table (keys support globs)
fieldsToIgnore:
users:
- updated_at
- last_login
wp_*:
- ID
# Skip rows matching column-value regex
rowsToIgnore:
wp_options:
- { column: option_name, pattern: "_transient_.*" }
sessions:
- { column: status, pattern: "expired|archived" }
# Override scope per table: schema | data | all
tableScope:
audit_log: schema
config: dataComparisons run in this order:
- Checks both databases exist and are accessible
- Compares database collation between source and target
- Detects differences in column count, name, type, collation or attributes
- New columns in the source are added to the target
- Detects created, dropped, and altered views across source and target
- ALTER = DROP IF EXISTS + CREATE with the new definition
- Detects created, dropped, and altered triggers
- PostgreSQL DROP TRIGGER includes the required ON table clause
- Detects created, dropped, and altered routines (MySQL and PostgreSQL)
- SQLite has no stored procedures — routines are skipped automatically
- MySQL definitions are normalized (DEFINER, ALGORITHM, SQL SECURITY stripped)
- Detects created, dropped, and altered
CREATE TYPE ... AS ENUMdefinitions - ALTER = DROP TYPE IF EXISTS + CREATE TYPE with the new labels
- Enum diffs are ordered before table diffs (tables may reference enum types)
- MySQL and SQLite do not have standalone enum types — skipped automatically
- Detects created, dropped, and altered
CREATE TYPE ... AS (...)andCREATE DOMAINdefinitions, including a domain's base type, default, nullability and named CHECK constraints - ALTER = DROP + CREATE: neither a composite's attributes nor a domain's base type can be changed in place
- Ordered before table diffs, since a column may be typed by either
- Every relation also owns a composite type describing its row shape; those belong to the table and are not reported separately
- Detects created, dropped, and altered materialized views
- A materialized view's indexes are carried with it, so a unique index on one is part of the diff
WITH NO DATAis preserved — an unpopulated matview is not recreated as a populated one- ALTER = DROP + CREATE; PostgreSQL has no
CREATE OR REPLACEfor them - Ordered after views, since a matview may select from one
- Detects created, dropped, and altered standalone sequences, rendering every option explicitly (type, increment, min, max, start, cache, cycle) because the MINVALUE and MAXVALUE defaults follow the sequence's type
- Altered in place with
ALTER SEQUENCErather than recreated, which would reset the counter - A sequence owned by a
serialor identity column belongs to that column and is not reported separately
- Detects policies created, dropped, and altered, with their command, roles,
permissiveness,
USINGandWITH CHECKexpressions - The table's
ENABLE/FORCE ROW LEVEL SECURITYflags are diffed separately, because a table carrying policies with the flag left off enforces none of them - Policies are keyed per table, so two tables may share a policy name
- Ordered last, after the tables they apply to exist
- Compares table storage engine, collation, and row count
- Records changed rows and missing rows per table
By default DBDiff refuses to generate a migration that would drop data. If the
diff contains a DROP TABLE or DROP COLUMN, generation stops and the offending
changes are listed:
Destructive changes detected — 2 destructive error(s), 1 warning(s):
[error] drop-table: table `legacy_sessions`
→ Use --allow-destructive to proceed, or archive the table instead.
[error] drop-column: column `users`.`legacy_token`
[warning] drop-view: view `active_users`
Re-run with --allow-destructive to generate the migration anyway.
Two severities:
- Errors block generation —
DROP TABLEandDROP COLUMN, the changes that destroy rows. - Warnings are reported but never block — dropping a view, materialized view, trigger, routine, enum type, composite type, domain, sequence or row level security policy. Most of these lose a definition rather than data, but three are worth reading before you proceed: dropping a materialized view discards the result set it holds until it is refreshed again, dropping a sequence loses its current value so a recreated one restarts, and dropping a policy widens which rows are visible or writable.
A dropped column paired with an added column of the same type on the same table
is treated as a likely rename: it is downgraded from an error to a
possible-rename warning, so it is still reported but no longer blocks.
To proceed anyway, pass --allow-destructive:
dbdiff diff --allow-destructive server1.db1:server2.db2Or set it permanently in your config file:
allowDestructive: trueSchema comparison is designed around round-trips rather than raw query cost — against a managed database (Supabase, RDS, Neon) network latency dominates, so the number of queries matters far more than how much each one returns.
Two passes keep that number flat as a database grows:
1. Pre-scan — skip tables that are already identical. Before diffing anything, DBDiff asks each side for a hash of every table's schema in a single query. Tables whose hashes match on both sides are byte-identical and are skipped entirely. On a production database compared against a staging copy, this is usually the overwhelming majority of tables.
ℹ Pre-scan: skipped 284 / 291 unchanged tables
2. Batch fetch — load the remaining tables together. The tables that do differ still need their full schema read. Rather than one set of round-trips per table, PostgreSQL loads all of them in a fixed 7 queries per side, no matter how many tables changed:
ℹ Batch schema fetch: loaded 7 changed table(s) in 14 queries
Together these turn schema diffing from O(tables) round-trips into a constant
number. A 300-table database with 7 real changes costs 16 queries in total,
against roughly 4,800 with per-table fetching.
Per-driver behaviour:
| Driver | Pre-scan | Batch fetch | Notes |
|---|---|---|---|
| PostgreSQL | Yes | Yes | Both passes active |
| MySQL | Yes | Not needed | A table already resolves in ~2 queries |
| SQLite | Not needed | Not needed | Local file, no network latency |
Both passes are optimisations only — they never change the generated migration. If an adapter cannot provide hashes, or a table is missing from a batch, DBDiff falls back to fetching that table individually.
Data diffing scales separately, via a streaming sorted-merge that compares row hashes in primary-key order and only fetches rows that actually differ.
DBDiff supports multiple output formats via --format. Use --description=<slug> to customise generated filenames.
--format |
Tool | Language | Output | Notes |
|---|---|---|---|---|
native (default) |
Plain SQL | Any | migration.sql |
Up, down, or both |
flyway |
Flyway | Java | V{ts}__{desc}.sql |
Down adds U{ts}__{desc}.sql (Flyway Teams) |
liquibase-xml |
Liquibase | Java | changelog.xml |
Both directions in one file |
liquibase-yaml |
Liquibase | Java | changelog.yaml |
Both directions in one file |
laravel |
Laravel Migrations | PHP | YYYY_MM_DD_HHMMSS_{desc}.php |
up()/down() methods |
| (template) | Simple DB Migrate | Python | custom | Use --template=templates/simple-db-migrate.tmpl |
Let us know if you're using DBDiff with other tools so we can add them here.
PHARs are built automatically and attached to every GitHub Release. To build locally from source:
composer install
vendor/bin/box compileOutput: dist/dbdiff.phar — rename and move to /usr/local/bin/dbdiff if desired.
box.jsonis pre-configured with GZ compression andcheck-requirements: falseso the PHAR works correctly when stitched with the static micro SAPI runtime used in the pre-built binaries.
- Go to GitHub Actions → Release DBDiff → Run workflow
- Enter the version number (e.g.
2.1.0— novprefix) - The workflow will:
- Build the PHAR with Box
- Build self-contained binaries for all 8 platforms via static-php-cli
- Publish all
@dbdiff/cli-*packages to npm (skips any already published) - Create or update the GitHub Release with all assets
- Create the git tag (skipped if it already exists)
# Build PHAR + tag
scripts/release.sh v2.1.0
git push origin v2.1.0
# Build Linux binaries locally (requires Podman or Docker)
SKIP_PHAR=1 scripts/release-binaries.sh 2.1.0
# Upload assets to an existing GitHub Release
gh release upload v2.1.0 --clobber \
dist/dbdiff.phar \
packages/@dbdiff/cli-linux-x64/dbdiff \
packages/@dbdiff/cli-linux-x64-musl/dbdiff \
packages/@dbdiff/cli-linux-arm64/dbdiff \
packages/@dbdiff/cli-linux-arm64-musl/dbdiff
# Update the Homebrew tap formula
scripts/update-homebrew-formula.sh 2.1.0 ../homebrew-dbdiffTest DBDiff locally against any combination of PHP and MySQL:
# Single combination
./start.sh 8.3 8.0
# All 16 combinations in parallel
./start.sh all all --parallelThe CI matrix, per push and pull request:
| Suite | Matrix | Jobs |
|---|---|---|
| Unit | 5 PHP | 5 |
| MySQL | 5 PHP × 4 MySQL | 20 |
| PostgreSQL | 5 PHP × 5 PostgreSQL (14–18) | 25 |
| SQLite e2e | 5 PHP | 5 |
| Dolt | 2 PHP | 2 |
| Supabase Postgres | 2 PHP | 2 |
| PG conformance | PostgreSQL 16, 17, 18 | 3 |
| DSN URL | mysql + pgsql | 1 |
See DOCKER.md for flags covering fast restarts, recording fixtures, and CI usage.
- Open a new issue or check existing ones
- For commercial support enquiries, get in touch
Please read the Contributing Guide before submitting a PR.
Could you spare 2 minutes to share your feedback?