pgmesh
An async orchestration layer for applications that talk to many PostgreSQL databases.
Register your databases once, address them by index or label, and let pgmesh own the connection pools, the parallelism, the timeouts and the failure isolation.
from pgmesh import PGCluster
async with PGCluster({
1: "postgresql://user:pass@db1/app",
2: "postgresql://user:pass@db2/app",
"analytics": "postgresql://user:pass@db3/analytics",
}, max_concurrency=10, query_timeout=5) as db:
users = await db.connection(1).execute("SELECT * FROM users LIMIT 10")
results = await db.parallel([
(1, "SELECT count(*) FROM users"),
(2, "SELECT count(*) FROM orders"),
("analytics", "SELECT count(*) FROM events"),
])
The problem
An application with more than one PostgreSQL database ends up hand-rolling the same
infrastructure every time: a connection string per database, a pool per database, some
routing logic, an asyncio.gather for the fan-out, a timeout that half-works, and a
try/except that lets one dead tenant take down an endpoint that never needed it.
Application
|
v
pgmesh
|
+---- DB 1 / tenant_a Pool(min=1, max=10)
+---- DB 2 / tenant_b Pool(min=1, max=10)
+---- DB 3 / analytics Pool(min=1, max=10)
+---- DB 4 / tenant_c Pool(min=1, max=10)
pgmesh is that layer, and nothing more. It is not a proxy, a driver, a sharding
engine, or a query rewriter. Routing is explicit, statements go straight to
asyncpg, and there is no server to run.
Install
pip install pgmesh
Python 3.10+. One runtime dependency: asyncpg.
Quickstart
Register
Both integer indexes and string labels work, and they share one namespace — 1 and
"1" address the same database.
from pgmesh import PGCluster
db = PGCluster({
1: "postgresql://user:pass@db1/app",
"analytics": "postgresql://user:pass@db3/analytics",
})
Connection strings come from your configuration or environment. pgmesh never reads them itself and never manages your secrets.
Query one database
rows = await db.connection(1).execute("SELECT * FROM users WHERE status = $1", "active")
row = await db.connection(1).fetchrow("SELECT * FROM users WHERE id = $1", 42)
count = await db.connection("analytics").fetchval("SELECT count(*) FROM events")
tag = await db.connection(1).command("UPDATE users SET seen = now()") # 'UPDATE 3'
Parameters are bound server-side as $1, $2, … — they are never interpolated into the
statement text.
| Method | Returns |
|---|---|
execute(sql, *args) / fetch(...) |
every row, as a list |
fetchrow(sql, *args) |
the first row, or None |
fetchval(sql, *args) |
the first column of the first row |
command(sql, *args) |
PostgreSQL's status tag, e.g. 'UPDATE 3' |
executemany(sql, args_seq) |
None — one execution per parameter tuple |
explain(sql, *args) |
the query plan, one string per line |
Query many databases at once
results = await db.parallel([
(1, "SELECT count(*) FROM users"),
(2, "SELECT count(*) FROM orders WHERE status = $1", ["open"]),
("analytics", "SELECT count(*) FROM events"),
])
You get back a dict keyed by database:
results[1].value # rows from database 1
results.successes # {database: rows} for the ones that worked
results.failures # {database: error} for the ones that did not
results.all_ok # bool
Same statement everywhere:
await db.parallel_map("SELECT count(*) FROM users")
await db.parallel_map("SELECT count(*) FROM users", databases=[1, 2])
Lifecycle
async with PGCluster(databases) as db:
...
# every pool is closed on the way out
Pools are created on first use. Call await db.startup() to open them all up front, so
a bad connection string fails at boot rather than inside the first request. await db.close() is idempotent and safe in a finally.
What it guarantees
One pool per database, reused
Every registered database gets its own asyncpg pool. A new TCP connection is never
opened per query.
Query → acquire from pool → execute → release back to pool
Bounded concurrency
max_concurrency caps how many operations are in flight in a single parallel() call.
Submit 100 operations against a limit of 20 and 20 run while 80 queue; slots free up as
work finishes. pgmesh never spawns an unbounded number of tasks.
db = PGCluster(databases, max_concurrency=20)
await db.parallel(operations, max_concurrency=5) # or per call
Timeouts that release the connection
db = PGCluster(databases, query_timeout=5) # cluster default
await db.connection(1).execute(sql, timeout=0.5) # per query
A statement that overruns raises QueryTimeoutError, and its connection goes straight
back to the pool — a timeout can never strand a connection or drain a pool.
Failure isolation
One database being slow, down, or misconfigured does not cancel operations against the others.
results = await db.parallel_map("SELECT count(*) FROM users")
for database, result in results.items():
if result.ok:
print(database, result.value)
else:
print(database, "failed:", result.error) # QueryTimeoutError, ...
Every entry is a Success(database, value) or a Failure(database, error), so you can
always tell which database failed and why. Nothing is raised unless you ask:
results.raise_for_failures() # after the fact
await db.parallel(ops, raise_on_error=True) # or up front — still runs everything
Errors you can actually catch
Driver exceptions are translated into one documented hierarchy, with the original
attached as __cause__ and .original:
PGMeshError
├── DatabaseNotFoundError unknown id or label
├── DatabaseConfigurationError bad identifier, DSN or option
├── DatabaseConnectionError pool or connection failure
├── QueryExecutionError the server rejected the statement
├── QueryTimeoutError exceeded its timeout budget
└── ClusterClosedError used after close()
DatabaseNotFoundError is also a KeyError and QueryTimeoutError is also a
TimeoutError, so existing error handling keeps working.
No secrets in your logs
Passwords never appear in log lines, reprs, or exception messages — anywhere a DSN might surface, it is masked first.
>>> from pgmesh import mask_dsn
>>> mask_dsn("postgresql://user:hunter2@db:5432/app")
'postgresql://user:***@db:5432/app'
Beyond the basics
Transactions
async with db.connection(1).transaction() as conn:
await conn.execute("INSERT INTO orders(total) VALUES ($1)", 99)
await conn.execute("UPDATE stock SET n = n - 1 WHERE sku = $1", "abc")
# commits on a clean exit, rolls back on any exception
Transactions are per-database. pgmesh does not do distributed transactions, and does not pretend to.
Raw connections
For anything pgmesh does not wrap — COPY, cursors, LISTEN/NOTIFY:
async with db.connection(1).acquire() as conn:
await conn.copy_to_table("users", source=path)
The connection is always released, including when the body raises.
Query plans
Ask for a plan directly:
plan = await db.connection(1).explain("SELECT * FROM users WHERE email = $1", "a@b.com")
for line in plan:
print(line)
Or turn on plan capture for every query — off by default:
db = PGCluster(databases, explain=True)
With the flag on, each statement's plan is logged to the pgmesh logger at INFO
before the statement runs, on the same connection so the plan describes the same session.
Two things worth knowing:
- The flag uses plain
EXPLAIN, which plans a statement without executing it. YourINSERTreaches the server exactly once. This is deliberate —EXPLAIN ANALYZEdoes execute, so using it here would double-apply every write. explain(sql, analyze=True)switches toEXPLAIN (ANALYZE, BUFFERS, VERBOSE)for real timings. That does execute the statement. On a write, wrap it in a transaction you roll back.
Plan capture costs an extra round trip per query, so it's a debugging aid rather than
something to leave on in production. A statement PostgreSQL can't explain (VACUUM,
SET) is skipped silently — a diagnostic never becomes the reason a query fails.
Health
await db.health() # {1: True, 2: False, "analytics": True}
await db.connection(1).ping() # True / False, never raises
Inspection
db.databases # [1, 2, "analytics"]
1 in db # True
db[1] # same as db.connection(1)
db.describe() # per-database config, passwords masked
Tuning
PGCluster(
databases,
max_concurrency=20, # ops in flight per parallel() call
query_timeout=5, # seconds; None for no client-side limit
connect_timeout=10, # seconds to open a connection
pool_min_size=1,
pool_max_size=10, # per database
explain=False, # log a query plan for every statement
connect_kwargs={"ssl": "require"}, # passed through to asyncpg
)
Sizing note: the cluster-wide ceiling is
len(databases) × pool_max_size. Check it against your server'smax_connectionsbefore registering many databases.
With FastAPI
Build the cluster once at startup and share it across requests — never one per request, which would mean one pool per request.
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from pgmesh import PGCluster
@asynccontextmanager
async def lifespan(app: FastAPI):
cluster = PGCluster(load_databases(), query_timeout=5)
app.state.cluster = cluster
await cluster.startup()
try:
yield
finally:
await cluster.close()
app = FastAPI(lifespan=lifespan)
@app.get("/users/{database}")
async def users(database: int, request: Request):
rows = await request.app.state.cluster.connection(database).execute(
"SELECT * FROM users LIMIT 10"
)
return [dict(r) for r in rows]
A complete service — dependency wiring, fan-out endpoint, and the pgmesh error hierarchy
mapped onto HTTP status codes — is in examples/fastapi_demo/main.py.
cp .env.example .env
./run.sh # http://127.0.0.1:8000/docs
Development
git clone https://github.com/Mayuradlak123/pgmesh
cd pgmesh
./setup.sh # or: uv sync
uv run pytest # unit tests — no PostgreSQL needed
uv run ruff check .
uv run mypy
The unit suite runs against a fake driver, so it is fast and hermetic. Integration tests need a real server:
docker compose up -d
PGMESH_TEST_DSN=postgresql://postgres:postgres@localhost:5432/postgres \
uv run pytest -m integration
Releasing
Publishing runs on GitHub Actions via PyPI Trusted Publishing — no API token lives in the repo.
- Bump
versioninpyproject.tomland__version__insrc/pgmesh/__init__.py. - Merge to
mainand let CI pass. - Publish a GitHub Release tagged
vX.Y.Z.
The workflow refuses to publish if the tag and the two versions disagree, then builds the
wheel and sdist, runs twine check, and uploads.
Scope
In: registration, id/label routing, one pool per database, query execution, bounded parallel execution, timeouts, failure isolation, a clean error model.
Out, deliberately: proxying, SQL parsing, distributed transactions, cross-database joins, automatic sharding, query rewriting, replication management. Retries, circuit breakers, structured logging and metrics are on the roadmap, not in v0.1.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pgmesh-0.2.0.tar.gz.
File metadata
- Download URL: pgmesh-0.2.0.tar.gz
- Upload date:
- Size: 34.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
796454db292a533e951154f20cbc3901436ed82fd78c8da519367659ef38fbf7
|
|
| MD5 |
bbc479a65e5f8ad9a936b3db902e4671
|
|
| BLAKE2b-256 |
0d075860bda13af6c63781c909d7e1db7b8a57c901993725e3445db4ae030f83
|
Provenance
The following attestation bundles were made for pgmesh-0.2.0.tar.gz:
Publisher:
publish.yml on Mayuradlak123/pgmesh
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pgmesh-0.2.0.tar.gz -
Subject digest:
796454db292a533e951154f20cbc3901436ed82fd78c8da519367659ef38fbf7 - Sigstore transparency entry: 2453396737
- Sigstore integration time:
-
Permalink:
Mayuradlak123/pgmesh@90c84ac3a0733c7995919a5e789c63ed3130e6fe -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Mayuradlak123
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@90c84ac3a0733c7995919a5e789c63ed3130e6fe -
Trigger Event:
release
-
Statement type:
File details
Details for the file pgmesh-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pgmesh-0.2.0-py3-none-any.whl
- Upload date:
- Size: 26.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
46c67c8442e27eacbcccf6e2ac908cc3920189ec53fe7a65ed9c16396626724a
|
|
| MD5 |
351ee6c35ba6a7ccf6adb7853b43ba2b
|
|
| BLAKE2b-256 |
229e3286285cbdda85b0efd98a0c3d3ebf3c721ece782df8243fe96edd46268d
|
Provenance
The following attestation bundles were made for pgmesh-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on Mayuradlak123/pgmesh
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pgmesh-0.2.0-py3-none-any.whl -
Subject digest:
46c67c8442e27eacbcccf6e2ac908cc3920189ec53fe7a65ed9c16396626724a - Sigstore transparency entry: 2453396818
- Sigstore integration time:
-
Permalink:
Mayuradlak123/pgmesh@90c84ac3a0733c7995919a5e789c63ed3130e6fe -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Mayuradlak123
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@90c84ac3a0733c7995919a5e789c63ed3130e6fe -
Trigger Event:
release
-
Statement type: