pg-infra
Safe, typed PostgreSQL application infrastructure built on psycopg 3.
This is not a thin wrapper. It turns raw psycopg into the pieces every app rebuilds by hand — cursor pagination, set-based bulk writes, full-text and fuzzy search, and transactions that retry correctly under serialization failures — with a sync and an async API in parallel.
- Injection-safe by construction. Every table, column, and value is composed through
psycopg.sql(Identifier/Placeholder). The SQL builders are pure and never use f-strings or%-formatting to assemble queries. - Composes with your transactions. Capability functions take a live
Connection/AsyncConnectionyou pass in; they never open hidden transactions except where documented (fuzzy search, which needs a per-query GUC). - Honest about guarantees. Keyset pagination requires a unique tiebreaker (documented and validated). Retries fire only on SQLSTATE
40001/40P01, never blindly. Errors are wrapped, never swallowed.
Requires Python ≥ 3.11 and PostgreSQL. Fuzzy search needs the pg_trgm extension.
Install
pip install pg-infra # includes psycopg[binary,pool]
Quick start
from pg_infra import PgConfig, PgClient, paginate, bulk_insert, transaction
cfg = PgConfig(host="localhost", dbname="app", user="app", password="secret")
client = PgClient.from_config(cfg)
with client.connection() as conn:
# Bulk insert (executemany under the hood)
bulk_insert(conn, "users", ["name", "email"], [("Ada", "ada@x.io"), ("Alan", "alan@x.io")])
# Keyset pagination — O(page) no matter how deep
page = paginate(conn, "users", order_by=[("created_at", "DESC"), ("id", "DESC")], limit=50)
for row in page.items:
...
if page.has_more:
next_page = paginate(
conn,
"users",
order_by=[("created_at", "DESC"), ("id", "DESC")],
limit=50,
cursor=page.next_cursor,
)
client.close()
Config from the environment
cfg = PgConfig.from_env(
"PG_"
) # PG_HOST, PG_PORT, PG_DBNAME, PG_USER, PG_PASSWORD, PG_CONNINFO, ...
No credentials live in source: pass them in or read them from the environment.
Capabilities
| Area | Sync | Async | Notes |
|---|---|---|---|
| Config | PgConfig |
— | frozen, validated, from_env |
| Pool / connections | PgClient, pg_pool |
AsyncPgClient, async_pg_pool |
psycopg's own pools; per-connection statement_timeout |
| Transactions | transaction |
atransaction |
isolation / read-only / deferrable |
| Safe retries | retry_on_serialization_failure |
aretry_on_serialization_failure |
40001/40P01 only, backoff + jitter |
| Keyset pagination | paginate |
apaginate |
opaque cursor, LIMIT n+1 for has_more |
| Offset pagination | paginate_offset |
apaginate_offset |
fallback; degrades with depth |
| Bulk insert | bulk_insert |
abulk_insert |
executemany, optional RETURNING |
| Upsert | upsert |
aupsert |
ON CONFLICT DO UPDATE/DO NOTHING |
| Bulk update | bulk_update |
abulk_update |
by key column |
| COPY load | copy_load |
acopy_load |
fastest path, no RETURNING |
| Full-text search | search.search / fts_search |
search.asearch / afts_search |
tsvector/tsquery, ts_rank |
| Fuzzy search | fuzzy.search / trgm_search |
fuzzy.asearch / atrgm_search |
pg_trgm similarity |
Timeouts
Three distinct layers, deliberately separate:
connect_timeout→ libpq connect timeout (in the conninfo).statement_timeout_ms→ Postgresstatement_timeoutGUC, applied per connection by the pool'sconfigurehook.pool_timeout→ how long to wait for a free pooled connection.
Async
Every capability has an a-prefixed async twin with an identical signature; only await differs.
from pg_infra import AsyncPgClient, apaginate
client = await AsyncPgClient.from_config(cfg)
async with client.connection() as conn:
page = await apaginate(conn, "users", order_by=[("id", "ASC")], limit=100)
await client.aclose()
On Windows, psycopg's async connections cannot run on the default ProactorEventLoop; start your entry point on a selector loop instead. async_pg_pool raises ConnectionError saying so rather than letting the pool time out.
asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop) # 3.12+
Guarantees and non-guarantees
- Keyset pagination is correct only with a unique tiebreaker. End
order_bywith a unique column (usually the primary key), or pages can skip or repeat rows. Theorder_bycolumns must be in the selectedcolumns(rows come back as dicts so the cursor can read them). - Cursors are opaque, not secret and not tamper-proof. They are base64-wrapped JSON; a corrupt or tampered cursor raises
PaginationErrorrather than being trusted. Sort keys may be JSON scalars ordatetime/date/time/Decimal/UUID/bytes, which round-trip as their original Python type; anything else raisesPaginationError. - Isolation must be set before the connection's first query. PostgreSQL accepts
SET TRANSACTIONonly as a transaction's opening statement, sotransaction(conn, isolation=...)requires an idle connection and raisesTransactionErrorotherwise. Commit, roll back, or take a fresh connection first. - Retries are not blind.
retry_on_serialization_failurere-runs only on serialization failure (40001) and deadlock (40P01); every other error propagates immediately. The decorated function must open its own transaction so each attempt is a clean re-run. - No hidden COPY↔executemany switch. You pick
copy_load(fast, noRETURNING) or theexecutemanywriters explicitly; behaviour never changes silently as row counts grow. - Full-text search picks exactly one source. Provide
text_columns(built on the fly, zero setup) ortsvector_column(precomputed + GIN-indexed, recommended for production) — never both.
Development
pip install -e ".[dev]"
ruff check . && ruff format --check .
mypy src/pg_infra
pytest tests/unit # pure unit tests, no database required
# integration tests (need a live PostgreSQL) are marked `integration` and deferred
License
MIT
Release files for pg-infra 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pg_infra-0.1.0.tar.gz | 23.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pg_infra-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 46.7 kB
Release files / pg_infra-0.1.0.tar.gz
| Download URL | pg_infra-0.1.0.tar.gz |
|---|---|
| Size | 23.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d232479e028ca6d0baf360c4bedaaf92038564e957f98df3999704d058d92b10
|
|
BLAKE2b-256 checksum How to use checksums |
30e7bf5e66f3d5b7377c1e7002a2b5a5b31a8f8fcb8a5270fb656d7062e9643c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|
Release files / pg_infra-0.1.0-py3-none-any.whl
| Download URL | pg_infra-0.1.0-py3-none-any.whl |
|---|---|
| Size | 23.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6af475baf3122b0234d14ee873f68129b2fffcc00c86bf800dab9e5dd4f2802f
|
|
BLAKE2b-256 checksum How to use checksums |
5800c15eaff066b42b6d280f354e38806137f5fea31e8349b2b29966736e362e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|