Async ORM for Pydantic models and PostgreSQL. Define models with type annotations, get async CRUD with Django-style lookups.
Zero config. uv add AirModel and app = air.Air(). If DATABASE_URL is set in the environment, Air connects automatically and the pool is available as app.db. If DATABASE_URL is not set, app.db is None and no database is configured.
await app.db.create_tables()uv add AirModelfrom airmodel import AirDB, AirField, AirModel
db = AirDB()With FastAPI, Starlette, or any ASGI framework that accepts a lifespan:
app = FastAPI(lifespan=db.lifespan("postgresql://user:pass@host/dbname"))With plain async Python:
import asyncpg
pool = await asyncpg.create_pool("postgresql://user:pass@host/dbname")
db.connect(pool)
# ... on shutdown:
await pool.close()
db.disconnect()await db.create_tables() # CREATE TABLE IF NOT EXISTS for every AirModel subclassAuto-migrates existing tables: if you add a field to a model, create_tables() runs ALTER TABLE ADD COLUMN for any columns not yet in the database. Non-destructive: never drops columns, never changes types. New columns are added without NOT NULL (existing rows have no value for them); Pydantic still enforces requirements at the app layer.
class UnicornSighting(AirModel):
id: int | None = AirField(default=None, primary_key=True)
location: str
sparkle_rating: int
confirmed: bool = AirField(default=False)AirField(primary_key=True)becomesBIGSERIAL PRIMARY KEY- Table name derives from class name:
UnicornSightingbecomesunicorn_sighting - Required fields without defaults get
NOT NULL str | Noneis nullable
str (TEXT), int (INTEGER), float (DOUBLE PRECISION), bool (BOOLEAN), datetime (TIMESTAMP WITH TIME ZONE), UUID (UUID).
Every method is async.
# Create
sighting = await UnicornSighting.create(location="Rainbow Falls", sparkle_rating=11)
# Get one (returns None if not found, raises MultipleObjectsReturned if ambiguous)
sighting = await UnicornSighting.get(id=1)
# Filter
confirmed = await UnicornSighting.filter(confirmed=True, order_by="-sparkle_rating")
page = await UnicornSighting.filter(confirmed=True, limit=10, offset=20)
# All rows
all_sightings = await UnicornSighting.all(order_by="location", limit=50)
# Count
total = await UnicornSighting.count()
bright = await UnicornSighting.count(sparkle_rating__gte=8)
# Update
sighting.sparkle_rating = 12
await sighting.save()
await sighting.save(update_fields=["sparkle_rating"]) # partial update
# Delete
await sighting.delete()Append __lookup to any field name in filter(), get(), or count():
__gt,__gte,__lt,__lte— comparisons__contains—LIKE '%...%'__icontains—ILIKE '%...%'(case-insensitive)__in—= ANY(...), pass a list__isnull—IS NULL(True) orIS NOT NULL(False)
await UnicornSighting.filter(sparkle_rating__gte=8, location__icontains="falls")Single-query, require at least one filter for update/delete:
created = await UnicornSighting.bulk_create([
{"location": "Rainbow Falls", "sparkle_rating": 11},
{"location": "Crystal Cave", "sparkle_rating": 8},
])
updated = await UnicornSighting.bulk_update({"confirmed": True}, sparkle_rating__gte=10)
deleted = await UnicornSighting.bulk_delete(confirmed=False)With Air use app.db, otherwise use your db instance:
async with app.db.transaction():
await UnicornSighting.create(location="Rainbow Falls", sparkle_rating=11)
await UnicornSighting.create(location="Crystal Cave", sparkle_rating=8)