01 · Getting started
This is your first conversation with sqlmodel-ext. In 15 minutes you'll have:
- sqlmodel-ext installed
- Your first model defined
- A complete CRUD round-trip running: insert, query, update, delete
- A clear mental model for "model + Mixin = table"
You don't need prior SQLAlchemy or SQLModel experience
This tutorial introduces those concepts on demand. You only need Python 3.10+ and basic async / await knowledge. If you've never touched an ORM, skim Prerequisites first.
0. Set up the environment
Create a new directory and a virtual environment:
mkdir hello-sqlmodel-ext
cd hello-sqlmodel-ext
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activateInstall sqlmodel-ext and the async SQLite driver:
pip install sqlmodel-ext aiosqlite1. Define your first model
Create app.py:
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str64
class UserBase(SQLModelBase):
name: Str64
"""User name"""
email: Str64
"""Email"""
class User(UserBase, UUIDTableBaseMixin, table=True):
passWhat just happened?
UserBaseinheritsSQLModelBase— this is a pure data model with no table. It only declares fields.Str64is a string type alias provided by sqlmodel-ext, equivalent toAnnotated[str, Field(max_length=64)]— it constrains Pydantic and creates aVARCHAR(64)column in SQLAlchemy in one step.Userinherits bothUserBase(gets the fields) andUUIDTableBaseMixin(gets a UUID primary key +created_at/updated_at+ the full set of CRUD methods).table=Truetells SQLModel "create a table".
Why split Base and Table
When you start writing APIs, UserBase becomes a useful POST request body (no id needed) while User is the database table. Tutorial 02 will use this pattern. For now, just remember: "Base — no table, Table — yes table".
2. Create the engine and session factory
Add this to app.py:
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import SQLModel
engine = create_async_engine("sqlite+aiosqlite:///hello.db", echo=True)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)echo=True makes SQLAlchemy print every SQL statement to the terminal — perfect for learning, since you can see exactly what's happening.
3. Run a CRUD round-trip
async def main() -> None:
await init_db()
async with SessionLocal() as session:
# CREATE
alice = User(name="Alice", email="alice@example.com")
alice = await alice.save(session)
print(f"Created: id={alice.id}")
# READ
fetched = await User.get_one(session, alice.id)
print(f"Read: name={fetched.name}")
# UPDATE
alice.name = "Alice Cooper"
alice = await alice.save(session)
print(f"Updated: name={alice.name}")
# LIST
users = await User.get(session, fetch_mode="all")
print(f"List: {len(users)} users")
# DELETE
deleted = await User.delete(session, alice)
print(f"Deleted: {deleted} rows")
if __name__ == "__main__":
asyncio.run(main())Run it:
python app.pyExpected output (apart from the SQL log):
Created: id=550e8400-e29b-41d4-a716-446655440000
Read: name=Alice
Updated: name=Alice Cooper
List: 1 users
Deleted: 1 rows4. Key takeaways
Always use the return value of save():
alice = await alice.save(session) # ✅ correct
await alice.save(session) # ❌ wrongWhy? session.commit() expires every object in the session — that's SQLAlchemy's design. save() returns a freshly-loaded object while the original alice variable is now expired. If you don't capture the return value, the next access to alice.name would trigger a re-fetch on an expired object — which in async land becomes a MissingGreenlet error.
This rule matters
Every save() / update() call must use the return value. Build the muscle memory: x = await x.save(session).
get_one vs get:
user = await User.get_one(session, user_id) # not found → exception
user = await User.get(session, User.id == user_id) # not found → NoneIn endpoints you usually use get_exist_one() — it auto-raises HTTP 404 when not found. Tutorial 02 will use it.
fetch_mode:
await User.get(session, fetch_mode="first") # T | None
await User.get(session, fetch_mode="one") # T, raises on 0 or multiple rows
await User.get(session, fetch_mode="all") # list[T]5. What you just learned
| Concept | Role |
|---|---|
SQLModelBase | Root class for all sqlmodel-ext models |
UUIDTableBaseMixin | Adds UUID PK + timestamps + CRUD methods |
Str64 and friends | Type aliases satisfying both Pydantic validation and SQLAlchemy column types |
save() / get() / get_one() / delete() | Async CRUD |
| The "use the return value" rule | After commit objects expire; you must work with the refreshed instance |
Next
Tutorial 02 builds a full blog API on the same pattern: users, articles, comments, with FastAPI endpoints, pagination, JOINs, and relation preloading.