Skip to main content

A modern, fast ORM for Python.

Project description

iceaxe

Iceaxe Logo

Python Version Test status

A modern, fast ORM for Python. We have the following goals:

  • 🏎️ Performance: We want to exceed or match the fastest ORMs in Python. We want our ORM to be as close as possible to raw-asyncpg speeds. See the "Benchmarks" section for more.
  • 📝 Typehinting: Everything should be typehinted with expected types. Declare your data as you expect in Python and it should bidirectionally sync to the database.
  • 🐘 Postgres only: Leverage native Postgres features and simplify the implementation.
  • Common things are easy, rare things are possible: 99% of the SQL queries we write are vanilla SELECT/INSERT/UPDATEs. These should be natively supported by your ORM. If you're writing really complex queries, these are better done by hand so you can see exactly what SQL will be run.

Iceaxe is used in production at several companies. It's also an independent project. It's compatible with the Mountaineer ecosystem, but you can use it in whatever project and web framework you're using.

For comprehensive documentation, visit https://iceaxe.sh.

To auto-optimize your self hosted Postgres install, check out our new autopg project.

Installation

If you're using poetry to manage your dependencies:

uv add iceaxe

Otherwise install with pip:

pip install iceaxe

Usage

Define your models as a TableBase subclass:

from iceaxe import TableBase

class Person(TableBase):
    id: int
    name: str
    age: int

TableBase is a subclass of Pydantic's BaseModel, so you get all of the validation and Field customization out of the box. We provide our own Field constructor that adds database-specific configuration. For instance, to make the id field a primary key / auto-incrementing you can do:

from iceaxe import Field

class Person(TableBase):
    id: int = Field(primary_key=True)
    name: str
    age: int

Structured JSON values and per-model identifier types also work naturally in table definitions:

from typing import NewType
from uuid import UUID

from iceaxe import Field, TableBase
from pydantic import BaseModel

PersonId = NewType("PersonId", UUID)
OrganizationId = NewType("OrganizationId", UUID)

class Preferences(BaseModel):
    theme: str
    notifications: bool

class Organization(TableBase):
    id: OrganizationId = Field(primary_key=True)

class Person(TableBase):
    id: PersonId = Field(primary_key=True)
    organization_id: OrganizationId
    preferences: Preferences = Field(is_json=True)

person_id = PersonId(UUID("12345678-1234-5678-1234-567812345678"))
organization_id = OrganizationId(UUID("87654321-4321-6789-4321-678943216789"))
preferences = Preferences(theme="dark", notifications=True)

Person(id=person_id, organization_id=organization_id, preferences=preferences)
Person(id=organization_id, organization_id=person_id, preferences=preferences)  # type checker error

Field(is_json=True) will round-trip Pydantic models through a JSON column. NewType identifiers are stored using their underlying Postgres type, so PersonId and OrganizationId are both UUID columns while static type checkers can still flag accidentally swapped IDs. Simple subclasses of types like UUID, str, int, date, and datetime are also stored using their base Postgres type while being returned as their subclass in Python.

Okay now you have a model. How do you interact with it?

Databases are based on a few core primitives to insert data, update it, and fetch it out again. To do so you'll need a database connection, which is a connection over the network from your code to your Postgres database. The DBConnection is the core class for all ORM actions against the database.

from iceaxe import DBConnection
import asyncpg

conn = DBConnection(
    await asyncpg.connect(
        host="localhost",
        port=5432,
        user="db_user",
        password="yoursecretpassword",
        database="your_db",
    )
)

The Person class currently just lives in memory. To back it with a full database table, we can run raw SQL or run a migration to add it:

await conn.conn.execute(
    """
    CREATE TABLE IF NOT EXISTS person (
        id SERIAL PRIMARY KEY,
        name TEXT NOT NULL,
        age INT NOT NULL
    )
    """
)

Magic Migrations

For local development or side projects, you can use magic_migrate to automatically sync your database schema with your models:

await conn.magic_migrate("my_project")

If you want to limit the sync to a subset of tables or label the generated revision, you can also pass models=[...] and message="...".

This will:

  1. Compare your current database schema against your model definitions
  2. Generate a migration file if changes are detected
  3. Apply all pending migrations

The migration files are written to your package's migrations/ folder, giving you a history of schema changes.

Recommended workflow for production:

While magic_migrate is convenient for rapid local iteration, we recommend a more controlled approach before merging to production:

  1. Iterate freely during development using magic_migrate
  2. Before merging, reset your database to the production schema state
  3. Run uv run migrate generate once to generate a single, clean migration file
  4. Commit this migration file with your PR

This ensures your production migrations are clean and reviewable, while still giving you the speed of automatic migrations during development.

Inserting Data

Instantiate object classes as you normally do:

people = [
    Person(name="Alice", age=30),
    Person(name="Bob", age=40),
    Person(name="Charlie", age=50),
]
await conn.insert(people)

print(people[0].id) # 1
print(people[1].id) # 2

Because we're using an auto-incrementing primary key, the id field will be populated after the insert. Iceaxe will automatically update the object in place with the newly assigned value.

Updating data

Now that we have these lovely people, let's modify them.

person = people[0]
person.name = "Blice"

Right now, we have a Python object that's out of state with the database. But that's often okay. We can inspect it and further write logic - it's fully decoupled from the database.

def ensure_b_letter(person: Person):
    if person.name[0].lower() != "b":
        raise ValueError("Name must start with 'B'")

ensure_b_letter(person)

To sync the values back to the database, we can call update:

await conn.update([person])

If we were to query the database directly, we see that the name has been updated:

id | name  | age
----+-------+-----
  1 | Blice |  31
  2 | Bob   |  40
  3 | Charlie | 50

But no other fields have been touched. This lets a potentially concurrent process modify Alice's record - say, updating the age to 31. By the time we update the data, we'll change the name but nothing else. Under the hood we do this by tracking the fields that have been modified in-memory and creating a targeted UPDATE to modify only those values.

Selecting data

To select data, we can use a QueryBuilder. For a shortcut to select query functions, you can also just import select directly. This method takes the desired value parameters and returns a list of the desired objects.

from iceaxe import select

query = select(Person).where(Person.name == "Blice", Person.age > 25)
results = await conn.exec(query)

If we inspect the typing of results, we see that it's a list[Person] objects. This matches the typehint of the select function. You can also target columns directly:

query = select((Person.id, Person.name)).where(Person.age > 25)
results = await conn.exec(query)

This will return a list of tuples, where each tuple is the id and name of the person: list[tuple[int, str]].

We support most of the common SQL operations. Just like the results, these are typehinted to their proper types as well. Static typecheckers and your IDE will throw an error if you try to compare a string column to an integer, for instance. A more complex example of a query:

query = select((
    Person.id,
    FavoriteColor,
)).join(
    FavoriteColor,
    Person.id == FavoriteColor.person_id,
).where(
    Person.age > 25,
    Person.name == "Blice",
).order_by(
    Person.age.desc(),
).limit(10)
results = await conn.exec(query)

As expected this will deliver results - and typehint - as a list[tuple[int, FavoriteColor]]

For the common "fetch the first matching model or fail" case, use .one():

from iceaxe import NoObjectFound

try:
    person = await conn.exec(
        select(Person)
        .where(Person.id == 1)
        .one()
    )
except NoObjectFound:
    person = None

.one() only applies to a single full-model select like select(Person). It adds LIMIT 1, returns a single Person instead of list[Person], and raises NoObjectFound if the query returns no rows.

When a query fails, Iceaxe raises IceaxeQueryError with the SQL text and variables attached to the exception message while still preserving the original asyncpg exception type.

Production

Note that underlying Postgres connection wrapped by conn will be alive for as long as your object is in memory. This uses up one of the allowable connections to your database. Your overall limit depends on your Postgres configuration or hosting provider, but most managed solutions top out around 150-300. If you need more concurrent clients connected (and even if you don't - connection creation at the Postgres level is expensive), you can adopt a load balancer like pgbouncer to better scale to traffic. More deployment notes to come.

It's also worth noting the absence of request pooling in this initialization. This is a feature of many ORMs that lets you limit the overall connections you make to Postgres, and re-use these over time. We specifically don't offer request pooling as part of Iceaxe, despite being supported by our underlying engine asyncpg. This is a bit more aligned to how things should be structured in production. Python apps are always bound to one process thanks to the GIL. So no matter what your connection pool will always be tied to the current Python process / runtime. When you're deploying onto a server with multiple cores, the pool will be duplicated across CPUs and largely defeats the purpose of capping network connections in the first place.

Benchmarking

We have basic benchmarking tests in the __tests__/benchmarks directory. To run them, you'll need to execute the pytest suite:

uv run pytest -m integration_tests

Current benchmarking as of October 11 2024 is:

raw asyncpg iceaxe external overhead
TableBase columns 0.098s 0.093s
TableBase full 0.164s 1.345s 10%: dict construction 90%: pydantic overhead

Development

If you update your Cython implementation during development, you'll need to re-compile the Cython code. This can be done with a simple uv sync.

uv sync

Project details


Release history Release notifications | RSS feed

Download files

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

Source Distribution

iceaxe-0.13.0.tar.gz (231.0 kB view details)

Uploaded Source

Built Distributions

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

iceaxe-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (302.9 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ x86-64

iceaxe-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (299.8 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ ARM64

iceaxe-0.13.0-cp313-cp313-macosx_11_0_arm64.whl (296.2 kB view details)

Uploaded CPython 3.13macOS 11.0+ ARM64

iceaxe-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (302.8 kB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ x86-64

iceaxe-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (299.8 kB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ ARM64

iceaxe-0.13.0-cp312-cp312-macosx_11_0_arm64.whl (296.2 kB view details)

Uploaded CPython 3.12macOS 11.0+ ARM64

iceaxe-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (298.1 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ x86-64

iceaxe-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (295.5 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ ARM64

iceaxe-0.13.0-cp311-cp311-macosx_11_0_arm64.whl (291.9 kB view details)

Uploaded CPython 3.11macOS 11.0+ ARM64

File details

Details for the file iceaxe-0.13.0.tar.gz.

File metadata

  • Download URL: iceaxe-0.13.0.tar.gz
  • Upload date:
  • Size: 231.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for iceaxe-0.13.0.tar.gz
Algorithm Hash digest
SHA256 06bf28db6fb239f7d0f8ce4f5f794e202d4f3f1859f8e693347e260ec25ab275
MD5 72fa8fbeee3162e2e075496e5dd2767e
BLAKE2b-256 35b865828b0286a445f61d9b025e5f0ef5a8bc836ccf391d18ee6c61a68bbd3e

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0.tar.gz:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 ef5b8b69c441f85514aa87c45d9b6ef55fc4c197870babf66248c47ec145d86c
MD5 8f1024042b2370e8cf863c68a4af2b93
BLAKE2b-256 d11037da4d7b0b8eeba2f277c1670ebc7eae83ae45439dec50a396f0f22d7fb3

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Algorithm Hash digest
SHA256 9bced6d47be125933edd8757b950da49d64e8562c776204e00e56d5f414f85f3
MD5 8fa2c575a28f3d696ea0ab759527684f
BLAKE2b-256 0a5ddcfcb378226507e65f56bdae63c4903f1d7d189b4d9837901a2fb80e6919

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp313-cp313-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp313-cp313-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 43d6e682ad94a84b8b4fedccbe4a48537c6fc93afdb02a94441296e0ebc197af
MD5 0bdea2bb17085acb3998a493a90de4d4
BLAKE2b-256 1212132b64f13b975cf21896aa157a536db0beeb34736d2c3841f1697c604a89

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp313-cp313-macosx_11_0_arm64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 43e672514e7eda742815c5f7fa7a8b9af7fa2cc5c77abe6e6c2055c406478c8b
MD5 8a251c69e2c57823ae50c58d745c12ab
BLAKE2b-256 d0f2608bbc62d1d71fef680bda08f9a9c95e07d5932e04e88ba09888833fefce

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Algorithm Hash digest
SHA256 074566165660e79b775b77679b84d9bfcd88a55e4bd2ea31ed887c6c49b06925
MD5 084c0baab1e4c3cb7d47d2a00b25b7f9
BLAKE2b-256 260e93925ee662002f41ffefa6478880c32677a80b61d48375c9267ec820a329

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp312-cp312-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp312-cp312-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 32a78a742997f30b11fb492299f04fc19c4d37b58ca68b039f997a7ebda7d996
MD5 a34fb13c7e671ed7ceca4626139fba6a
BLAKE2b-256 139f71e2c373c2d13c42733d89d068e96197495feb94c942b6478bb3a4412f94

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp312-cp312-macosx_11_0_arm64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 d4e03a31298d239c541399731965059f8130da41d9b0e2fdf88372c4469238aa
MD5 176df516bb6e9057153ef27f890c2164
BLAKE2b-256 4d00cdcab7bc36d233563d730b9f19de9fc87c49683b60eff8f94d3ded9af1de

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Algorithm Hash digest
SHA256 f86a782ae2c95aa51c64713c8476588d96afa4873f9c8b6b14103c6e7cae6c43
MD5 cff22408679207fccd3e7d63c8fda92e
BLAKE2b-256 697aa1b54ba6c7531ceb43d9a21c6a95d9a3cf16cb7fdf3c45f8d1c5005f9f54

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

File details

Details for the file iceaxe-0.13.0-cp311-cp311-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for iceaxe-0.13.0-cp311-cp311-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 c7ebe6d6d95547d997cfde30faedfbcf58191e0b43abdbb22b14d3288a99b3eb
MD5 1dfdf88ca639a3ca47b8ff6adcf45e35
BLAKE2b-256 d6a3f915ec3c185fd6bd0e71b43175f36c01ec9079c36c9a4fde5d0e73a78ae0

See more details on using hashes here.

Provenance

The following attestation bundles were made for iceaxe-0.13.0-cp311-cp311-macosx_11_0_arm64.whl:

Publisher: test.yml on piercefreeman/iceaxe

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

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page