Skip to main content

ORMX (Python)

Pydantic-native async ORM for Postgres, with a Rust core for the hot path and a real migration engine.

The headline trick: one class plays three roles — DB persistence object, framework request body, OpenAPI response model — without a translation layer. Drop into Rivex, FastAPI, Litestar, anything that knows Pydantic.

from rivex import Depends, Rivex
from ormx import Field, Model, TransactionMiddleware, connect, disconnect, get_db


class User(Model):
    __tablename__ = "users"
    id: int = Field(primary_key=True)
    email: str = Field(unique=True, db_index=True, pattern=r"^[^@\s]+@[^@\s]+$")
    name: str = Field(min_length=1, max_length=100)
    age: int = Field(ge=0, le=120)


app = Rivex()
app.add_middleware(TransactionMiddleware)


@app.on_event("startup")
async def init(): await connect("postgres://localhost/myapp")

@app.on_event("shutdown")
async def close(): await disconnect()


@app.get("/users/{id}", response_model=User)
async def get_user(id: int, db=Depends(get_db)):
    return await User.get(id)


@app.post("/users", response_model=User)
async def create_user(user: User, db=Depends(get_db)):
    return await user.save()

That's the entire stack.

Status

3.1.0 — stable. 171 tests passing.

Feature Status
Pydantic-native Model (every constraint, alias, validator) ✅
Framework-agnostic TransactionMiddleware ✅
Async-generator get_db dependency ✅
Rust transaction API (Engine.begin() / tx.commit() / tx.rollback()) ✅
Multi-statement DDL via simple-query protocol ✅
One-to-many eager loading via .include() ✅
Migration engine — CREATE/DROP TABLE, ADD/DROP COLUMN, ALTER COLUMN TYPE, indexes, foreign keys with ON DELETE/ON UPDATE, multi-schema, NULL ↔ NOT NULL ✅
Sharding — hash / range / geo / list / custom routing ✅
Multi-schema (__schema__ on Model) ✅
Query builder — where / order_by / limit / offset / select / distinct / group_by / having / count / exists / scalar / values / update / delete / stream ✅
Streaming reads via server-side cursor (.stream(chunk_size=)) ✅
Model.refresh() for re-reading rows touched externally ✅
BYTEA / bytes round-trip ✅ (since v3.0.2)
Many-to-many eager loading 🚧
Beyond Postgres (MySQL / SQLite drivers) 🚧
Composite (multi-column) indexes + foreign keys 🚧

Install

pip install ormx

ormx is pure-Python; the Rust core ships as a separate ormx-core package and installs transitively. Wheels are published for Linux (x86_64 + aarch64), macOS (Intel + Apple Silicon), and Windows. Source builds need rustc 1.75+.

Querying — the cookbook

ORMX query builders are chainable. Every chain method returns self; terminal methods (all, first, count, exists, scalar, values, update, delete, stream) execute the query and return.

# Basic WHERE
users = await User.query().where(User.age > 18).all()

# Chained — AND
adults = await (
    User.query()
    .where(User.age > 18)
    .where(User.email.like("%@example.com"))
    .order_by("name")
    .limit(50)
    .all()
)

# Count / exists / scalar
total = await User.query().count()
has_admin = await User.query().where(User.email == "admin@x.com").exists()

# Aggregates with GROUP BY + HAVING (HAVING is a raw SQL fragment)
buckets = await (
    User.query()
    .select("age")
    .group_by("age")
    .having("count(*) > 5")
    .all(raw=True)
)
# → [{"age": 30}, {"age": 31}, ...]

# DISTINCT and DISTINCT ON
unique_ages = await User.query().select("age").distinct().values("age")
latest_per_user = await (
    Event.query()
    .order_by("user_id").order_by("created_at", "desc")
    .distinct("user_id")
    .all()
)

# Bulk update / delete (skip per-row hooks)
n = await User.query().where(User.age < 13).update(age=13)
n = await User.query().where(User.deleted_at.is_not_null()).delete()

# Streaming — memory-bounded, server-side cursor
async for user in User.query().where(User.age > 50).stream(chunk_size=1000):
    process(user)

# Refresh a stale instance after an external write
await user.refresh()  # re-reads by PK

Relationships and eager loading

class User(Model):
    __tablename__ = "users"
    id: int = Field(primary_key=True)
    posts = Relationship("Post", back_populates="user")

class Post(Model):
    __tablename__ = "posts"
    id: int = Field(primary_key=True)
    user_id: int = ForeignKey("users.id", on_delete="CASCADE")
    title: str = Field(db_index=True)
    user = Relationship("User", back_populates="posts")


# One IN-query per included relation — no N+1.
users = await User.query().include("posts").all()
for u in users:
    print(u.name, [p.title for p in u.posts])

Transactions

Three flavours, pick what fits:

# 1. Explicit context manager
async with ormx.transaction():
    await User.create(name="alice")
    await Order.create(user_id=1, total=10)
# Rollback automatic on exception; commit on clean exit.

# 2. Unit-of-Work (batched flush at the end)
async with ormx.uow_session():
    user = User(id=1, name="alice", age=30)
    user.age = 31         # implicitly dirty
    await user.save()      # flushed at context exit, all in one tx

# 3. Per-request middleware (the most common shape)
app.add_middleware(TransactionMiddleware)
# Every handler wrapped automatically.
# Default: rollback on 5xx responses + raised exceptions; configurable.

Migrations

# Diff models vs DB, write a new timestamped migration file
ormx makemigrations

# Apply unapplied migrations in order
ormx migrate

# Roll back the most recently applied one
ormx migrate --rollback

# Drop orphan tables (off by default — safety against accidental data loss)
ormx makemigrations --allow-drop-tables

State lives in an ormx_migrations table the runner creates on first use. Each migration runs in its own transaction; partial-failure rolls back cleanly.

Detects: CREATE/DROP TABLE, ADD/DROP COLUMN, ALTER COLUMN TYPE (with USING), CREATE/DROP INDEX, ADD/DROP FOREIGN KEY, SET/DROP NOT NULL, CREATE SCHEMA for non-public-schema models. Multi-statement DDL + PL/pgSQL with dollar-quoted bodies work via the Postgres simple-query protocol — executor.execute_many(sql).

Multi-schema

class AuditEvent(Model):
    __tablename__ = "events"
    __schema__ = "audit"   # ← non-public schema; CREATE SCHEMA emitted automatically
    id: int = Field(primary_key=True)
    event_type: str

# Cross-schema FK
class AuditRef(Model):
    __tablename__ = "refs"
    event_id: int = ForeignKey("audit.events.id")

Sharding

import ormx_core
from ormx import set_router
from ormx.sharding import Router, HashSharding

# Connect each shard
await ormx.connect("postgres://shard0/...", shard_name="shard0", is_default=True)
await ormx.connect("postgres://shard1/...", shard_name="shard1")

# Install a routing strategy
set_router(Router(shards=["shard0", "shard1"], strategy=HashSharding()))

# Pass ``sharding_value`` and ORMX routes to the right shard
await User.query().where(User.id == 42).first(sharding_value=42)

Hash / range / geo / list / custom routing all supported via ormx_core.ShardingConfig.

Settings management

ORMX itself doesn't ship one — use pydantic-settings (standard) or write a small helper. Combined with Rivex's Settings base class:

from rivex import Settings

class Config(Settings):
    database_url: str
    debug: bool = False

cfg = Config.from_env()
await ormx.connect(cfg.database_url)

Upgrading from 2.x

See MIGRATION-3.0.md. TL;DR: type annotations on fields are now required (Pydantic uses them); ormx.fastapi was replaced by framework-agnostic primitives (TransactionMiddleware + get_db work with FastAPI, Rivex, Litestar — anything that knows async-generator dependencies).

Examples

Documentation

Full docs at docs/ — mkdocs serve to read locally, auto-publishes to https://ormx.dev/docs via the docs workflow.

License

MIT.

Metadata

Release files for ormx-py 4.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for ormx-py 4.4.0
File Interpreter ABI Platform
ormx_py-4.4.0-py3-none-any.whl Python 3 none any Details

Release files / ormx_py-4.4.0-py3-none-any.whl

Download URL ormx_py-4.4.0-py3-none-any.whl
Size 4.4 MB
Tags Python 3
SHA-256 checksum
How to use checksums
0900fbf97f4cecff8740f4aa32afa4d444eead244c1525218fcd1a0a08e87b57
BLAKE2b-256 checksum
How to use checksums
d90a27d80bf7a7ca157ad5b63da8d3a9b2ba7114b73b14aa3305b82a2a29004e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.7

Release history Release notifications | RSS feed

This release

4.4.0 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page