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
Metadata
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 | Provenance | Actions |
|---|---|---|---|---|
| pg_infra-0.1.0.tar.gz | 23.2 kB | Without provenance | Details for pg_infra-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pg_infra-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pg_infra-0.1.0-py3-none-any.whl |
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
|
Security for pg-infra 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.