Beta — async rewrite, breaking changes expected before 1.0

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.

Memory-safe Rust core Fully async, GIL released on I/O Zero Python dependencies
quick_example.py
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 string
  • max_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 / .paramstyle

Module-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 withasync 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.

DatabaseError # base ├── InterfaceError # driver interface problems ├── DataError # bad/unrepresentable data ├── OperationalError # connection / resource issues ├── IntegrityError # constraint violations ├── InternalError # internal database errors ├── ProgrammingError # SQL / usage errors └── NotSupportedError # unsupported feature/type
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 typePython typeExample
BOOLEANboolTrue, False
SMALLINT, INTEGER, BIGINTint42, -123
REAL, DOUBLE PRECISIONfloat3.14
NUMERICdecimal.DecimalDecimal('1234.5678')
TEXT, VARCHAR, CHAR, NAMEstr"Hello"
DATEdatetime.datedate(2023, 12, 25)
TIMEdatetime.timetime(14, 30, 0)
TIMESTAMPdatetime.datetime (naive)datetime(2023, 12, 25, 14, 30)
TIMESTAMPTZdatetime.datetime (aware)With tzinfo, normalized to UTC
UUIDuuid.UUID in / str outuuid.uuid4()
JSON, JSONBdict, non-homogeneous list/tuple{"key": "value"}
BYTEAbytes (bytearray/memoryview in)b"\x00\x01"
BOOL[], INT2/4/8[], FLOAT4/8[], TEXT[]-familyhomogeneous 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

BenchmarkPostPyro (median ms)asyncpg (median ms)psycopg3 (median ms)
Round trip (200× SELECT 1)29.8620.7417.19
Bulk insert (1,000 rows, one at a time)4304.054475.534171.60
Transaction (4 statements)8.904.854.13
Concurrency (20 tasks via asyncio.gather)2.811.163.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

FeaturePostPyropsycopg2asyncpgpsycopg3
LanguageRust + PythonC + PythonCythonC + Python
Memory safety Rust Manual C— Cython Manual C
InstallationWheelCompilationWheelCompilation
DependenciesZeroSeveralFewSeveral
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)")