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

Installationfirst modelmigrations.

@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.

Download files

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

Source Distribution

snake_orm-0.1.0b1.tar.gz (486.5 kB view details)

Uploaded Source

Built Distribution

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

snake_orm-0.1.0b1-py3-none-any.whl (596.3 kB view details)

Uploaded Python 3

File details

Details for the file snake_orm-0.1.0b1.tar.gz.

File metadata

  • Download URL: snake_orm-0.1.0b1.tar.gz
  • Upload date:
  • Size: 486.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for snake_orm-0.1.0b1.tar.gz
Algorithm Hash digest
SHA256 fe48f66c9ee2abfe7872ff18ee11f72ac1d9a0840df0b9532ab4ccdcb3c4ad8f
MD5 d8524daf9bc5770076a9bec9c04164d2
BLAKE2b-256 3e26be4b24139f4bd6b7148e57e590cdd44be3fd330958f538d4dbe8b37a2e43

See more details on using hashes here.

Provenance

The following attestation bundles were made for snake_orm-0.1.0b1.tar.gz:

Publisher: release.yml on velezanthony/snake-orm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file snake_orm-0.1.0b1-py3-none-any.whl.

File metadata

  • Download URL: snake_orm-0.1.0b1-py3-none-any.whl
  • Upload date:
  • Size: 596.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for snake_orm-0.1.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 9146f571fa6bac7a576d507a928034a4df9cb03b2df923d680c518ab61c94456
MD5 4030b4bd3bfaedee54dfb9371de38035
BLAKE2b-256 4bd8c3168567337c9873ce2a6b78a423c264f376e44ebd9e6163c34e28db8b18

See more details on using hashes here.

Provenance

The following attestation bundles were made for snake_orm-0.1.0b1-py3-none-any.whl:

Publisher: release.yml on velezanthony/snake-orm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0b1 This release

2 files

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