GitHub - DBDiff/DBDiff: Compare MySQL, Postgres or SQLite databases & automatically create schema & data change scripts/migrations automatically for database version control. Supports Flyway, Liquibase and has a built-in migration tool. · GitHub
Skip to content
DBDiffPublic

About

Compare MySQL, Postgres or SQLite databases & automatically create schema & data change scripts/migrations automatically for database version control. Supports Flyway, Liquibase and has a built-in migration tool.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

747 stars

Watchers

34 watching

Forks

Latest commit

 

History

574 Commits

Folders and files

Repository files navigation

DBDiff logo

Build Status Total Downloads Monthly Downloads License

DBDiff compares two MySQL, PostgreSQL or SQLite databases, local or remote, and writes the migration that turns one into the other: schema and data, up and down, ready to apply.

When used alongside a compatible database migration tool, it can help enable database version control within your team or enterprise.

Using Supabase? SupaForge runs DBDiff for the schema and covers the rest of a project too — RLS, storage, auth config, Edge Functions, cron, webhooks and more — with a preview before anything is applied: npx @akal/supaforge init.

Features

  • Compares two databases (local or remote) and generates SQL migrations automatically
  • Migrations that run. Each one applies as written and leaves the target identical to the source, and the DOWN reverts it, including the changes that need several statements in the right order, like a column type under a view or an enum losing a label
  • Proven, not hoped. Every change to the PostgreSQL support is tested by building, diffing, applying and fingerprinting a corpus of real-world shapes on every supported version
  • Supports MySQL, PostgreSQL, and SQLite via --driver
  • Every schema of a PostgreSQL database, or just the ones you choose, with --schemas / --ignore-schemas
  • Connect via DSN URLs (--server1-url, --server2-url, --db-url) — works with any connection string
  • Supabase-ready via --supabase one-flag shorthand (not required when using DSN URLs)
  • Diffs tables, partitions, views, materialized views, triggers, stored procedures/functions, enum types, composite types, domains, sequences, collations, row level security policies, comments, extensions, 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 on large and remote databases: schema diffs use a constant number of round-trips however many tables there are
  • Runs on Windows, Linux and macOS (command-line / Terminal)

Supported Databases

Other versions may work but are not actively tested. PRs to add official support are welcome.

MySQL

MySQL 8.0, the 8.4 LTS, and current Innovation releases.

PostgreSQL

Use --driver=pgsql (or driver: pgsql in your .dbdiff config).

PostgreSQL 14 and later. CI tests every supported major version.

Rendering DDL: built-in or pg_dump

DBDiff renders CREATE TABLE and what hangs off it (indexes, constraints, identity options, collations, storage, compression) in one of two ways:

  • Built-in. Always available. It needs nothing installed.
  • pg_dump. Used when pg_dump and pg_restore are on PATH and at least as new as the server. This is PostgreSQL's own rendering.

Both renderers reproduce every case in the @akal/pg-conformance corpus on every supported PostgreSQL version. The corpus holds shapes drawn from PostgreSQL's own regression suite plus hand-written hard cases. Each one is built, diffed, applied and compared by catalog fingerprint, on every pull request.

The migration header records which renderer was used (-- Renderer: pg_dump). Set DBDIFF_PG_DUMP_RENDERER=off to pin the built-in one for byte-identical output across machines. DBDIFF_PG_DUMP and DBDIFF_PG_RESTORE override the binary paths.

Partitioned tables always use the built-in renderer. pg_dump adds a partitioned parent's primary key with ALTER TABLE ONLY, which reaches only the partitions that exist at that moment. The built-in renderer puts the key inside CREATE TABLE, where every partition inherits it whatever order the statements run in.

Migrations that run, not just SQL that parses

Every PostgreSQL migration DBDiff generates is meant to apply as written and leave the target identical to the source — and to revert cleanly with the DOWN. The cases where that takes more than one statement:

  • Changing a column's type. PostgreSQL refuses ALTER COLUMN ... TYPE while a view, materialized view, policy, trigger condition or stored generated column reads the column. Those are dropped and put back around the change — views in any schema, and through any partition — with their options (security_invoker included), grants, comments, INSTEAD OF triggers and indexes. Grants come back exactly: where default privileges would widen a recreated view, they are revoked first. A generated column is recomputed from its expression, so no data is lost. A change that is not a type change — a default, nullability — leaves them alone.
  • String to non-string types (text → uuid, varchar → integer) get USING column::type, which parses each value and fails loudly on one it cannot read. Never towards a string type, where an explicit cast would truncate silently.
  • Partitions and inheritance. A column a table inherits takes its type from the parent, so its type changes once, on the parent.
  • Generated columns whose expression changes are dropped and re-added with the new expression, together with their indexes, constraints, comments and grants. A plain column becoming generated is re-added the same way; a generated column becoming plain is DROP EXPRESSION, which keeps its values.
  • Identity columns are changed in place — SET GENERATED, SET INCREMENT BY ..., a plain TYPE change — so the sequence carries on where it was. A column becoming an identity, or a serial one becoming an identity or back, gets a sequence started past the values already in the column, and a serial column's old sequence is dropped rather than left behind.
  • Column storage and compression (SET STORAGE, SET COMPRESSION) are compared, and set again after a type change, which resets both to the new type's defaults.
  • Enums. Adding a label is ALTER TYPE ... ADD VALUE ... BEFORE/AFTER, positioned where the source has it. A label added this way cannot be used in the same transaction until it commits (unsafe use of new value), so dbdiff migration:up applies label additions first, each committed, and then the rest of the migration in one transaction; another runner applying a whole migration in one transaction should do the same.
  • Removing or reordering enum labels moves everything to a new type with the new labels, which then takes the old one's name. Each column of the type, or an array of it, is retyped with USING col::text::new, keeping its data — a row holding a removed label fails the cast, loudly, before anything has changed. Defaults, CHECK constraints and partial or expression indexes that name a label, foreign keys between two tables' columns of the type, and the views, policies, triggers and generated columns reading them stand aside and come back; so do the type's comment and grants.
  • Creation order. A function a column default, CHECK constraint or expression index calls is created before the tables. Types, domains, sequences and routines the source no longer has are dropped last, once the tables, columns, views, triggers and policies using them are gone. The DOWN mirrors both: it recreates dropped types and table-called functions first, other functions, views, triggers and policies after the tables, and drops what the UP created only once nothing uses it.
  • Storage. UNLOGGED / LOGGED is compared and ordered by foreign keys, and storage parameters (fillfactor, autovacuum_*) are compared regardless of the order they were set in or how a boolean was spelled.

What is deliberately not reported as a difference:

  • A materialized view's population state — REFRESH changes it, no DDL does, and every dump-based copy starts unpopulated.
  • The same expression rendered two ways. status IN ('draft', 'active') on a varchar column renders differently once recreated from its own rendering, as a dump or a migration does. CHECK constraints, partial indexes, policies, views, trigger conditions, domain CHECKs and generated-column expressions are compared by what PostgreSQL makes of them, not by their text.

Known limitations:

  • An enum label removal or reorder cannot move a routine taking or returning the type, a domain, composite or range type over it, a partition key of the type, or a view using a literal of it without reading a column of it. The migration names them in a comment and falls back to replacing the type, which PostgreSQL then refuses rather than dropping anything; migrate those first, or apply the change by hand.

SQLite

Use --driver=sqlite. The file path is passed as the database name:

./dbdiff --driver=sqlite server1./path/to/source.db:server1./path/to/target.db

SQLite 3.x is supported (any version supported by the installed pdo_sqlite PHP extension).

Supabase

--supabase sets driver=pgsql and enables SSL automatically:

./dbdiff --supabase --server1=user:pass@db.xxx.supabase.co:5432 server1.mydb:server1.mydb

Row 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 unless told otherwise, 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. Schemas of your own are compared with --schemas, or every schema but the managed ones with --ignore-schemas:

dbdiff diff --supabase --ignore-schemas='auth,storage,realtime,_realtime,_analytics,vault,net,graphql*,supabase_*,pgsodium*,pgtle,extensions' ...

For the rest of a Supabase project, such as storage, auth config, cron, webhooks, Realtime, Vault and grants, use SupaForge. It runs DBDiff for the schema and data and covers the other layers itself.

Compatible Database Variants

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.

MySQL-compatible — --driver=mysql (default)

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

PostgreSQL-compatible — --driver=pgsql

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 Branching

Neon's copy-on-write branching lets you diff any two branches directly:

./dbdiff \
  --server1-url postgres://user:pass@main-branch.hostname.neon.tech/mydb \
  --server2-url postgres://user:pass@feature-branch.hostname.neon.tech/mydb \
  --format=flyway --description=my_feature

Dolt (Git for Databases)

Dolt is a MySQL-compatible database with Git-style branching. Each branch is exposed as a separate database:

./dbdiff server1.main:server1.feature_add_users

Installation

The 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.

Pre-built Binaries

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 11 on Arm dbdiff-win32-arm64.exe (the x64 build, run by Windows' built-in x64 emulation)

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 --version

npm

npm install -g @dbdiff/cli
dbdiff --version

The 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.com

PHAR

Download dbdiff.phar from GitHub Releases. Requires PHP ≥ 8.1.

chmod +x dbdiff.phar
sudo mv dbdiff.phar /usr/local/bin/dbdiff
dbdiff --version

To build a PHAR locally from source, see Building a PHAR.

Docker / Podman

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.

Pull and run (no build 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.mydb

Podman (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.mydb

Podman on Linux runs rootless by default — no daemon required. Install via your package manager: sudo apt install podman (Debian/Ubuntu) or brew install podman (macOS).

Image variants

Tag pattern Registry Description
latest, {version}, slim-{version} GHCR Slim — PHAR + PHP Alpine. For production use / CI.
full, full-{version} GHCR Full — Composer source install. For development and cross-version testing.

Build locally

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

Composer Source Install

git clone https://github.com/DBDiff/DBDiff.git
cd DBDiff
composer install --optimize-autoloader

Or as a project dependency:

composer require "dbdiff/dbdiff:@dev"

Or globally:

composer global require "dbdiff/dbdiff:@dev"

After installing from source, continue with Setup.

Setup

For source installs (git clone / Composer) only. Binaries, PHAR, npm, and Docker do not require these steps.

  1. Create a dbdiff.yml config file — see File Examples
  2. 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

Command-Line API

diff (default command)

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.
--units Mark each change's statements as one unit: -- dbdiff:unit <Kind> <object> before them, -- dbdiff:end after. For tools that review or apply changes one at a time — an enum label swap, a column type change with its views stood aside or a serial column turned into an identity is several statements that only work together and in order. The markers are SQL comments; the migration runs the same with or without them.
--config=<file> Config file path. Defaults to .dbdiff.
--output=<path> Where to write. A file path for native, liquibase-xml and liquibase-yaml; a directory for flyway and laravel, which name their own files. Defaults to migration.sql in the current directory.
--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_*. PostgreSQL with --schemas: a pattern with a schema (app.*, public.orders) matches in that schema only; one without matches the table in every schema.
--ignore-tables=<list> Comma-separated table exclude list (supports globs: *, ?). Example: --ignore-tables=cache_*,temp_*
--schemas=<list> PostgreSQL: the schemas to compare (supports globs). Defaults to public. --schemas='*' compares every schema but the system's own; --schemas=public,app compares two. Objects outside public are named in full in the migration, and a schema only one side has is created or dropped.
--ignore-schemas=<list> PostgreSQL: schemas to skip (supports globs). On its own, compares every other schema. Example: --ignore-schemas=auth,storage,extensions
--allow-destructive Generate the migration even when it contains data-losing changes. See Destructive Change Protection.
--renames=auto|off PostgreSQL: auto (the default) renames a column, table or sequence whose only difference is its name, keeping its data, where exactly one such pair exists. off drops and adds instead.
--no-snapshot PostgreSQL: read each query on its own. By default each side is read as of one instant, in a REPEATABLE READ transaction held for the run, so a change made while the diff runs is seen by all of its reads or by none. As with pg_dump, what the reads lock stays locked until the run ends, so DDL on that side waits for it.
--source-snapshot=<id> / --target-snapshot=<id> PostgreSQL: read that side as of a snapshot another session exported with pg_export_snapshot() and holds open, so a tool running DBDiff as part of a larger read sees one instant throughout.
--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 --server flags: Use --server1-url / --server2-url when you have a connection string (common with Supabase, Neon, Railway, etc.). Use --server1 / --server2 when specifying credentials separately.

Passwords with special characters: Embed the password percent-encoded in the URL. Use dbdiff url:encode to safely encode any password (see url:encode below). If dbdiff is not yet installed, scripts/encode-password.sh works without any dependencies.

Memory: The CLI sets a default PHP memory limit of 1G. Diffing very large databases may need more — pass --memory-limit=2G on the command line or add memory_limit: 2G to your .dbdiff / dbdiff.yml config. The CLI flag always wins over the config file.

url:encode — Password encoder

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:pass@db.yyyy.supabase.co:5432/postgres"

Accepts stdin too, for use in pipelines:

echo 'my$ecret#pass' | dbdiff url:encode

All 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')

Migration Runner

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

Migration file formats

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.

Supabase project auto-detection

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-dir needed)
  • Defaults migration:new to Supabase format — creating a plain .sql file
  • Shows a Supa? column in migration:status, indicating which migrations Supabase's own schema_migrations table considers applied

Pass --format=native to migration:new to override the auto-detected format.

Usage Examples

MySQL (default)

./dbdiff server1.db1:server2.db2

MySQL — data diff only

./dbdiff server1.dev.table1:server2.prod.table1 --nocomments --type=data

MySQL — Flyway format with output path

./dbdiff --format=flyway --description=add_users --include=all \
  server1.db1:server2.db2 --output=./sql/

PostgreSQL

./dbdiff --driver=pgsql --server1=user:pass@localhost:5432 server1.staging:server1.production

Supabase

./dbdiff --supabase --server1=postgres:pass@db.xxxx.supabase.co:5432 \
  server1.staging:server1.production

SQLite

./dbdiff --driver=sqlite server1./var/db/v1.db:server1./var/db/v2.db

DSN URLs

./dbdiff diff \
  --server1-url='postgres://user:pass@db.xxxx.supabase.co:5432/postgres' \
  --server2-url='postgres://user:pass@db.yyyy.supabase.co:5432/postgres'

Each URL's own sslmode and options apply to that server's connection, as they do for psql and pg_dump — ?sslmode=require&options=-c%20default_transaction_read_only%3Don keeps a session on the source read-only, for example.

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:pass@db.yyyy.supabase.co:5432/postgres'

Migration runner

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

Supabase — diff with local stack auto-fill

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:pass@db.yyyy.supabase.co:5432/postgres'

File Examples

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

dbdiff.yml

# ── 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_migrations

Filtering

DBDiff 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
Schema include list (PostgreSQL) schemas --schemas schema + data
Schema exclude list (PostgreSQL) schemasToIgnore --ignore-schemas 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: data

What Is Compared

Object MySQL PostgreSQL SQLite Notes
Tables and columns ✅ ✅ ✅ Types, defaults, nullability, collation; UNLOGGED and storage parameters on PostgreSQL
Keys, indexes, constraints ✅ ✅ ✅ Including NOT VALID, NULLS NOT DISTINCT, deferrable and cyclic foreign keys
Views ✅ ✅ ✅
Materialized views — ✅ — Their indexes go with them; whether one is populated is not compared
Triggers ✅ ✅ ✅
Functions, procedures ✅ ✅ — Overloads are told apart by signature
Enum, composite and domain types — ✅ — Enum labels are added in place, or migrated to a new type when removed or reordered
Sequences — ✅ — Altered in place, so the counter is kept
Row level security — ✅ — Policies, and each table's ENABLE/FORCE flags
Extensions — ✅ — Created and dropped with the schema they're installed in; their own objects are never diffed
Comments — ✅ — On every object above, and set again when a change recreates an object
Partitions, inheritance — ✅ — Partition bounds, keys, and a partition's own constraints and indexes
Schemas — ✅ — With --schemas / --ignore-schemas; a schema only one side has is created or dropped
Data ✅ ✅ ✅ Changed, missing and extra rows, compared by hash in primary-key order

Expressions in CHECK constraints, partial indexes, policies, views, trigger conditions and generated columns are compared by what PostgreSQL makes of them, not by their text. A dump-and-restore copy therefore doesn't read as changed.

Destructive Change Protection

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 TABLE and DROP 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.db2

Or set it permanently in your config file:

allowDestructive: true

Schema Diff Performance

Schema 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 skipped entirely. Comparing production against a staging copy, that's usually almost all of them.

2. Batch fetch: load the remaining tables together. The tables that do differ are read in a fixed handful of queries per side, however many there are, rather than a set of queries per table.

Together these turn schema diffing from one set of round-trips per table into a constant number, which is what makes diffing a large remote database fast.

Per-driver behaviour:

Driver Pre-scan Batch fetch Notes
PostgreSQL Yes Yes Both passes active
MySQL Yes Not needed A table already resolves in very few 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.

Compatible Migration Tools

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.

Building a PHAR

PHARs are built automatically and attached to every GitHub Release. To build locally from source:

composer install
vendor/bin/box compile

Output: dist/dbdiff.phar — rename and move to /usr/local/bin/dbdiff if desired.

box.json is pre-configured with GZ compression and check-requirements: false so the PHAR works correctly when stitched with the static micro SAPI runtime used in the pre-built binaries.

Releasing 🚀

Automated (recommended)

  1. Go to GitHub Actions → Release DBDiff → Run workflow
  2. Enter the version number (e.g. 2.1.0 — no v prefix)
  3. The workflow will:
    • Build the PHAR with Box
    • Build self-contained binaries for every platform 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)

Manual / local

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

Cross-Version Testing

Test DBDiff locally against any combination of PHP and MySQL:

# Single combination
./start.sh 8.3 8.0

# Every combination, in parallel
./start.sh all all --parallel

On every push and pull request, CI runs:

  • the unit suite on every supported PHP version;
  • integration tests against every supported MySQL and PostgreSQL version, SQLite, Dolt and Supabase's own Postgres image;
  • the PostgreSQL conformance corpus, and round trips of every corpus migration in both directions;
  • SupaForge's scenario suite against the checkout, so a change is tested the way SupaForge runs DBDiff.

See DOCKER.md for flags covering fast restarts, recording fixtures, and CI usage.

Questions & Support 💡

Contributions 💖

Please read the Contributing Guide before submitting a PR.

Feedback 💬

Could you spare 2 minutes to share your feedback?

https://forms.gle/gjdJxZxdVsz7BRxg7

License

MIT

Made with 💖 by
Akal Logo

About

Compare MySQL, Postgres or SQLite databases & automatically create schema & data change scripts/migrations automatically for database version control. Supports Flyway, Liquibase and has a built-in migration tool.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

747 stars

Watchers

34 watching

Forks

Releases

Packages

Used by

Contributors

Languages