Skip to main content

A lightweight PostgreSQL query builder and mini-ORM for Python

Project description

pgstorm

A lightweight PostgreSQL query builder and mini-ORM for Python. Compose type-safe queries that compile to parameterized SQL, then execute them using a pluggable engine (sync or async).

Features

  • Type-safe models — Define models with type annotations; __table__ or __tablename__ for table names
  • Rich QuerySet APIfilter, exclude, order_by, limit, offset, join, aggregate, annotate, alias
  • Q objects — Combine conditions with | (OR), & (AND), ~ (NOT)
  • SubqueriesSubquery and OuterRef for correlated subqueries
  • F expressions — Reference annotations/aliases in filters and order_by
  • SQL functionsConcat, Coalesce, Upper, Lower, Now, DateTrunc, Func_, and more
  • AggregatesMin, Max, Count, Sum, Avg
  • Writes includedcreate, bulk_create, update, delete (sync or await with async engines)
  • Engine abstraction — Sync (psycopg2, psycopg3) and async (psycopg3_async, asyncpg) interfaces
  • Transactionswith pgstorm.transaction(): or async with pgstorm.transaction():
  • Observers & hooks — Django-style pre_* / post_* and connection/transaction hooks via pgstorm.observers
  • Schema supportusing_schema() and per-join rhs_schema

Requirements

  • Python 3.10+
  • A PostgreSQL database
  • A driver (installed automatically via extras): psycopg3 (default), psycopg2, or asyncpg

Installation

pip install pgstorm

This installs pgstorm with psycopg3 (the default driver). To use a different driver:

# psycopg2: choose normal (requires libpq) or binary (pre-built)
pip install pgstorm[psycopg2]        # psycopg2 (sync, normal build)
pip install pgstorm[psycopg2-binary] # psycopg2-binary (sync, pre-built)

# psycopg3: choose normal or binary (default uses binary)
pip install pgstorm[psycopg3]        # psycopg3 (normal build)
pip install pgstorm[psycopg3-binary] # psycopg3 binary (pre-built)

pip install pgstorm[asyncpg]         # asyncpg (async)
pip install pgstorm[all]             # all drivers (binary variants)

From source: pip install -e . or pip install -e ".[asyncpg]"

Quick Start

from pgstorm import BaseModel, types, create_engine

class User(BaseModel):
    __table__ = "users"
    id: types.Integer[types.IS_PRIMARY_KEY_FIELD]
    age: types.Integer
    email: types.String

class UserProfile(BaseModel):
    __table__ = "user_profile"
    user: types.ForeignKey[User, types.ON_DELETE_CASCADE]

# Create engine (sets global context for querysets)
engine = create_engine("postgresql://user:pass@localhost/dbname", interface="psycopg3")

# Build and compile a query
qs = UserProfile.objects.filter(
    UserProfile.user.email.like("%@example.com")
).join(User, UserProfile.user.id == User.id)

compiled = qs.compiled()
print(compiled.sql.as_string(None))
print(compiled.params)

# Execute and iterate (uses engine from context)
for profile in qs:
    print(profile.user.email)

Async example (asyncpg)

QuerySet.fetch() and other methods return an awaitable when the configured engine is async.

import asyncio
from pgstorm import create_engine, Subquery
from example.model import User, AuditLog

db_credentials = {
    "host": "localhost",
    "port": 5432,
    "user": "postgres",
    "password": "admin",
    "dbname": "testdb",
}

async def main():
    create_engine(db_credentials, interface="asyncpg")

    log = await AuditLog.objects.create(
        user=Subquery(
            User.objects.using_schema("tenant1")
                .filter(User.email == "mohamed@example.com")
                .columns("id")
        ),
        action="INSERT",
        target_table="user",
        target_id=2,
    )
    print("log id:", log.id)

    rows = await User.objects.using_schema("tenant1").filter(User.email.like("%@example.com")).fetch()
    for user in rows:
        print(user.email)

    print("count:", await User.objects.count())

asyncio.run(main())

Observers & hooks

pgstorm includes an observer system for registering callbacks around queries, connections, cursors, and transactions via pgstorm.observers. Observers can be global (for all tables) or table-specific, and can be sync or async (async callbacks are awaited automatically when you use an async engine).

from pgstorm.observers import (
    ObserverContext,
    table_observers,
    on_fetch,
    on_query_before_execute,
    on_query_after_execute,
    POST_CREATE,
)
from example.model import User


@on_fetch
def log_fetch(ctx: ObserverContext) -> None:
    # Called for any SELECT
    print(f"[fetch] table={ctx.table}, model={ctx.model}")


@table_observers(action=POST_CREATE, table=User)
def user_created(ctx: ObserverContext) -> None:
    # Called only for User inserts
    instance = ctx.extra.get("instance")
    print(f"[post_create] User created: {instance}")


@on_query_before_execute
def before_any_query(ctx: ObserverContext) -> None:
    action = ctx.extra.get("query_action", "?")
    print(f"[before] action={action}, table={ctx.table}")


@on_query_after_execute
def after_any_query(ctx: ObserverContext) -> None:
    rows = ctx.result if isinstance(ctx.result, list) else []
    print(f"[after] rows={len(rows)}")

See example/observers_example.py and the Observers & hooks section in the docs for a fuller walkthrough and list of supported actions.

Models

Define models by subclassing BaseModel and annotating attributes with types:

from pgstorm import BaseModel, types

class Product(BaseModel):
    __table__ = "products"
    id: types.Integer[types.IS_PRIMARY_KEY_FIELD]
    name: types.String
    price: types.Integer

Use __table__ or __tablename__ to set the table name; otherwise the class name (lowercased) is used.

Types

  • Scalars: types.Integer, types.String, types.BigSerial, types.Jsonb, types.Inet, types.Varchar(20), types.TimestampTZ(default=...)
  • Relations: types.ForeignKey[User], types.OneToOne, types.ManyToMany
  • Relation metadata: types.ON_DELETE_CASCADE, types.FK_FIELD("email"), types.FK_COLUMN("user_email"), types.ReverseName("profiles")
user: types.ForeignKey[User, types.ON_DELETE_CASCADE, types.FK_FIELD("email")]

Self-referential relations: reply_to: types.ForeignKey[types.Self].

Engine & Execution

Create an engine with create_engine(). By default it sets the engine in a context variable so querysets use it automatically.

from pgstorm import create_engine

# Sync (default)
engine = create_engine("postgresql://user:pass@localhost/db", interface="psycopg3")

# Async
engine = create_engine("postgresql://...", interface="psycopg3_async")
# or
engine = create_engine("postgresql://...", interface="asyncpg")

Interfaces: psycopg2, psycopg3, psycopg3_sync, psycopg3_async, asyncpg

Fetching results

# Sync: iterate or index
users = list(User.objects.filter(User.age > 18))
user = User.objects.filter(User.id == 1)[0]

# Async: use await fetch()
users = await User.objects.filter(User.age > 18).fetch()

Transactions

import pgstorm

# Sync
with pgstorm.transaction():
    # queries run in transaction
    pass

# Async
async with pgstorm.transaction():
    await User.objects.all().fetch()

QuerySet API

Filtering

# Simple comparisons (==, !=, <, <=, >, >=)
User.objects.filter(User.age >= 18)
User.objects.filter(User.email == "a@b.com")

# LIKE / ILIKE
User.objects.filter(User.email.like("%@example.com"))
User.objects.filter(User.name.ilike("%john%"))

# IN
User.objects.filter(User.id.in_([1, 2, 3]))
User.objects.filter(User.id.in_(Subquery(Order.objects.columns("user_id"))))

# Exclude
User.objects.filter(User.age > 18).exclude(User.deleted)

Q objects (AND / OR / NOT)

from pgstorm import Q, and_, or_, not_

User.objects.filter(Q(User.age > 18) | Q(User.age < 5))
User.objects.filter(and_(Q(User.active), Q(User.verified)))
User.objects.filter(~Q(User.deleted))

Joins

UserProfile.objects.join(
    User,
    UserProfile.user.email == User.email,
    join_type="LEFT"
)

Schemas

User.objects.using_schema("tenant_1").filter(...)
UserProfile.objects.join(User, ..., rhs_schema="tenant_2")

Aggregates

from pgstorm import Min, Max, Count, Sum, Avg

# Positional: alias = col_name_function_name (e.g. price_min)
Product.objects.aggregate(Min(Product.price), Max(Product.price))

# Keyword: alias = key
Product.objects.aggregate(total=Sum(Product.price), cnt=Count())

# COUNT(*)
Product.objects.aggregate(row_count=Count())

Annotate & Alias

from pgstorm import Concat, F

# annotate: add computed columns to SELECT; results include them
User.objects.annotate(full_name=Concat(User.first_name, " ", User.last_name))

# alias: define expressions for filter/order_by without including in SELECT
User.objects.alias(full_name=Concat(User.first_name, " ", User.last_name)).filter(
    F("full_name").ilike("%mohamed%")
)

Subqueries & OuterRef

from pgstorm import Subquery, OuterRef

# Users who have at least one order
User.objects.filter(
    User.id.in_(
        Subquery(
            Order.objects.filter(Order.user_id == OuterRef(User.id)).columns("user_id")
        )
    )
)

Other

  • order_by(User.age) — ORDER BY
  • limit(10), offset(20) — LIMIT / OFFSET
  • distinct() — SELECT DISTINCT
  • defer("col"), columns("col1", "col2") — column selection
  • as_cte(name) — use queryset as CTE

Compiling to SQL

qs = User.objects.filter(User.age > 18).limit(10)
compiled = qs.compiled()

# For psycopg
sql, params = qs.as_sql()
cursor.execute(sql, params)

SQL Functions

Built-in: Concat, Coalesce, Upper, Lower, Length, Trim, Replace, NullIf, Abs, Round, Floor, Ceil, Now, CurrentDate, CurrentTimestamp, DateTrunc. Use Func_("name", arg1, arg2) for any other PostgreSQL function.

Documentation

See the docs/ folder for detailed documentation:

License

Not specified yet (README previously said MIT, but a LICENSE file is not currently present in this repository).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pgstorm-0.1.3.tar.gz (59.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pgstorm-0.1.3-py3-none-any.whl (71.7 kB view details)

Uploaded Python 3

File details

Details for the file pgstorm-0.1.3.tar.gz.

File metadata

  • Download URL: pgstorm-0.1.3.tar.gz
  • Upload date:
  • Size: 59.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for pgstorm-0.1.3.tar.gz
Algorithm Hash digest
SHA256 1a99896dbd42caaf45ede18259513f09773b55392963e843bafa3dbb3d82e438
MD5 8bc4ff1e445aac363c4a8e8b48b0a7be
BLAKE2b-256 3fa381d1b6a576b5ffb1df126bd0e3b2daedbb314034f9243dd91f9183287eb0

See more details on using hashes here.

File details

Details for the file pgstorm-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: pgstorm-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 71.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for pgstorm-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 f81faf79ea1904f91d7f80729887be85ccbb671c7d65692efb5b5a065cd9d9a4
MD5 562579362129aa8117706354c8793882
BLAKE2b-256 5dd610945ea5348977fec94bda2e16e0266bfbd4ed61e45ed7ca59f38da810e6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page