Postgres in Python.
Typed and fast.
Define your tables as Python models. Get fully typehinted SELECTs, INSERTs, and migrations, running close to raw-protocol speed. Common queries are easy. Complex ones stay possible.
from iceaxe import TableBase, Field, select class Person(TableBase): id: int = Field(primary_key=True) name: str age: int adults = await conn.exec( select(Person).where(Person.age >= 21))We wanted an ORM that felt like the rest of modern Python. Typed, explicit, fast.
Most ORMs abstract over every database at once: Postgres, MySQL, SQLite, all behind one interface. You rarely use that portability; almost nobody swaps databases mid-project. You pay for it anyway. Async gets bolted onto a synchronous core. Rows come back in bespoke types that don't act like the Python around them.
Iceaxe's philosophy is the opposite. It assumes Postgres, the database most webapps already run on, and treats async and types as the default rather than an afterthought. The 99% of queries that are plain SELECTs, INSERTs, and UPDATEs are trivial and fully typed. The rare complex query drops to SQL you can read, still typed on the way out. Easy things easy, hard things possible.
Trivially, and fully typed
SELECT, INSERT, and UPDATE as plain Python objects. No boilerplate, no stringly-typed columns.
adults = await conn.exec(
select(User).where(User.age >= 21)
)
# → list[User], fully typedRaw SQL, still typed
Drop to SQL you can read for the hard 1%, with proper type casting so results stay safe.
query = f"SELECT count(*) AS total FROM {sql(User)}"
totals = await conn.exec(
select(alias("total", int)).text(query)
)
# → list[int], cast straight to a primitiveselect() returns the right types.
Every query is statically checked by mypy and pyright. Reference a column that does not exist and it fails in your editor, not at runtime. Results come back as real Python objects, not dict[str, Any]. Even for complex joins and filtering.
Close to raw-protocol throughput.
Iceaxe sits on asyncpg with batched operations, connection pooling, and lean query building. It aims to match the fastest ORMs in Python and get out of the way of Postgres.
Leans into Postgres, not around it.
No lowest-common-denominator abstraction over five databases. Iceaxe targets Postgres directly, so native features are first-class and the generated SQL stays predictable.
Change a model, get a migration.
Generate migrations from your table definitions, review the SQL, and roll forward or back. Your schema is versioned alongside the code that depends on it.
When dict[str, Any] stops being good enough.
It starts clean. A few asyncpg calls and a couple of helpers. Then the queries multiply. Every result is a dict[str, Any] you hope you spelled right. One renamed column slips through to production. Iceaxe gives you that same direct line to Postgres, now with your models as the source of truth. The column that no longer exists is an error in your editor, not a 2am page.
$ uv add iceaxe$ docker run -p 5432:5432 postgres:16→ connect a DBConnection and queryAdd it and go.
Iceaxe ships as a single Python package with no required services beyond the Postgres you already run. Define a TableBase, open a DBConnection, and start querying with full types. When you're ready to deploy to production, we have you covered too.
Iceaxe & friends really can do it all.
Each can be used alone but really click together. Reach for one, or run the whole stack: together they're everything you need to take a webapp from weekend POC to Series C.
The core: typed table models, high-speed queries, and migrations generated from your Python.
Get startedA strongly-conventioned full-stack framework for Python. Server-rendered React with no API glue to write.
VisitDurable background workflows on Postgres. Plain async Python that survives restarts, with no replay.
VisitStop guessing what your queries
return.
Iceaxe types every row, from schema to result.
Get the updates.
New features, release notes, and type-driven Postgres deep dives for Iceaxe. Occasional, no spam.