Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,38 @@ jobs:
fail-fast: false
matrix:
# Sparse cross: every Postgres major on the newest Python, plus every other
# Python on the default Postgres (18). The PG-18 x 3.14 cell is shared.
# Python on the default Postgres (18). The PG-18 x 3.14 cell is shared. Everything
# runs on the default psycopg driver; one extra cell covers the asyncpg driver.
include:
- python-version: "3.14"
pg-version: "14"
driver: "psycopg"
- python-version: "3.14"
pg-version: "15"
driver: "psycopg"
- python-version: "3.14"
pg-version: "16"
driver: "psycopg"
- python-version: "3.14"
pg-version: "17"
driver: "psycopg"
- python-version: "3.14"
pg-version: "18"
driver: "psycopg"
- python-version: "3.11"
pg-version: "18"
driver: "psycopg"
- python-version: "3.12"
pg-version: "18"
driver: "psycopg"
- python-version: "3.13"
pg-version: "18"
driver: "psycopg"
- python-version: "3.14"
pg-version: "18"
driver: "asyncpg"

name: Unit Tests - Python ${{ matrix.python-version }} - Postgres ${{ matrix.pg-version }}
name: Unit Tests - Python ${{ matrix.python-version }} - Postgres ${{ matrix.pg-version }} - ${{ matrix.driver }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand All @@ -81,6 +93,7 @@ jobs:
- name: Run unit tests
env:
PGMIG_TEST_PG_VERSION: ${{ matrix.pg-version }}
PGMIG_DRIVER: ${{ matrix.driver }}
run: pytest --showlocals -c tests/pytest.ini

check:
Expand Down
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ uv add --dev pgmig # Or add pgmig to your project.
pip install pgmig
```

The base install uses the pure-Python psycopg driver (needs a system libpq). For a faster build,
a bundled-libpq build, or the asyncpg driver, see [which driver should I install?](#which-driver-should-i-install).

### Usage

`pgmig` can be used directly in the command line or as a Python library.
Expand Down Expand Up @@ -101,7 +104,7 @@ adds a few more (`—` in the library column):
| `--ignore-schema` | `ignore_schemas` | Schema names to exclude from the diff entirely: their tables and every other object, and the `CREATE`/`DROP` of the schema itself, are ignored (even object kinds pgmig cannot otherwise process). The schema must be isolated — if it shares any dependency with a kept schema (a foreign key, a view read, a cross-schema type, …), pgmig errors rather than emit a migration that would fail at apply. Repeatable on the CLI; a list of names in the library. |
| `--include-owner` | `include_owner` | Emit `ALTER ... OWNER TO` statements to reconcile ownership. Off by default: ownership references cluster-level roles that routinely differ across environments, so it is not part of the default convergence. |
| `--include-grants` | `include_grants` | Also emit named-role `GRANT`/`REVOKE`. `PUBLIC` grants are always diffed (portable and apply-safe); named-role grants reference cluster-level roles that diverge across environments and fail on apply when the role is absent on the target, so they are opt-in. |
| `--driver` | `driver` | Database driver to connect with: `auto` (default) or `psycopg`. `auto` picks among the supported drivers; naming one pins it. On the CLI it falls back to the `PGMIG_DRIVER` environment variable. |
| `--driver` | `driver` | Database driver to connect with: `auto` (default), `psycopg` or `asyncpg`. `auto` picks among the installed drivers; naming one pins it. On the CLI it falls back to the `PGMIG_DRIVER` environment variable. See [the FAQ](#which-driver-should-i-install). |
| `--output`, `-o` | — | Write the migration SQL to this file instead of stdout. |
| `--check`, `-c` | — | Exit non-zero if the databases differ (CI gate); the migration is still emitted. |

Expand Down Expand Up @@ -130,15 +133,43 @@ e.g. `postgresql://user:pass@host:5432/dbname`.

## FAQ

### Which driver should I install?

pgmig talks to Postgres through a driver. The base `pgmig` install uses **psycopg** (pure
Python), which needs a system-provided libpq. Extras swap in a faster build or a different
driver — the migration code is identical, only the connection layer changes.

| Install | Driver / build | Needs at install | libpq | Notes |
| ----------------------- | ------------------------ | ---------------- | ------------- | ----- |
| `pip install pgmig` | psycopg (pure Python) | nothing | system libpq | Works everywhere; slowest. |
| `pip install 'pgmig[binary]'` | psycopg (prebuilt C) | nothing | **bundled** | Fast, no build tools or system libpq. Bundled libpq/OpenSSL only update with the wheel, so psycopg advises against it in production — ideal for CLI / standalone use. |
| `pip install 'pgmig[c]'` | psycopg (compiled C) | C compiler + libpq headers | system libpq | Fast **and** rides your OS's libpq security updates. Recommended for production. |
| `pip install 'pgmig[asyncpg]'` | asyncpg | nothing | **none** | No libpq at all. Select it with `--driver asyncpg` (or `driver="asyncpg"`). |

### Do I need libpq installed?

By default, yes — pgmig requires the Postgres client library (libpq) on the machine. For
standalone / CLI use you can skip that by installing the `binary` extra, which bundles it:
Depends on the install. The base install and the `c` extra use the system libpq (install it via
your OS, e.g. `apt install libpq5`). The `binary` extra bundles its own, and the `asyncpg` extra
doesn't use libpq at all — so either of those needs nothing extra on the machine.

### Can I use asyncpg instead of psycopg?

Yes. Install the extra and select the driver:

```shell
pip install 'pgmig[binary]'
pip install 'pgmig[asyncpg]'
pgmig generate -s "$SRC" -t "$TGT" --driver asyncpg # or set PGMIG_DRIVER=asyncpg
```

In the library, pass `driver="asyncpg"` to `generate` / `agenerate`. The default is `psycopg`.

### Which driver is fastest?

psycopg — especially the `c` / `binary` builds — is about 1.3× faster than asyncpg for pgmig's
workload. Introspection is a handful of one-shot catalog queries, where asyncpg's per-statement
prepare cost outweighs its throughput edge. That said, a generation runs once and finishes in
well under a second, so pick by install convenience rather than speed.

## Contributing

Contributions are welcome!
Expand Down
1 change: 1 addition & 0 deletions linters/cspell/words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pycache
pyproject
softprops
psycopg
asyncpg
dataclass
dataclasses
conftest
Expand Down
26 changes: 16 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ requires = ["hatchling", "hatch-vcs"]

[dependency-groups]
dev = [
# asyncpg is an optional extra for users, but the dev env installs it to test that driver.
"asyncpg>=0.31.0",
"asyncpg-stubs>=0.31.3",
"httpx==0.28.1",
"mypy==2.3.1",
"prek==0.5.2",
Expand Down Expand Up @@ -41,18 +44,21 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
#
"psycopg>=3.3.5",
"pydantic>=2",
"typer>=0.12",
]
# The default driver is psycopg (pure Python), which talks to a system-provided libpq. Faster
# and driver-alternative builds are opt-in extras below; the runtime `driver` argument selects
# which installed driver to use.
dependencies = ["psycopg>=3.3.5", "pydantic>=2", "typer>=0.12"]

[project.optional-dependencies]
binary = [
#
"psycopg[binary]>=3.3.5",
]
# psycopg's C speedups compiled against the system libpq. Fast; recommended for production.
# Needs a C compiler and libpq headers at install time.
c = ["psycopg[c]>=3.3.5"]
# psycopg's C speedups shipped as a prebuilt wheel that bundles libpq. Fast and needs no build
# tools or system libpq -- but the bundled libpq/OpenSSL only update when psycopg-binary does,
# so psycopg recommends against it for production. Great for CLI / standalone use.
binary = ["psycopg[binary]>=3.3.5"]
# The asyncpg driver instead of psycopg. Needs no libpq at all. Select it with driver="asyncpg".
asyncpg = ["asyncpg>=0.31.0"]

[project.scripts]
pgmig = "pgmig._cli._cli:app"
Expand Down
88 changes: 88 additions & 0 deletions scripts/bench_drivers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Benchmark the psycopg and asyncpg drivers on a full introspection of a populated database.

Run from the worktree: uv run python scripts/bench_drivers.py
"""
# ruff: noqa: INP001, S608 (throwaway benchmark script, hardcoded DDL)

import asyncio
import statistics
import time

from pgmig._db import DbConnection, DbConnInfo
from pgmig._drivers import DbDriver
from pgmig._introspect._engine import introspect_db

_DSN_PREFIX = "postgresql://pgmig:pgmig@localhost:15432"
_ADMIN_DSN = f"{_DSN_PREFIX}/postgres"
_BENCH_DB = "pgmig_bench"
_BENCH_DSN = f"{_DSN_PREFIX}/{_BENCH_DB}"

_TABLES = 40
_VIEWS = 15
_ITERATIONS = 25


async def _recreate_bench_db() -> None:
async with DbConnection.connect(
db_conn_info=DbConnInfo(dsn=_ADMIN_DSN, label="admin", driver=DbDriver.PSYCOPG)
) as admin:
await admin.execute(f"DROP DATABASE IF EXISTS {_BENCH_DB} WITH (FORCE)")
await admin.execute(f"CREATE DATABASE {_BENCH_DB}")


async def _populate() -> None:
stmts: list[str] = ["CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')"]
for i in range(_TABLES):
stmts.append(
f"CREATE TABLE t{i} ("
f"id bigint PRIMARY KEY, name text NOT NULL, val numeric(10, 2), "
f"created timestamptz DEFAULT now(), feeling mood)"
)
stmts.append(f"CREATE INDEX t{i}_name_idx ON t{i} (name)")
stmts.extend(f"CREATE VIEW v{i} AS SELECT id, name FROM t{i}" for i in range(_VIEWS))
stmts.append("CREATE FUNCTION add(a integer, b integer) RETURNS integer LANGUAGE sql AS 'SELECT a + b'")

async with DbConnection.connect(
db_conn_info=DbConnInfo(dsn=_BENCH_DSN, label="bench", driver=DbDriver.PSYCOPG)
) as conn:
await conn.execute(";\n".join(stmts))


async def _time_driver(driver: DbDriver) -> list[float]:
db_conn_info = DbConnInfo(dsn=_BENCH_DSN, label="bench", driver=driver)

# Warm up (connection setup, prepared-statement caches) before timing.
await introspect_db(db_conn_info=db_conn_info)

timings: list[float] = []
for _ in range(_ITERATIONS):
start = time.perf_counter()
await introspect_db(db_conn_info=db_conn_info)
timings.append(time.perf_counter() - start)
return timings


async def main() -> None:
await _recreate_bench_db()
await _populate()

print(f"Introspecting {_TABLES} tables + {_VIEWS} views, {_ITERATIONS} iterations each.\n")
print(f"{'driver':<10}{'mean (ms)':>12}{'min (ms)':>12}{'median (ms)':>14}")
results: dict[str, float] = {}
for driver in (DbDriver.PSYCOPG, DbDriver.ASYNCPG):
timings = await _time_driver(driver)
mean_ms = statistics.mean(timings) * 1000
results[driver.value] = mean_ms
print(
f"{driver.value:<10}{mean_ms:>12.2f}{min(timings) * 1000:>12.2f}{statistics.median(timings) * 1000:>14.2f}"
)

faster = min(results, key=lambda k: results[k])
slower = max(results, key=lambda k: results[k])
ratio = results[slower] / results[faster]
print(f"\n{faster} is {ratio:.2f}x faster than {slower} (mean).")


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading