Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

🐍 SnakeORM

CI Python Engines Typing License: MIT

Fully typed deep relationship navigation in Python. No codegen. No type-checker plugin.

Docs (English and Spanish): https://velezanthony.github.io/snake-orm/

Truck.maker.nation.name == "España"   # SnakeExpr[str] -> SnakeCondition
SELECT t0."id", t0."model", t0."maker_id" FROM "public"."trucks" AS t0
JOIN "public"."makers" AS t1  ON t0."maker_id" = t1."id"
JOIN "public"."nations" AS t2 ON t1."nation_id" = t2."id"
WHERE t2."name" = %s

Mypy checks it. Pyright checks it. Pylance autocompletes it. The Django equivalent, filter(maker__nation__name="España"), is a string: no autocomplete, no check, and renaming nation fails in production.


Install

Requires Python 3.11+. SQLite ships with the standard library, so nothing needs to be running to start.

pip install snake-orm==0.1.0b1   # or: pip install --pre snake-orm

The distribution is snake-orm and the package is snakeorm: import snakeorm.

The version is a beta, and the pin is the point: a preliminary is not picked up by a plain pip install snake-orm, so nobody upgrades into it by accident while the API is still moving.

From a checkout, to work on the ORM itself:

uv sync --all-extras --all-groups
uv run pytest          # suite
uv run mypy .          # must pass
uv run ruff check .    # must pass

Installation → first model → migrations.

@snake_model(table="makers")
class Maker(SnakeModel):
    id: SnakeColumn[int] = snake_auto()
    name: SnakeColumn[str] = snake_str(unique=True)
    nation_id: SnakeColumn[int] = snake_int()
    nation: SnakeToOne[Nation] = snake_to_one(nation_id)
    trucks: SnakeToMany[Truck] = snake_to_many("maker")

snake_link()   # once, after importing every model

The type always comes from the annotation. snake_column() only adds SQL information.

uv run snakeorm makemigrations --models myapp.models --name initial
uv run snakeorm migrate --models myapp.models --dsn "host=... dbname=..."
uv run snakeorm rollback --models myapp.models --dsn "..."

The mechanism

Recursive descriptors whose __get__ returns a different type depending on the access:

user.car.name               # instance -> the value           -> str
User.car.brand.name == "x"  # class    -> a SQL expression     -> SnakeCondition

User.car returns type[Car], so .brand.name re-triggers the class overload of Car's descriptors. @dataclass_transform on the decorator types __init__. It is the manual equivalent of TypeScript's mapped types.

The type system is the single source of truth: the class is compiled once into an immutable metadata graph, and the runtime never reflects on it again.


Why Django returns Any

User.objects.annotate(num_posts=Count("posts"))
user.num_posts   # -> Any

annotate returns "a User plus a num_posts: int". That is an intersection type. TypeScript has it; Python does not. So there are three paths, and only three:

Path Dynamic names Real typing IntelliSense
__getattr__ -> Any ✅ ❌ ❌
Declared names ❌ ✅ ✅
Type-checker plugin ✅ ✅ partial

Django took the first and patched it with the third (django-stubs). SnakeORM forbids the third by thesis and takes the second, with a typed escape hatch.

Any is not typing, it is switching the checker off:

def __getattr__(self, name: str) -> Any: ...
u.agg.count_children * 2           # mypy: 0 errors
other: str = u.agg.count_children  # mypy: 0 errors  <- same value, as a str

object keeps the dynamic name and wakes the checker up:

def __getattr__(self, name: str) -> object: ...
u.agg.count_children * 2           # error: unsupported operand types for *
count: int = cast("int", u.agg.count_children)   # explicit, and signed with your name

Illegal states you cannot write

SnakeQuery(Nation).filter(Nation.makers.name == "SEAT")
# error: "SnakeCollection[Maker]" has no attribute "name"  [attr-defined]

In Django that compiles, runs and silently duplicates rows — which is what .distinct() is for. A to-many exposes collection operations, not the child's columns:

q.filter(Nation.makers.any(Maker.name == "SEAT"))  # any?   -> correlated EXISTS
q.filter(~Nation.makers.any())                     # none?  -> NOT EXISTS
q.filter(Nation.makers.count() > 3)                # how many? -> scalar subquery
q.include(Nation.makers)                           # load them -> select-in, 2 queries

q.include(SnakePrefetch(Nation.makers).then(Maker.trucks))
# one query per LEVEL (root + makers + trucks = 3), never one per parent

One row per parent. DISTINCT is never needed.

-- Nation.makers.any(Maker.trucks.any(Truck.model == "Ibiza"))
SELECT "id", "name" FROM "public"."nations" WHERE EXISTS (
  SELECT 1 FROM "public"."makers" AS e0 WHERE e0."nation_id" = "nations"."id" AND EXISTS (
    SELECT 1 FROM "public"."trucks" AS e1 WHERE e1."maker_id" = e0."id" AND e1."model" = %s))

Implicit when the answer is unique. Explicit when there is more than one correct answer.

A to-one never changes the row count, so the JOIN is inferred. A to-many does, so the ORM does not guess.


What the thesis gives for free

No silent N+1. An unloaded relation raises instead of querying:

truck.maker
# SnakeRelationshipNotLoaded: Relation 'maker' was not loaded.
#                             Use .include(Truck.maker) in the query.

No F(). Class access already is an expression:

session.update_where(query, [(Counter.views, Counter.views + 1)])
# UPDATE "counters" SET "views" = ("views" + %s) WHERE ...

Pairs, not a dict: SnakeExpr is unhashable because its __eq__ returns a SnakeCondition.

Many-to-many crosses a real model, never an implicit table:

tags: SnakeToMany["Tag"] = snake_to_many_through(through="PostTag", via="post", to="tag")

The bridge is an ordinary model, so extra columns on it are ordinary fields from day one.

A bulk UPDATE/DELETE uses the filter and nothing else. With no WHERE it is refused, and so is any other knob you set on the same query — limit(), order_by(), only(). Dropping what you asked for would answer a different question without saying so: select the rows first, then write by primary key.

annotate() counts with a correlated subquery, not a LEFT JOIN — a parent with no children comes out 0 instead of disappearing.

A view is a read-only model, and navigable. @snake_view maps a VIEW with typed columns, navigable both ways; session.add/update/delete reject it in the type, because writing requires a SnakeModel. CreateView/AlterView/DropView live in the migrations.

Migration history is .py, because python_type is a Python type. JSON would need a name↔type registry: a second type system, parallel to Python's and worse.


Typed annotations

Declare the result class — you choose the name, Python chooses the type:

@snake_result
class RealmStats(SnakeResult[Realm]):
    realm: Realm
    forge_count: int

rows = session.annotate(query, RealmStats, forge_count=Realm.forges.count())
rows[0].forge_count  # int, with IntelliSense
rows[0].realm.name   # str, navigation intact

For genuinely dynamic names, the escape hatch is explicit:

count = cast("int", realm.aggregate.forge_count)   # object -> the cast is mandatory

Without the cast it does not compile. Without annotating, it raises SnakeAggregateNotLoaded naming the aggregates it does have.


What is inside

Queries filter · order/limit/offset · group by/having · aggregates · annotate · explicit joins · include (to-one and to-many) · deep navigation · .any() · correlated subqueries · composite IN · only/defer · iterate (server cursor) · for_update · raw
SQL window functions with frame · UNION/INTERSECT/EXCEPT · WITH RECURSIVE · CASE/COALESCE/NULLIF · text, date and math functions · json_get · ILIKE
Writes insert/update/delete · upsert · bulk · RETURNING · savepoints · isolation levels · retry on transient conflict · refresh
Schema composite PK and FK · polymorphic inheritance · views · triggers · indexes (partial, functional, GIN/GIST/BRIN) · checks · comments · enums · custom converters
Engines PostgreSQL · MySQL/MariaDB · SQLite, all first class · Cap catalogue (Full/Degraded/Nope) · sync and async drivers · pool with pre_ping/recycle/timeout · statement timeout · EXPLAIN
Migrations autodetected diff · atomic runner · RebuildTable for SQLite · RunPython with reverse · squash · cross-app dependencies · drift detection against the live database
Tooling introspection and scaffold for the three engines · debug panel (ssr, envelope, timing, sidecar, otel) · index advisor · WSGI/ASGI/Django contrib · CLI

Row by row, with links to the code, the test and the page: feature index.


Architecture

Python class → Model Compiler → immutable metadata graph
                                        ↓
        SQL · migrations · query · session · CLI
decorators/  metadata/  compiler/  registry/  linker/
query/  expressions/  sql/  dialects/  drivers/  session/  migration/  cli/

Two axes that never mix: the dialect decides how SQL is written (placeholders, quoting, RETURNING, ON CONFLICT); the driver decides how it is executed. Models and graph are engine-agnostic — anything Postgres-specific reaching the model is a bug.

SQL is always parameterised: emission returns (sql, params) and values never enter the string. That kills injection, and it is what makes multi-engine possible.

Async reuses the whole core: SQL generation does not execute, so it has no colour.

Details in architecture; how to work here in CONTRIBUTING.


The type contract is a test

In test/typing/:

  • cases_positive.py — what must type, with assert_type.
  • cases_negative.py — what must not compile, each line carrying its # EXPECT: <code>.
  • The runner requires mypy to report exactly those errors on exactly those lines, and pyright to reject the same ones.

Break Truck.maker.nation.name and the suite fails.


Deliberately not built

  • Identity map and unit of work. Two queries to the same row return two objects. Writes are explicit; nothing is flushed behind your back.
  • Lazy loading. Touching an unloaded relation raises. This is what makes N+1 impossible by default.
  • Joined-table inheritance. Single table with a discriminator covers the polymorphism, and its price is one rule: a child's own columns must allow NULL.
  • Model default ordering. A hidden ORDER BY you did not write.

Known limits

  • storage=NATIVE in snake_enum (Postgres CREATE TYPE) is not built: ALTER TYPE ... ADD VALUE has no inverse, so its down_sql would be a lie. The default CHECK is reversible.
  • CHECKs are declared outside the class body (snake_checks(User, ...)). Inside it, __set_name__ has not run and the column does not know its own name.
  • An expression does not carry its owning model in the type: Maker.id and Truck.id are twins to the checker. Encoding the owner would break deep navigation and condition composition; where it matters (aggregates, .any()) it is validated at runtime.
  • No lazy loading, full-text search, JSON containment operators or array operators with a typed API.

The complete, current list is known limits — part of the contract, not a list of apologies.


Status

Not published on PyPI. The distribution is named snake-orm, the import name is snakeorm; both are explained in release.

Everything above is implemented and tested against real PostgreSQL, MySQL/MariaDB and SQLite. It has not run in production yet, and that is the one thing a repository cannot give itself.

Metadata

Release files for snake-orm 0.1.0b1

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

Source distribution (sdist)

Source distribution for snake-orm 0.1.0b1
File Size Uploaded Provenance Actions
snake_orm-0.1.0b1.tar.gz 486.5 kB With provenance Details for snake_orm-0.1.0b1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for snake-orm 0.1.0b1
File Interpreter ABI Platform Actions
snake_orm-0.1.0b1-py3-none-any.whl Python 3 none any Details for snake_orm-0.1.0b1-py3-none-any.whl

Total release size: 1.1 MB

Release files / snake_orm-0.1.0b1.tar.gz

Download URL snake_orm-0.1.0b1.tar.gz
Size 486.5 kB
Tags Source
SHA-256 checksum
How to use checksums
fe48f66c9ee2abfe7872ff18ee11f72ac1d9a0840df0b9532ab4ccdcb3c4ad8f
BLAKE2b-256 checksum
How to use checksums
3e26be4b24139f4bd6b7148e57e590cdd44be3fd330958f538d4dbe8b37a2e43
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.

Transparency log

Release files / snake_orm-0.1.0b1-py3-none-any.whl

Download URL snake_orm-0.1.0b1-py3-none-any.whl
Size 596.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9146f571fa6bac7a576d507a928034a4df9cb03b2df923d680c518ab61c94456
BLAKE2b-256 checksum
How to use checksums
4bd8c3168567337c9873ce2a6b78a423c264f376e44ebd9e6163c34e28db8b18
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.

Transparency log

Security for snake-orm 0.1.0b1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for snake_orm-0.1.0b1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release
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