Quickstart¶
This guide walks through setup and the two core usage modes: standalone (default) and transactions for related graphs.
Concepts at a glance¶
- Standalone (statement-driven): Each call (
save,first,create) uses its own short-lived session/statement. Great for simple CRUD and scripts. - Transaction-driven: Wrap work in
db.transaction()to share one session across steps; needed for related writes, cascades, or multi-step flows. - Sync vs async: Same API; async is used when an event loop is running. Set
derive_async=Falseif you only want sync engines. - Payloads: All helpers accept plain dicts; Pydantic is optional validation/serialization sugar.
- Escape hatch: Call
.alchemize()on any query to get the raw SQLAlchemySelectwhen you need advanced SQL (CTEs, window functions, hints). See The Escape Hatch.
Troubleshooting / FAQ¶
- “Do not include a driver in the URL” - Use driverless URLs (
postgresql://..., notpostgresql+psycopg://...). DuoORM injects drivers automatically. - Dialect mismatch - If you pass
dialect=..., it must match the URL’s dialect; otherwise aConfigurationErroris raised. - Async-only errors - If you created
Database(..., derive_async=False), async helpers (await Model.create(...),db.async_engine) will raise; use sync calls instead. - require_filter guard - Bulk helpers default to
require_filter=Trueto block table-wide writes. Set toFalseonly when you intend to affect every row.
Setup¶
Install¶
Use driverless URLs (e.g., postgresql://..., sqlite:///...); the ORM injects the right drivers. Optionally add dialect="postgresql" (or mysql, mssql, oracle, sqlite) to Database(...) if you want the URL validated against a declared dialect.
Scaffold the project¶
Create the recommended layout (database entrypoint, models package, schemas package, migrations).
Result:
.
├── db/
│ ├── database.py
│ ├── schemas/
│ │ └── __init__.py
│ ├── models/
│ │ └── __init__.py
│ └── migrations/
│ ├── alembic.ini
│ ├── env.py
│ └── ...
└── pyproject.toml
Need a different location? Run:
This creates or updates pyproject.toml so future migration commands find your db stack:
Configure the database¶
Edit db/database.py to set your connection.
from duo_orm import Database
db = Database("sqlite:///./test.db") # driverless URL; drivers managed for you
Note
If you pass derive_async=False, DuoORM will not build an async URL/engine. You can still use the synchronous API, but any async calls (or db.async_engine) will raise.
Fast demo setup
For quick demos or tests (not production), you can create tables directly without migrations:
Define models¶
Create db/models/user.py and db/models/post.py.
from __future__ import annotations
from typing import List
from duo_orm import Mapped, mapped_column, relationship
from ..database import db
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
age: Mapped[int]
posts: Mapped[List["Post"]] = relationship(back_populates="author")
from __future__ import annotations
from duo_orm import Mapped, mapped_column, relationship, ForeignKey
from ..database import db
from .user import User
class Post(db.Model):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
Expose them in db/models/__init__.py:
Define Pydantic schemas¶
duo-orm init also creates db/schemas/. Keep your Pydantic models here, mirroring your ORM models. DuoORM treats Pydantic as a core dependency, so no extra install flags are needed.
from pydantic import BaseModel, ConfigDict
class User:
class Create(BaseModel):
email: str
name: str
age: int
class Update(BaseModel):
email: str | None = None
name: str | None = None
age: int | None = None
model_config = ConfigDict(extra="forbid")
class Read(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
name: str
age: int
Tip
Schemas are optional convenience. All CRUD helpers also accept plain dictionaries if you prefer to skip Pydantic for certain code paths or tests.
Create and apply migrations¶
Generate and apply a migration to create the tables.
Usage¶
Standalone calls (default)¶
Most work uses single-statement calls with short-lived sessions. This is ideal for simple CRUD and scripts.
import asyncio
from db.database import db
from db.models import User
from db.schemas import User as UserSchema
async def main():
await db.create_all() # quick demo setup; use migrations in real projects
# Create from a Pydantic payload (validated) and save
ada = await User.create(UserSchema.Create(email="ada@example.com", name="Ada", age=30))
# One-shot read
ada = await User.where(User.name == "Ada").first()
print(ada.to_schema(UserSchema.Read))
if __name__ == "__main__":
asyncio.run(main())
Run:
Controlled transactions¶
Need more control over how work commits or rolls back? Use a transaction block. It becomes important when you work with related graphs, cascades, or multi-step flows that must stay consistent. In frameworks, a common pattern is to wrap each request in a dependency (e.g., FastAPI) that opens a transaction so handlers avoid dirty or partial state.
import asyncio
from db.database import db
from db.models import User, Post
from db.schemas import User as UserSchema
async def main():
async with db.transaction(): # shared session for the block
alice = await User.create(UserSchema.Create(email="alice@example.com", name="Alice", age=30))
await Post(title="DuoORM Quickstart", author=alice).save()
await Post(title="Advanced Queries", author=alice).save()
user_with_posts = await User.related("posts").where(User.name == "Alice").first()
print([p.title for p in user_with_posts.posts])
if __name__ == "__main__":
asyncio.run(main())
Synchronous?¶
Drop await and use the same API; the ORM chooses sync or async automatically based on context.
from db.database import db
from db.models import User
from db.schemas import User as UserSchema
def main():
db.create_all() # quick demo setup; use migrations in real projects
User.create(UserSchema.Create(email="sync@example.com", name="Syncy", age=45)) # standalone
with db.transaction(): # shared session for the block
User.create(UserSchema.Create(email="another@example.com", name="Another", age=50))
if __name__ == "__main__":
main()
Handy CRUD helpers¶
Model.create(payload)/Model.create_bulk(payloads, return_models=False): Persist in one step. Accepts dicts (or Pydantic models if you prefer); non-column keys are ignored.instance.save()vsModel.create(...):save()lets you build an instance and then persist;create()builds + saves in one call.instance.update(payload)/Query.update_bulk(payload, with_hooks=False, require_filter=True): Partial apply; missing/Nonefields are skipped when the payload supports that (e.g., Pydantic). The bulk variant guards against table-wide writes unless you setrequire_filter=False.instance.delete()/Query.delete_bulk(...): Remove a single instance or many. The bulk path supportswith_hooksand batching.Query.iterate(batch_size=200, batch=False): Stream rows (or batches); auto-orders by primary key when no explicitorder_byis set. Usepaginate(limit, offset)for page-style slices.
See also:
- Full CRUD coverage and examples: CRUD API
- Framework example: Framework Integration
- Deeper query patterns: User Guides: Querying Data
- Raw SQLAlchemy when you need it: The Escape Hatch