Skip to main content

ormx-py

Pydantic-native async ORM for Postgres, written in Python, accelerated by Rust.

PyPI · crates.io · GitHub · Docs · Changelog

PyPI version Python License: MIT Rust core: ormx-ru Wheel: abi3 Downloads


Why ORMX?

One class, three roles User is the DB row, the API request body, and the response model — no translation layer.
Pydantic-native Every Pydantic constraint, alias, validator, and JSON schema just works.
Rust hot path Connection pool, parameter binding, row decoding, sharding router — all in Rust via PyO3.
Real migrations Autogen CREATE TABLE, ALTER COLUMN, FKs, indexes, multi-schema — with rollback.
Sharding built-in Hash / range / geo / list / custom routing, evaluated in Rust.
Framework-agnostic Rivex, FastAPI, Litestar, raw asyncio — same primitives.
Single pip install abi3 wheel with the Rust core compiled in — no separate package, no subprocess.
pip install ormx-py

The PyPI distribution is ormx-py — the ormx and pyormx names there belong to unrelated packages. The import is ormx.


A full app in one file

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=150)


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. No mappers. No DTOs. No separate request/response models.


Feature status

4.5.0 — stable. 329 Python tests + 42 Rust 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 Postgres simple-query protocol
One-to-many eager loading via .include()
Migration engine — CREATE/DROP TABLE, ADD/DROP COLUMN, ALTER COLUMN TYPE, indexes, FKs 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
CIDR / INET decode + bind
Many-to-many eager loading — secondary= and through=
Beyond Postgres (MySQL / SQLite drivers — types compile, drivers wip) 🚧
Composite (multi-column) indexes + foreign keys

Querying — the cookbook

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
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])

Many-to-many

# Simple — raw junction table, no extras.
class Article(Model):
    id: int = Field(primary_key=True)
    tags = ManyToMany("Tag", secondary="article_tags", through_local="article_id")

# Or with a junction model carrying extras (role, joined_at, …):
class Membership(Model):
    article_id: int = ForeignKey("articles.id")
    member_id: int = ForeignKey("members.id")
    role: str
    joined_at: int  # epoch seconds

class Article(Model):
    id: int = Field(primary_key=True)
    members = ManyToMany("Member", through="Membership")

# Bare targets (default):
articles = await Article.query().include("members").all()
# → articles[0].members: list[Member]

# With junction extras:
articles = await Article.query().include(("members", True)).all()
# → articles[0].members: list[(Member, Membership)]
#   so pair[1].role / .joined_at are addressable.

Composite indexes + composite foreign keys

from ormx import CompositeIndex, CompositeForeignKey

class OrderItem(Model):
    __tablename__ = "order_items"
    __indexes__ = [
        CompositeIndex("order_id", "created_at"),
        CompositeIndex("customer_id", "status", unique=True),
    ]
    __foreign_keys__ = [
        CompositeForeignKey(
            columns=("order_id", "product_id"),
            ref_table="catalog_entries",
            ref_columns=("order_id", "product_id"),
            on_delete="CASCADE",
        ),
    ]
    id: int = Field(primary_key=True)
    order_id: int
    product_id: int
    customer_id: int
    status: str
    created_at: int

The migration runner emits CREATE INDEX/ADD CONSTRAINT FOREIGN KEY (col1, col2) … automatically — no raw DDL needed.


Transactions — three flavours

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

# 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).

The migration primitives (discovery, DML rendering, state DDL) live in the ormx-ru Rust crate — shared across every ORMX SDK.


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. The router runs in Rust (md5-based deterministic hashing matching the Python strategy).


Architecture

┌─────────────────────────────────────┐
│  Python (Pydantic, asyncio)         │  ← User-facing API
├─────────────────────────────────────┤
│  PyO3 bridge (abi3)                 │  ← Zero-copy calls
├─────────────────────────────────────┤
│  ormx-core (Rust)                   │  ← Connection pool, bind/decode, router
│   ├─ Tokio runtime                  │
│   ├─ sqlx (Postgres / MySQL / SQLite)│
│   └─ PyO3 #[pyclass] bindings       │
└─────────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────────┐
│  ormx-ru (Rust, published crate)    │  ← SQL discovery, DML, state DDL
│   ├─ discover    (file walking)     │
│   ├─ dml         (literal rendering)│
│   ├─ runner      (apply/rollback)   │
│   └─ check       (validation)       │
└─────────────────────────────────────┘

The Rust core lives in-tree at core/ and depends on the published ormx-ru crate for migration primitives — so anyone using ORMX from Rust, Go, JS, or Ruby shares the same migration engine.


Install

pip install ormx-py

Pre-built abi3 wheels for Linux (x86_64 + aarch64), macOS (Intel + Apple Silicon), and Windows. One pip install, no separate core package, no compiled extension to build locally.

For source builds (e.g. adding your own hooks), you need rustc 1.75+ — see RELEASING.md.

# Optional: install dev/test extras
pip install "ormx-py[dev]"
pip install "ormx-py[docs]"

Development

git clone https://github.com/shregar1/python.ormx.vexarr.com
cd python.ormx.vexarr.com

# Build the wheel locally (compiles ormx-core + ormx-ru from source)
maturin build --release
pip install --force-reinstall target/wheels/ormx_py-*.whl

# Run the suite
pytest                          # 317 Python tests
cd core && cargo test           # 42 Rust tests (10 ormx-core + 32 ormx-ru)

# Lint / type-check
ruff check .
mypy ormx/

Project layout:

python.ormx.vexarr.com/
├── ormx/                     # pure-Python package (Pydantic models, query builder)
├── ormx_core.pyi             # PyO3 type stubs (one file, ships in sdist)
├── core/                     # Rust extension (ormx-core crate)
│   ├── src/                  # lib.rs, logic.rs, migrate.rs, migrate_runner.rs
│   └── Cargo.toml            # depends on ormx-ru = "0.1" (crates.io)
├── examples/                 # rivex_basic.py, fastapi_basic.py, blog/
├── docs/                     # mkdocs material
└── pyproject.toml            # maturin build backend

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


Ecosystem

Crate / package What
ormx-py This package — Python + PyO3
ormx-ru Universal migration crate (Rust, used by every SDK)
rust.ormx.vexarr.com Pure-Rust ORM (separate project, no PyO3)

License

MIT.

Download files

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

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

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

ormx_py-4.6.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.4 MB view details)

Uploaded CPython 3.7+manylinux: glibc 2.17+ x86-64

ormx_py-4.6.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (4.2 MB view details)

Uploaded CPython 3.7+manylinux: glibc 2.17+ ARM64

ormx_py-4.6.0-cp37-abi3-macosx_11_0_arm64.whl (4.6 MB view details)

Uploaded CPython 3.7+macOS 11.0+ ARM64

ormx_py-4.6.0-cp37-abi3-macosx_10_12_x86_64.whl (4.7 MB view details)

Uploaded CPython 3.7+macOS 10.12+ x86-64

File details

Details for the file ormx_py-4.6.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for ormx_py-4.6.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 20589d4ecc52e914b68a4adae4a26e3274f87f13f7cc1c65311afda638639ada
MD5 0a731ef885c56c2a9401ee09509d3d62
BLAKE2b-256 497776a12a0c210fe479ac98633c8967ffd1969a67027367fec5063534c054c6

See more details on using hashes here.

File details

Details for the file ormx_py-4.6.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for ormx_py-4.6.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 5949c16fcfc28b8f89ceaaeab0383655e082783543b4d477a47dccee797f36a9
MD5 04db36d75135529059bff0d6dba30937
BLAKE2b-256 ac557a035f61b67905cb7dfec5fa0f0e882ab65f85275af2f963e9191e2b0bfe

See more details on using hashes here.

File details

Details for the file ormx_py-4.6.0-cp37-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for ormx_py-4.6.0-cp37-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 a8286ff611a389b7d4e1334e63804cfb65a7c82b1c715a0511278a61b9bc7f5b
MD5 0021da5a4fcaa1a680835b4a1b895273
BLAKE2b-256 8418645c838c10dce6140d9977dc081f60881f0cdaaaa7a793ae46a1a3ae4529

See more details on using hashes here.

File details

Details for the file ormx_py-4.6.0-cp37-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for ormx_py-4.6.0-cp37-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 8fdaf5a3a218d83975e4c4ff36aa05695fc4fd80af1e1f0baef8454dff39d203
MD5 6ba7a668d53ea77b860d64d3d32e0f43
BLAKE2b-256 821d89a3e9d07e47b1af4aa9e48c56afc1fa15a93e3304af6318b7b0d22c6b8f

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

4.6.0 This release

4 files

4.5.2

1 file

4.5.1

1 file

4.5.0

1 file

4.4.3

1 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