8 unstable releases (3 breaking)
| 0.5.0 | Jul 16, 2026 |
|---|---|
| 0.4.0 | Jun 29, 2026 |
| 0.3.0 | Jun 29, 2026 |
| 0.2.6 | Jun 29, 2026 |
#966 in Database interfaces
430KB
5K
SLoC
PgPaw
PgPaw is a read-only HTTP and realtime layer for Postgres. It keeps a local
pglite-rs logical replica of your upstream
database, runs plain PostgreSQL SELECT queries against that replica, and
returns either cacheable JSON snapshots or live Server-Sent Events.
With the az-wire feature, the same read-only operations are also available through a native
az-wire host. PgPaw can separately run as an embedded writable PostgreSQL primary for a Rust host;
embedded mode does not use logical replication.
Use PgPaw when you want:
- read-only SQL over HTTP without sending every read to upstream Postgres;
- shared snapshot URLs that browsers or CDNs can cache;
- realtime updates for the same SQL query shape;
- Postgres Row-Level Security to remain the source of truth for private data.
PgPaw does not handle writes. Keep writes in your application API, write to Postgres, and let logical replication bring those changes back to PgPaw.
Contents
- Install
- Quickstart
- How PgPaw Fits In
- Prepare Postgres
- Run PgPaw
- HTTP API
- Realtime Streams
- Authorization And RLS
- SQL Rules
- Caching Model
- Configuration
- Logging
- Operations
- Troubleshooting
- Development
Install
Install from Cargo:
cargo install pgpaw
When a supported prebuilt artifact is published, you can instead install the optional npm wrapper, which downloads the native binary during installation:
npm install -g pgpaw
Check the binary:
pgpaw --help
Quickstart
Prerequisites:
- Postgres 13 or newer.
- A Postgres user that can run the one-time setup in
pgpaw init. - PgPaw installed locally.
Prepare the upstream database:
pgpaw init \
--pg-host 127.0.0.1 \
--pg-port 5432 \
--pg-user postgres \
--pg-password "$POSTGRES_PASSWORD" \
--pg-database myapp
If pgpaw init changes WAL settings, restart Postgres before starting PgPaw.
The command prints this clearly when a restart is required.
Start PgPaw:
pgpaw serve \
--host 127.0.0.1 \
--port 8080 \
--data-dir ./pgpaw-data \
--pg-host 127.0.0.1 \
--pg-port 5432 \
--pg-user postgres \
--pg-password "$POSTGRES_PASSWORD" \
--pg-database myapp
Check health:
curl http://127.0.0.1:8080/healthz
Run a snapshot query:
curl -i -X POST http://127.0.0.1:8080/query \
-H "content-type: application/json" \
-d '{"sql":"select id, email from users where id = 7"}'
Public queries return a 303 See Other response:
HTTP/1.1 303 See Other
Location: /q/{hash}/{version}
Cache-Control: no-store
Fetch the snapshot URL:
curl http://127.0.0.1:8080/q/{hash}/{version}
Open a live stream:
curl -N -X POST "http://127.0.0.1:8080/query?live=true" \
-H "content-type: application/json" \
-d '{"sql":"select id, status from orders order by id"}'
How PgPaw Fits In
Application writes
|
v
Upstream Postgres
wal_level=logical
publication=pgpaw_pub
DDL event trigger, when permitted
|
| logical replication
v
PgPaw local pglite replica
SQL classifier
version index
query cache
live diff hub
|
v
HTTP clients
POST /query
GET /q/{hash}/{version}
POST /query?live=true
The important boundary is simple: PgPaw serves reads from a local replica. Your application still owns writes, migrations, business logic, and upstream Postgres.
Prepare Postgres
pgpaw init is the guided setup command. It connects to upstream Postgres and
prepares logical replication.
It ensures:
wal_level=logicalmax_wal_senders >= 10max_replication_slots >= 10- publication
pgpaw_pubexists for all tables, unless you pass another name - a DDL event trigger is installed, when the user has permission
The replication slot is not created by init. PgPaw creates the configured
slot automatically when pgpaw serve starts.
Run the setup with a custom publication name:
pgpaw init \
--pg-host 127.0.0.1 \
--pg-user postgres \
--pg-password "$POSTGRES_PASSWORD" \
--pg-database myapp \
--publication app_read_pub
If you want a narrow publication, create it yourself first, then pass the same
name to pgpaw init and pgpaw serve:
create publication app_read_pub for table public.users, public.orders;
Tables that are not in the publication are not available through PgPaw.
Run PgPaw
Most deployments use pgpaw serve:
pgpaw serve \
--host 0.0.0.0 \
--port 8080 \
--data-dir /var/lib/pgpaw \
--pg-host postgres.internal \
--pg-port 5432 \
--pg-user pgpaw \
--pg-password "$PGPAW_POSTGRES_PASSWORD" \
--pg-database app \
--publication pgpaw_pub \
--slot pgpaw_slot \
--sslmode require
Use a persistent --data-dir. It stores the local pglite replica. If you
delete it, PgPaw must rebuild the replica from upstream.
For browser apps, set CORS explicitly:
pgpaw serve \
--cors-origin https://app.example.com \
--pg-database myapp
For local development, --cors-origin "*" is convenient. Avoid * for
production unless every response is intentionally public.
serve is also the default command, so this works:
pgpaw --pg-database myapp --port 8080
Use explicit subcommands in scripts because they make intent clearer:
pgpaw serve --pg-database myapp --port 8080
Native az-wire
Build PgPaw with the az-wire feature to add an independent native az-wire listener:
cargo run --features az-wire -- serve \
--host 127.0.0.1 \
--port 8080 \
--az-wire-host 127.0.0.1 \
--az-wire-port 8788 \
--az-wire-node pgpaw \
--pg-database myapp
--az-wire-port is optional and has no default. Without it, serve remains HTTP-only. When set,
Actix HTTP and native az-wire bind independent listeners over shared PgPaw state; az-wire traffic
never passes through Actix. Startup reports readiness only after both listeners bind, and failure of
either listener rolls back startup.
The native subjects are pgpaw.read, pgpaw.cursor, and pgpaw.live. They preserve the HTTP
read-only SQL, authorization, cache, cursor, and live-query semantics. Mutating SQL is rejected.
Embedded primary
Rust hosts compose PgPaw through one builder. PgSource::primary starts one writable embedded
PostgreSQL primary; primary_dsn() yields the DSN for a direct PostgreSQL pool. With the
az-wire feature, .az_wire(node, topology) exposes the same read-only and realtime services
over az-wire, for example as a listenerless child of a parent node:
let pgpaw = PgPaw::builder()
.source(PgSource::primary(EmbeddedPrimarySource::embedded("./primary-data")))
.auth(AuthConfig::jwt_secret(secret))
.az_wire(
az_wire::NodeBuilder::new("pgpaw").insecure_accept_declared_peer_identities(),
az_wire::TopologyConfig::parent(az_wire::ParentLink::unix(
"worldant",
"/tmp/worldant.sock",
)),
)
.open()
.await?;
let dsn = pgpaw.primary_dsn().unwrap();
The same builder serves the replica mode: PgSource::replica(ReplicaSource { .. }) plus an optional
.http(HttpConfig { .. }) binding and any number of .az_wire(..) bindings — every binding reaches
the same read/live capabilities. pgpaw.wait().await blocks until a binding stops; shutdown()
stops bindings first, then the source, then closes the database. The direct pool remains the
read/write database path; the az-wire binding is additive and creates no replica or second database.
HTTP API
| Method | Path | Purpose |
|---|---|---|
GET |
/healthz |
Check replica status and watermark. |
POST |
/query |
Run one read-only SQL query. |
GET |
/q/{hash}/{version} |
Fetch a cached public snapshot. |
POST |
/query?live=true |
Run a query and stream realtime changes. |
GET /healthz
Healthy response:
{ "status": "ok", "watermark": 12345678 }
Halted replica response:
{ "status": "halted", "reason": "replication error details" }
Use this endpoint for readiness checks. A halted replica returns HTTP 503.
POST /query
Request body:
{ "sql": "select id, email from users where id = 7" }
Public query response:
HTTP/1.1 303 See Other
Location: /q/{hash}/{version}
Cache-Control: no-store
Private query response:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Private queries are returned inline because they depend on the caller's token and must not enter the shared snapshot cache.
GET /q/{hash}/{version}
Snapshot response:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: {hash}:{version}
Cache-Control: public, max-age=259200
The body is a JSON array:
[
{ "id": 7, "email": "ada@example.com" }
]
If the cursor is unknown or has been evicted from PgPaw's in-process cache, the
server returns 404. Query /query again to get the current cursor.
Errors
Errors use a JSON envelope:
{
"name": "RejectedError",
"message": "only read-only SELECT queries are cacheable; writes and DDL are not supported"
}
Common status codes:
| Status | Meaning |
|---|---|
400 |
SQL parse error or rejected query. |
401 |
Missing, malformed, expired, or unverifiable bearer token. |
403 |
Postgres rejected the query under the caller's role. |
404 |
Snapshot cursor is unknown. |
503 |
Replica is halted. |
Realtime Streams
Use POST /query?live=true to open an SSE stream.
Public query first event:
data: {"type":"snapshot","url":"/q/{hash}/{version}","version":42}
Private query first event:
data: {"type":"snapshot","rows":[{"id":1,"status":"open"}],"version":42}
Delta events:
data: {"op":"insert","key":"7","row":{"id":7,"status":"open"},"txid":123}
data: {"op":"update","key":"7","row":{"id":7,"status":"paid"},"txid":124}
data: {"op":"delete","key":"7","txid":125}
data: {"op":"up-to-date","txid":125}
up-to-date marks the end of one transaction's diff cycle for that
subscription. The stream stays open.
If the live hub falls behind the internal replication broadcast, PgPaw emits:
data: {"op":"reset"}
After a reset, reconnect and request a fresh snapshot.
For the most stable live keys, use single-table queries that include a primary key. Multi-table queries and queries without a primary key use a content hash as the row key.
Authorization And RLS
PgPaw decides whether a query is public or private from the replicated schema:
- A table is public only when Row-Level Security is disabled and
PUBLIChasSELECTon that table. - A table is private when RLS is enabled or
PUBLIClacksSELECT. - A query is private if any table it touches is private.
Public queries need no token and use the shared snapshot cache. Private queries require a bearer token.
Configure exactly one JWT verification method:
pgpaw serve \
--jwt-secret "$JWT_SECRET" \
--jwt-role-claim role \
--pg-database myapp
Or use a PEM public key for RS256 or ES256 tokens:
pgpaw serve \
--jwt-public-key "$JWT_PUBLIC_KEY" \
--jwt-role-claim role \
--pg-database myapp
--jwt-jwks-url is present in the CLI but JWKS verification is not implemented
yet. Use --jwt-secret or --jwt-public-key.
The role claim defaults to role. PgPaw runs private queries under that
Postgres role. The full claims JSON is also available to RLS policies through
request.jwt.claims.
Example RLS policy:
create role member;
alter table documents enable row level security;
create policy documents_by_org on documents
for select
to member
using (
org_id = (current_setting('request.jwt.claims', true)::json->>'org_id')::int
);
Token example:
{
"role": "member",
"org_id": 7,
"exp": 1893456000
}
Private request example:
curl -i -X POST http://127.0.0.1:8080/query \
-H "content-type: application/json" \
-H "authorization: Bearer $TOKEN" \
-d '{"sql":"select id, title from documents order by id"}'
SQL Rules
PgPaw accepts one read-only PostgreSQL SELECT statement over replicated
tables.
Accepted examples:
select id, email from users where id = 7;
select u.id, u.email, count(o.id) as order_count
from users u
left join orders o on o.user_id = u.id
where u.id = 7
group by u.id, u.email;
Rejected input includes:
- multiple statements, such as
select 1; select 2; - writes:
insert,update,delete,merge; - DDL:
create,alter,drop; - locking reads:
for update,for share; select into;- references to tables outside the publication;
- side-effecting functions such as
nextval,setval, and advisory lock functions; - volatile functions such as
now,random,clock_timestamp,statement_timestamp,gen_random_uuid, anduuid_generate_v4.
For cache precision, PgPaw recognizes equality filters such as:
where id = 7
On a single table, equality on a primary key anchors the query version to that
row. If the table uses REPLICA IDENTITY FULL, equality on any replicated
column can be used as an anchor. Other queries fall back to table-level
invalidation.
Caching Model
PgPaw computes a fingerprint from the parsed SQL and combines it with a replication-derived version:
cache key = sql_fingerprint + ":" + version
The version changes when logical replication reports a relevant upstream commit. This gives PgPaw two useful properties:
- The same SQL returns the same snapshot cursor while relevant data is unchanged.
- A relevant upstream write produces a new cursor.
Public snapshots are stored in PgPaw's in-process query cache and served through
GET /q/{hash}/{version} with:
Cache-Control: public, max-age=259200
The server-side cache is bounded by --cache-size-bytes. If a snapshot is
evicted, the old cursor returns 404; clients should call /query again.
Configuration
All common flags have matching environment variables.
pgpaw init
| Flag | Env | Default | Description |
|---|---|---|---|
--pg-host |
UPSTREAM_HOST |
127.0.0.1 |
Upstream Postgres host. |
--pg-port |
UPSTREAM_PORT |
5432 |
Upstream Postgres port. |
--pg-user |
UPSTREAM_USER |
postgres |
Upstream Postgres user. |
--pg-password |
UPSTREAM_PASSWORD |
empty | Upstream Postgres password. |
--pg-database |
UPSTREAM_DATABASE |
postgres |
Upstream database. |
--publication |
UPSTREAM_PUBLICATION |
pgpaw_pub |
Publication to create or verify. |
pgpaw serve
| Flag | Env | Default | Description |
|---|---|---|---|
--host |
PGPAW_HOST |
127.0.0.1 |
PgPaw HTTP bind host. |
--port |
PGPAW_PORT |
8080 |
PgPaw HTTP bind port. |
--data-dir |
PGPAW_DATA_DIR |
./cache-data |
Local pglite replica directory. |
--max-connections |
PGPAW_MAX_CONNECTIONS |
8 |
Local replica connection pool size. |
--cache-size-bytes |
PGPAW_CACHE_SIZE_BYTES |
268435456 |
Query cache byte budget. |
--pg-host |
UPSTREAM_HOST |
127.0.0.1 |
Upstream Postgres host. |
--pg-port |
UPSTREAM_PORT |
5432 |
Upstream Postgres port. |
--pg-user |
UPSTREAM_USER |
postgres |
Upstream Postgres user. |
--pg-password |
UPSTREAM_PASSWORD |
empty | Upstream Postgres password. |
--pg-database |
UPSTREAM_DATABASE |
postgres |
Upstream database. |
--publication |
UPSTREAM_PUBLICATION |
pgpaw_pub |
Publication to replicate. |
--slot |
UPSTREAM_SLOT |
pgpaw_slot |
Logical replication slot name. |
--sslmode |
UPSTREAM_SSLMODE |
disable |
disable, prefer, require, or verify-full. |
--jwt-secret |
JWT_SECRET |
unset | HS256 verification secret. |
--jwt-public-key |
JWT_PUBLIC_KEY |
unset | RS256 or ES256 PEM public key. |
--jwt-jwks-url |
JWT_JWKS_URL |
unset | Reserved; not implemented yet. |
--jwt-role-claim |
JWT_ROLE_CLAIM |
role |
Claim containing the Postgres role. |
--cors-origin |
CORS_ORIGIN |
unset | Browser origin, comma-separated origins, or *. |
pgpaw primary
primary runs an embedded writable Postgres over TCP. It is not required for
normal upstream-replica mode.
pgpaw primary \
--data-dir ./primary-data \
--primary-listen 127.0.0.1 \
--primary-port 5432
| Flag | Env | Default | Description |
|---|---|---|---|
--data-dir |
PGPAW_DATA_DIR |
./cache-data |
Embedded Postgres data directory. |
--max-connections |
PGPAW_MAX_CONNECTIONS |
8 |
Connection pool size. |
--primary-listen |
PRIMARY_LISTEN |
127.0.0.1 |
TCP listen address. |
--primary-port |
PRIMARY_PORT |
5432 |
TCP port. |
Logging
PgPaw writes operational logs to stderr in logfmt-style lines. It logs at
INFO, WARN, and ERROR; it does not require DEBUG logs to understand
whether the service is working.
Startup logs show the bind address, HTTP URL, upstream Postgres target, publication, replication slot, data directory, cache size, auth state, schema scan counts, CDC startup, and readiness:
ts=2026-06-29T10:00:07.656Z level=INFO pid=65538 target=pgpaw event=command_start command=serve
ts=2026-06-29T10:00:07.657Z level=INFO pid=65538 target=pgpaw::source::replica event=preflight_start upstream_host=127.0.0.1 upstream_port=5432 upstream_user=postgres upstream_database=myapp publication=pgpaw_pub
ts=2026-06-29T10:00:07.940Z level=INFO pid=65538 target=pgpaw::source::replica event=replication_started result=ok
ts=2026-06-29T10:00:08.120Z level=INFO pid=65538 target=pgpaw::binding::http::server event=http_server_listening bind_addr=127.0.0.1:8080 url=http://127.0.0.1:8080
The target= field is derived from the Rust module path and follows the module layout
(pgpaw::source::*, pgpaw::capability::*, pgpaw::binding::http::*); key log lines off
event= names, which are stable.
Request and query logs show access events, query classification, public/private decision, snapshot cursor/version, cache hits, live subscriptions, CDC transactions, and health checks:
ts=2026-06-29T10:00:10.001Z level=INFO pid=65538 target=pgpaw::binding::http::query event=query_classified fingerprint=9a4f tables=users live=false scope=public
ts=2026-06-29T10:00:10.002Z level=INFO pid=65538 target=pgpaw::capability::cache event=query_cache_get_or_compute result=hit key=9a4f:42 bytes=128
ts=2026-06-29T10:00:10.003Z level=INFO pid=65538 target=pgpaw::binding::http::query event=query_snapshot scope=public fingerprint=9a4f tables=users version=42 cursor=/q/9a4f/42 response=redirect snapshot_bytes=128
ts=2026-06-29T10:00:10.004Z level=INFO pid=65538 target=actix_web::middleware::logger event=http_request remote_addr=127.0.0.1 request="POST /query HTTP/1.1" status=303 response_bytes=0 duration_ms=2 user_agent="curl/8.7.1"
Warnings and errors are logged for rejected SQL, missing tokens, cursor misses, replica halt, DDL-trigger setup failures, bind failures, and other operator action items.
PgPaw intentionally does not log raw bearer tokens, Postgres passwords, or raw SQL text. Query logs use the SQL fingerprint and table list so production logs stay useful without exposing sensitive query literals.
Capture logs with your process supervisor:
pgpaw serve ... 2>&1 | tee pgpaw.log
With systemd:
journalctl -u pgpaw -f
With Docker:
docker logs -f <container>
Operations
Production checklist:
- Keep
--data-diron persistent storage. - Run
pgpaw initbefore the firstserve. - Restart Postgres when
initsays WAL settings changed. - Use a dedicated upstream Postgres user for PgPaw in production.
- Set
--cors-originto exact browser origins. - Configure JWT verification before serving private RLS-protected data.
- Monitor
/healthz; treat503as not ready. - Size
--cache-size-bytesfor your hot public snapshots. - If the DDL event trigger was not installed, restart PgPaw after schema changes that affect queried tables.
Schema changes:
- With
CREATE PUBLICATION ... FOR ALL TABLES, new tables are automatically part of the publication. - With a narrow publication, add new tables manually:
alter publication pgpaw_pub add table public.new_table;
Security changes:
- Enabling RLS or revoking
PUBLIC SELECTmakes affected queries private. - Granting
PUBLIC SELECTand disabling RLS makes affected queries public. - PgPaw checks table privacy from the replicated schema, so allow replication to catch up before expecting new behavior.
Troubleshooting
wal_level must be logical
Run:
pgpaw init --pg-database myapp
Then restart Postgres if init changed WAL settings.
Publication does not exist
Run pgpaw init with the same --publication that pgpaw serve uses, or pass
an existing publication name to both commands.
Table is not available in this cache
The table is not present in the replicated publication, or PgPaw has not caught up yet. Add the table to the publication and wait for replication.
Private query returns 401
The query touches a table with RLS enabled or without PUBLIC SELECT. Send a
valid bearer token and configure PgPaw with --jwt-secret or
--jwt-public-key.
Token is presented but JWT verification is not configured
Start PgPaw with one JWT verification source. If all data is public, do not send
an Authorization header.
--jwt-jwks-url fails at startup
JWKS verification is not implemented yet. Use --jwt-secret or
--jwt-public-key.
Old snapshot URL returns 404
The snapshot may have been evicted from PgPaw's in-process cache. Repeat the
POST /query request to get the current cursor.
Live stream emits reset
Reconnect and request a fresh snapshot. reset means PgPaw intentionally
dropped the subscription because it could not safely continue diffing from the
previous state.
Development
Build and check the Rust workspace:
cargo check --workspace
cargo test --workspace
Build the CLI:
cargo build --bin pgpaw
Run the Node integration tests from integration-tests/node after building the
release binary they expect:
cargo build --release --bin pgpaw
cd integration-tests/node
pnpm install
pnpm test
Example apps live in:
examples/nextjs-todosexamples/nextjs-project-board
License
MIT. See LICENSE.
Dependencies
~7–33MB
~407K SLoC