Async PostgreSQL for Python, built in Rust
PostPyro wraps sqlx in a PyO3/pyo3-asyncio binding. Every I/O method
is async def and releases the GIL while waiting on Postgres, parameters bind
natively — datetimes, UUIDs, Decimal, JSON, arrays, bytes — and errors surface
through a DB-API 2.0-flavored exception hierarchy.
import asyncio
import PostPyro
async def main():
pool = await PostPyro.connect("postgresql://user:pass@localhost/db")
users = await pool.query(
"SELECT * FROM users WHERE active = $1", [True]
)
for user in users:
print(f"User: {user['name']} ({user['email']})")
tx = await pool.transaction()
async with tx:
await tx.execute(
"INSERT INTO orders (user_id) VALUES ($1)", [user_id]
)
await tx.execute("UPDATE inventory SET stock = stock - 1")
await pool.close()
asyncio.run(main())
Key features
Everything the driver handles for you, out of the box.
Rust core
The driver is a PyO3 extension module — query execution and type conversion happen in compiled Rust, not interpreted Python.
Memory safe
Rust's ownership system rules out the leaks and segfaults hand-rolled C bindings are prone to.
Broad type support
Booleans, numerics, text, dates/times, UUIDs, Decimal, JSON/JSONB, BYTEA, and arrays convert automatically — see the type reference.
Fully async
Every I/O method is async def, built on sqlx's pooling and the Tokio runtime, releasing the GIL while waiting on Postgres.
Native parameter binding
datetime, uuid.UUID, Decimal, dict, homogeneous list/tuple (as a real array), and bytes bind directly — no manual ::type cast in the SQL text.
DB-API 2.0 exceptions
Standard exception hierarchy (IntegrityError, OperationalError, ...) mapped from Postgres SQLSTATE codes.
Installation
Pre-release
This page documents the async 2.0.0-dev API (python/PostPyro/__init__.pyi in the repo). It hasn't shipped to PyPI yet — pip install PostPyro currently installs the older, synchronous 1.0.0 driver, which doesn't have this API. Build from source below to use the async driver described here.
From PyPI (1.0.0, sync — not this API)
pip install PostPyro
Build from source (async, this API)
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Clone and build
git clone https://github.com/magi8101/PostPyro.git
cd PostPyro
pip install -e .
Requirements
- Python 3.8+
- PostgreSQL 9.6+ (server)
- Rust 1.70+ (for building from source)
Quick start
import asyncio
import PostPyro
async def main():
pool = await PostPyro.connect("postgresql://user:password@localhost:5432/mydb")
await pool.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
age INTEGER
)
""")
affected = await pool.execute(
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3)",
["Alice", "alice@example.com", 30]
)
print(f"Inserted {affected} rows")
await pool.close()
asyncio.run(main())
# Query multiple rows
users = await pool.query("SELECT * FROM users WHERE age > $1", [25])
for user in users:
print(f"ID: {user['id']}, Name: {user['name']}, Age: {user['age']}")
# Query a single row - raises ProgrammingError on zero matches;
# extra matches beyond the first are silently discarded
user = await pool.query_one("SELECT * FROM users WHERE id = $1", [1])
print(f"Found user: {user['name']}")
# There's no execute_batch() - run each statement individually
for name, age in [("Bob", 25), ("Charlie", 35), ("Diana", 28)]:
await pool.execute(
"INSERT INTO users (name, age) VALUES ($1, $2)", [name, age]
)
if not pool.is_closed():
print("Pool is open")
# pool.transaction() is a real BEGIN round-trip, so it must be
# awaited before it can be used as a context manager -
# `async with pool.transaction():` raises TypeError.
tx = await pool.transaction()
async with tx:
await tx.execute("INSERT INTO users (name) VALUES ($1)", ["Alice"])
await tx.execute("UPDATE accounts SET balance = balance + 100 WHERE user_id = $1", [1])
# Commits automatically on success, rolls back on exception
# Manual transaction management also works outside a `with` block
tx = await pool.transaction()
try:
await tx.execute("INSERT INTO orders (user_id, amount) VALUES ($1, $2)", [1, 99.99])
await tx.commit()
except Exception as e:
await tx.rollback()
print(f"Transaction failed: {e}")
# Non-primitive types bind natively - no str() + ::type cast needed.
import uuid
from datetime import datetime, timezone
from decimal import Decimal
await pool.execute(
"INSERT INTO events (at, uid, price, meta, blob) VALUES ($1,$2,$3,$4,$5)",
[
datetime.now(timezone.utc), # -> TIMESTAMPTZ
uuid.uuid4(), # -> UUID
Decimal("9.99"), # -> NUMERIC, exact
{"ok": True, "tags": ["a", "b"]},# -> JSONB
b"\x00payload", # -> BYTEA
],
)
# A homogeneous list/tuple binds as a real Postgres array
await pool.execute(
"INSERT INTO tags (id, names, scores) VALUES ($1, $2, $3)",
[1, ["red", "blue"], [10, 20, None]], # None -> NULL array element
)
# Reading back decodes to the right Python type automatically
row = await pool.query_one("SELECT * FROM events WHERE id = $1", [1])
assert isinstance(row["at"], datetime)
API reference
Module functions
await PostPyro.connect(dsn, max_size=10, min_size=0)Create a connection pool. This is the only way to get a Pool — there's no synchronous constructor, since establishing the first connection is itself async.
Parameters
dsn(str) — PostgreSQL connection stringmax_size(int, optional) — maximum pool connections (default 10)min_size(int, optional) — minimum pool connections (default 0)
Example
pool = await PostPyro.connect("postgresql://user:pass@localhost:5432/mydb", max_size=20)
PostPyro.__version__ / .apilevel / .threadsafety / .paramstyleModule-level constants. No get_version() function — read __version__ directly.
Example
print(PostPyro.__version__) # driver version
print(PostPyro.apilevel) # "2.0"
print(PostPyro.paramstyle) # "numeric" - $1, $2, ... placeholders
Pool class
Obtained via await PostPyro.connect(...). Pool and the old ConnectionPool class were unified — there's no separate Connection class to construct.
await pool.execute(query, params=None)Run INSERT, UPDATE, DELETE, or DDL. Returns the number of affected rows (int).
Example
affected = await pool.execute("UPDATE users SET age = $1 WHERE name = $2", [31, "Alice"])
await pool.query(query, params=None)Run a SELECT and return all matching rows as a list of Row.
Example
rows = await pool.query("SELECT id, name FROM users WHERE age > $1", [25])
await pool.query_one(query, params=None)Run a SELECT and return its first row.
Raises
ProgrammingError on zero rows. Extra matches beyond the first are silently discarded — only use this when the query itself guarantees at most one row.
Example
user = await pool.query_one("SELECT * FROM users WHERE id = $1", [1])
await pool.transaction()Start a transaction (a real BEGIN). Await it, then use the result as async with — async with pool.transaction(): raises TypeError since the un-awaited coroutine has no __aenter__.
Example
tx = await pool.transaction()
async with tx:
await tx.execute("INSERT INTO users (name) VALUES ($1)", ["Alice"])
await pool.close()Close the pool and free its connections.
pool.is_closed()Synchronous — no await. Returns bool.
Row class
A single query result row with a dict-like interface.
row[key]Access by column name or index. Negative indices work like a Python list.
Example
row = await pool.query_one("SELECT id, name, email FROM users WHERE id = $1", [1])
print(row['name'])
print(row[0]) # id
print(row[-1]) # last column
row.to_dict() / .keys() / .values() / .items()keys()/values()/items() return plain lists, not dict views. to_dict() gives a real dict for pandas/json.dumps.
Example
user_dict = row.to_dict() # {'id': 1, 'name': 'Alice', ...}
Transaction class
Obtained via await pool.transaction(). Using it after commit/rollback raises ProgrammingError rather than hanging.
await tx.execute(...) / await tx.query(...) / await tx.query_one(...)Same semantics as the Pool methods, scoped to the transaction.
await tx.commit() / await tx.rollback() / tx.is_active()Explicit commit/rollback — also work outside a with block. is_active() is synchronous, False after commit/rollback.
Error handling
Postgres/sqlx errors map onto a DB-API 2.0-flavored hierarchy, so you catch by class instead of parsing SQLSTATE yourself.
Example
try:
await pool.execute("INSERT INTO users (email) VALUES ($1)", ["invalid-email"])
except PostPyro.IntegrityError as e:
print(f"Constraint violation: {e}")
except PostPyro.DatabaseError as e:
print(f"Database error: {e}")
Type system
PostPyro converts between Python and PostgreSQL types automatically in both directions.
| PostgreSQL type | Python type | Example |
|---|---|---|
BOOLEAN | bool | True, False |
SMALLINT, INTEGER, BIGINT | int | 42, -123 |
REAL, DOUBLE PRECISION | float | 3.14 |
NUMERIC | decimal.Decimal | Decimal('1234.5678') |
TEXT, VARCHAR, CHAR, NAME | str | "Hello" |
DATE | datetime.date | date(2023, 12, 25) |
TIME | datetime.time | time(14, 30, 0) |
TIMESTAMP | datetime.datetime (naive) | datetime(2023, 12, 25, 14, 30) |
TIMESTAMPTZ | datetime.datetime (aware) | With tzinfo, normalized to UTC |
UUID | uuid.UUID in / str out | uuid.uuid4() |
JSON, JSONB | dict, non-homogeneous list/tuple | {"key": "value"} |
BYTEA | bytes (bytearray/memoryview in) | b"\x00\x01" |
BOOL[], INT2/4/8[], FLOAT4/8[], TEXT[]-family | homogeneous list/tuple | [1, 2, 3], ["a", "b"] |
Anything else (INET/CIDR, custom/enum types, nested arrays) isn't decodable yet — reading such a column raises PostPyro.NotSupportedError naming the type, rather than silently returning the wrong value.
Examples
Pandas integration
rows = await pool.query("SELECT * FROM sales_data")
df = pd.DataFrame([row.to_dict() for row in rows])
print(df.head())
FastAPI integration
@app.on_event("startup")
async def startup():
app.state.pool = await PostPyro.connect(DSN)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
try:
user = await app.state.pool.query_one(
"SELECT id, name, email FROM users WHERE id = $1", [user_id]
)
return user.to_dict()
except PostPyro.DatabaseError:
raise HTTPException(404, "User not found")
Pooling is built in
pool = await PostPyro.connect(DSN, max_size=20, min_size=2)
results = await asyncio.gather(
pool.query("SELECT * FROM users WHERE id = $1", [1]),
pool.query("SELECT * FROM users WHERE id = $1", [2]),
)
Batch processing
tx = await pool.transaction()
async with tx:
for row in users_data:
await tx.execute(
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3)",
[row['name'], row['email'], row['age']]
)
# All rows commit or roll back together
Performance
Not a speed claim
The architecture below is real, but asyncpg and psycopg3 are compiled too (Cython/C), so "no Python interpreter overhead" isn't a PostPyro-only advantage. The numbers underneath are from one machine, one Docker Postgres 16, one point in time - not a claim about your hardware or workload. Run benchmarks/bench_vs_alternatives.py yourself before trusting any number here - see benchmarks/README.md.
Rust backend
Query execution and type conversion happen in compiled Rust, not interpreted Python.
Async I/O
sqlx + Tokio-powered networking, GIL released while waiting on Postgres.
Binary protocol
Direct wire-format encode/decode instead of text parsing round trips.
Zero dependencies
No external Python packages required at runtime.
Measured results
| Benchmark | PostPyro (median ms) | asyncpg (median ms) | psycopg3 (median ms) |
|---|---|---|---|
Round trip (200× SELECT 1) | 29.86 | 20.74 | 17.19 |
| Bulk insert (1,000 rows, one at a time) | 4304.05 | 4475.53 | 4171.60 |
| Transaction (4 statements) | 8.90 | 4.85 | 4.13 |
Concurrency (20 tasks via asyncio.gather) | 2.81 | 1.16 | 3.91 |
PostPyro is currently slower than both on plain round-trips and transactions, roughly on par on bulk inserts, and behind asyncpg but ahead of psycopg3 on concurrency. No optimization pass has happened yet - see benchmarks/README.md for the exact methodology and how to reproduce this.
Driver comparison
| Feature | PostPyro | psycopg2 | asyncpg | psycopg3 |
|---|---|---|---|---|
| Language | Rust + Python | C + Python | Cython | C + Python |
| Memory safety | Rust | Manual C | — Cython | Manual C |
| Installation | Wheel | Compilation | Wheel | Compilation |
| Dependencies | Zero | Several | Few | Several |
| Async | Fully async | Sync | Fully async | — Sync or async |
# Insert 10,000 records concurrently across the pool
start = time.time()
await asyncio.gather(*(
pool.execute("INSERT INTO benchmark (value) VALUES ($1)", [i])
for i in range(10000)
))
elapsed = time.time() - start
print(f"Inserted 10,000 records in {elapsed:.2f}s ({10000/elapsed:.0f}/s)")