Run your queries before and after any optimization.
Get a full HTML report with latency percentiles, QPS, and execution plan diffs.
For Russian documentation, see RU.md.
Pre-built binaries are available on the Releases page. Or install via script:
# Linux/macOS
curl -fsSL https://raw.githubusercontent.com/pg-tools/pgcompare/main/install.sh | sh# Windows
irm https://raw.githubusercontent.com/pg-tools/pgcompare/main/install.ps1 | iex# macOS (Homebrew)
brew tap pg-tools/tap
brew install pg-tools/tap/pgcompareFor more options (custom version, custom directory), see INSTALL.md.
Update to the latest version:
# macOS (Homebrew)
brew update
brew upgrade pg-tools/tap/pgcompare# Linux/macOS (install script)
curl -fsSL https://raw.githubusercontent.com/pg-tools/pgcompare/main/install.sh | sh# Windows (PowerShell)
irm https://raw.githubusercontent.com/pg-tools/pgcompare/main/install.ps1 | iexVerify installed version:
pgcompare --versionpgcompare runs the same benchmark flow twice:
- Load
pgcompare.yamland.envfrom the same project directory. - Prepare the
beforestate with the configured migration env var. - Benchmark the
beforequeries and captureEXPLAIN ANALYZEplans. - Recreate the environment for the
afterstate. - Benchmark the
afterqueries and capture plans again. - Generate one HTML comparison report.
This is intended for comparing the effect of:
- schema changes
- new or changed indexes
- rewritten SQL
- migration changes
- changes in seeded data
- Docker with either
docker composev2 ordocker-composev1 inPATH - PostgreSQL reachable from the host on
localhost:$POSTGRES_PORT - A project directory containing
.env,pgcompare.yaml, and the SQL files referenced by the config
If you build from source, use Go 1.25.
pgcompare treats the directory that contains pgcompare.yaml as the project root. It loads .env from there, runs external commands there, and writes the default report there.
Recommended layout:
student-project/
├── .env
├── docker-compose.yml
├── pgcompare.yaml
├── queries_before.sql
└── queries_after.sql
Common .env keys:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app
POSTGRES_PORT=5432
MIGRATION_VERSION=0POSTGRES_PORT is optional and defaults to 5432. If you keep the default migration env var name, use MIGRATION_VERSION. If you override it with migration.env_var, use the same custom key in .env.
Example pgcompare.yaml:
migration:
env_var: MIGRATION_VERSION
before_version: "3"
after_version: "5"
setup:
command: "$DOCKER_COMPOSE up -d postgres && $DOCKER_COMPOSE run --rm -T migrate && $DOCKER_COMPOSE run --rm -T seed"
benchmark:
before_queries: queries_before.sql
after_queries: queries_after.sql
iterations: 100
concurrency: 4
report:
description:
- query: find_active_users
what: Replace a sequential scan with an index-backed lookup.
changes: |
CREATE INDEX idx_users_active_created_at
ON users (active, created_at DESC);
expected: Lower p95 latency and higher QPS for the query.Notes on the setup command flags:
up -d postgres— starts the PostgreSQL container explicitly before running migrations, ensuring the database is available-T— disables TTY allocation.docker compose runallocates a TTY by default, which fails when the command is launched frompgcompare(non-interactive context). Without-T, the setup command will exit with an error--rm— removes the container after it exits. Without this flag, eachpgcompare runleaves behind stopped migrate/seed containers
How migration switching works:
pgcompare does not apply migrations by itself. It runs your setup.command twice and overrides the migration env var:
- first with
migration.before_version - then with
migration.after_version
By default, the env var name is MIGRATION_VERSION. If needed, you can override it with migration.env_var.
Because of that, your migration workflow must explicitly use the same env var name. If your migration script or Docker service ignores that variable, both phases will prepare the same database state.
Typical setup:
.env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app
POSTGRES_PORT=5432
MIGRATION_VERSION=0docker-compose.yml
services:
migrate:
env_file:
- .env
environment:
MIGRATION_VERSION: ${MIGRATION_VERSION}
command: sh -c "./migrate up --to ${MIGRATION_VERSION}"If you use a custom migration.env_var, the same name must be used in .env, in docker-compose.yml, and inside the migration command.
MIGRATION_VERSION in .env is useful as a default for manual local runs. During pgcompare run, it is overridden automatically for the before and after phases.
Configuration structure:
env_var: optional env var name used for migration switching. Default:MIGRATION_VERSIONbefore_version: value injected into the migration env var for the first phaseafter_version: value injected into the migration env var for the second phase
These two values define which schema state is compared. They should point to two valid migration states that your project can build successfully.
command: shell command executed in the project directory
This command is responsible for preparing the database completely. In most projects it starts containers, applies migrations, and optionally seeds data. It should exit with status 0 only when PostgreSQL is actually ready for benchmark queries.
before_queries: SQL file used for thebeforephaseafter_queries: SQL file used for theafterphaseiterations: total number of executions per queryconcurrency: number of parallel workers used for each benchmark
The query file paths are resolved relative to the directory that contains pgcompare.yaml.
description: optional list rendered at the top of the report
Each description entry may contain:
query: query name shown as a label in the reportwhat: short explanation of what was optimizedchanges: schema or SQL changes that were appliedexpected: expected effect of the optimization
pgcompare also injects DOCKER_COMPOSE into the setup command so the same command can work with both Compose v1 and v2.
Each SQL file must contain named queries:
-- name: find_active_users
SELECT id, email
FROM users
WHERE active = true
ORDER BY created_at DESC
LIMIT 100;
-- name: count_orders_by_status
SELECT status, COUNT(*)
FROM orders
GROUP BY status;Rules:
- both files must contain the same query names
- keep the queries in the same order in both files
- query names must be unique inside each file
See example/report.html for a complete example of the generated report.
Files from one real benchmark run are available in example:
- example/pgcompare.yaml
- example/queries_before.sql
- example/queries_after.sql
- example/docker-compose.yml
- example/report.html
The HTML report is designed to answer three practical questions:
- What exactly was changed?
- Did the queries become faster?
- How did the execution plans change?
The top section of the report shows the optional optimization description from report.description. This is where you can explain the goal of the change, record the SQL or schema update, and state the expected outcome.
The summary section gives a compact before/after view for each query:
- p95 latency before and after
- p95 delta
- speedup
- QPS before and after
- QPS delta
The speedup badge is calculated from p95 latency:
speedup = p95_before / p95_after
Each query then gets its own detailed card with:
- p50, p95, p99, min, max, mean, and standard deviation
- QPS and error rate
- percentage deltas between the
beforeandafterruns - a short summary of detected plan changes
- rendered
beforeandafterquery plans
If report.description is empty, the report shows a warning block at the top so it is immediately clear that the explanatory part is missing.
After preparing .env, pgcompare.yaml, and the query files, run:
pgcompare run --config ./pgcompare.yamlBy default, the report is written to report.html next to the config file.
Write the report to a custom path:
pgcompare run --config ./pgcompare.yaml --out ./artifacts/report.htmlEnable verbose logs:
pgcompare run --config ./pgcompare.yaml --verboseShow command help:
pgcompare run --helpShow root command help:
pgcompare --helpCheck installed version:
pgcompare --version- The CLI currently connects to PostgreSQL through
localhost, using credentials from.env - If
--outis omitted, the default output file isreport.htmlin the config directory - The HTML report interface is currently in Russian