# Basic CRUD Repository and Functions !!! info "Primary sources" - [SQLModel create-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/multiple-models/) - [SQLModel update-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/update-extra-data/) - [SQLModel select tutorial](https://sqlmodel.tiangolo.com/tutorial/select/) - [SQLAlchemy `AsyncSession` API](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncSession) - [`nicegui-db` service functions](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/services/my_table.py) ??? abstract "Decision metadata" - Status: adopted - Decision level: advisory - Applies to: api-runtime, workers, tests - Last reviewed: 2026-08-06 --- ## Purpose Show a small SQLModel CRUD layer in two forms: - independent functions for convenient standalone or composed operations; - a repository object that groups those functions behind one domain-oriented interface. Template-style public functions use `@with_session` and accept an optional `AsyncSession`. When the argument is omitted, the decorator resolves the cached session factory and owns a short-lived session. When supplied, the function borrows the session without controlling its lifetime or transaction. The decorator does not commit, so standalone writes need a visible transaction strategy; repository methods remain explicit-session operations for predictable composition. Use the same vocabulary at every layer: | Operation | Function | Repository method | Scope when session is omitted | Missing-row result | |---|---|---|---|---| | Create | `create_widget()` | `create()` | Owned session; no implicit commit | Not applicable | | Read one | `get_widget()` | `get()` | Owned session | `None` | | Read many | `list_widgets()` | `list()` | Owned session | Empty list | | Update | `update_widget()` | `update()` | Owned session; no implicit commit | `None` | | Delete | `delete_widget()` | `delete()` | Owned session; no implicit commit | `None` | Functions and repository methods both put domain arguments first. Database configuration and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible. --- ## Models Start with one table model when the application does not need distinct persistence and API schemas. ```python from sqlmodel import Field from sqlmodel import SQLModel class Widget(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str = Field(index=True) description: str | None = None ``` This reference uses direct field arguments and full-update semantics to keep the CRUD mechanics visible. Introduce separate create, update, or public schemas only when an API boundary needs different validation, field visibility, or partial-update behavior. See [SQLModel integration](sqlmodel.md) for that larger modeling pattern. --- ## Independent CRUD Functions Functions are the simplest default when grouping state or behavior in an object adds no value. Decorate public service functions when both standalone reads and explicit composition are useful. The assertion narrows the optional type after decorator injection and catches accidental explicit `None` calls. ```python from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from .session import with_session @with_session async def create_widget( name: str, description: str | None = None, *, session: AsyncSession | None = None, ) -> Widget: assert session is not None, "Session must be provided by with_session decorator" widget = Widget(name=name, description=description) session.add(widget) await session.flush() return widget @with_session async def get_widget( widget_id: int, *, session: AsyncSession | None = None, ) -> Widget | None: assert session is not None, "Session must be provided by with_session decorator" return await session.get(Widget, widget_id) @with_session async def list_widgets( *, offset: int = 0, limit: int = 100, session: AsyncSession | None = None, ) -> list[Widget]: assert session is not None, "Session must be provided by with_session decorator" if offset < 0: raise ValueError("offset must be non-negative") if not 1 <= limit <= 100: raise ValueError("limit must be between 1 and 100") statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit) return list(await session.scalars(statement)) @with_session async def update_widget( widget_id: int, name: str, description: str | None, *, session: AsyncSession | None = None, ) -> Widget | None: assert session is not None, "Session must be provided by with_session decorator" widget = await session.get(Widget, widget_id) if widget is None: return None widget.name = name widget.description = description await session.flush() return widget @with_session async def delete_widget( widget_id: int, *, session: AsyncSession | None = None, ) -> Widget | None: assert session is not None, "Session must be provided by with_session decorator" widget = await session.get(Widget, widget_id) if widget is None: return None await session.delete(widget) await session.flush() return widget ``` Update and delete load the row through the same session that mutates it. This avoids accepting detached instances from an earlier standalone read and gives both operations an explicit `None` result that the application layer can map to a domain or HTTP error. Delete returns the loaded object for callers that need its values, but that object represents a row scheduled for deletion and must not be reused as persistent state. List operations validate their bounds and order by the primary key so pagination is deterministic. Add a unique tiebreaker whenever ordering by a non-unique field. `flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. A decorated write called without a session will therefore roll back when its owned session closes unless the function explicitly commits. Prefer passing a transaction-scoped session so several writes compose atomically. Use `await session.refresh(widget)` only when the operation deliberately needs database-generated state that was not returned during the flush; an unconditional refresh adds another query. --- ## Repository Object A repository can provide a stable domain-facing interface when several callers need the same grouped operations. It remains stateless here: every method requires a session and delegates to the analogous function. ```python from sqlmodel.ext.asyncio.session import AsyncSession class WidgetRepository: async def create( self, session: AsyncSession, name: str, description: str | None = None, ) -> Widget: return await create_widget( name, description, session=session, ) async def get( self, session: AsyncSession, widget_id: int, ) -> Widget | None: return await get_widget(widget_id, session=session) async def list( self, session: AsyncSession, *, offset: int = 0, limit: int = 100, ) -> list[Widget]: return await list_widgets( offset=offset, limit=limit, session=session, ) async def update( self, session: AsyncSession, widget_id: int, name: str, description: str | None, ) -> Widget | None: return await update_widget( widget_id, name, description, session=session, ) async def delete( self, session: AsyncSession, widget_id: int, ) -> Widget | None: return await delete_widget(widget_id, session=session) ``` The object is intentionally thin. Tests pass a transaction-scoped test session directly. The caller always owns that session and its transaction, and the repository never closes or commits it. If a read participates in a later write, pass the same session and place both operations inside the explicit transaction. This avoids splitting one use case across sessions and keeps SQLAlchemy's autobegin behavior from obscuring transaction ownership. Add a repository only when its naming, shared query policy, dependency substitution, or domain boundary improves the application. Independent functions remain a valid and often clearer design. --- ## Transaction Ownership Compose multiple calls under one use-case transaction. `db_transaction_scope()` owns the standalone engine, factory, session, and transaction lifetimes. Decorated CRUD functions detect the supplied session and borrow it; repository methods receive it directly. ```python from .session import db_transaction_scope async def replace_widget( repository: WidgetRepository, widget_id: int, replacement_name: str, replacement_description: str | None = None, ) -> Widget | None: async with db_transaction_scope() as active_session: deleted_widget = await repository.delete( active_session, widget_id, ) if deleted_widget is None: return None return await repository.create( active_session, replacement_name, replacement_description, ) ``` If creation fails, deletion rolls back with it. Inside an already-running application, prefer `async with session_factory.begin()` or `async with session.begin()` over `db_transaction_scope()` so the application-owned engine and factory remain in use. Do not add direct `commit()` calls to CRUD functions or repository methods because that prevents callers from composing several operations atomically. See [transaction boundaries](transactions.md) and [session management](session.md) for ownership details. --- ## Anti-Patterns - Storing one mutable `AsyncSession` on a long-lived repository object. - Creating sessions manually inside functions already using `@with_session`. - Passing database configuration through every CRUD call instead of injecting a session at the data-access boundary. - Assuming decorator-owned write sessions commit on close. - Forwarding explicit `session=None` when decorator injection was intended. - Accepting unbounded list queries. - Accepting detached ORM instances for update or delete when an identifier can be resolved in the active session. - Accessing unloaded attributes after a standalone repository read has closed its owned session. --- ## Operational Checks - Every CRUD call receives a task-local `AsyncSession`. - Standalone reads create and close a session at the service or application boundary. - Standalone reads may omit `session`; decorated writes receive a transaction-scoped session or explicitly own their commit policy. - Supplied write sessions remain caller-owned. - Each complete write operation declares a visible transaction boundary. - List operations have pagination and deterministic ordering where required. - Update requires values for both mutable fields; passing `None` explicitly clears the nullable description. - Get, update, and delete use the same identifier and missing-row semantics. - Decorated functions accept an optional keyword-only session; repository methods require one explicitly. - Standalone service reads load all state needed after their owned session closes. - Repository objects hold query policy when useful, never database configuration or request-scoped session state. --- ## Testing Checks - Create tests verify generated identifiers and persisted field values after commit. - Get and list tests cover found, missing, pagination, and ordering behavior. - List tests reject negative offsets and limits outside the supported range. - Update tests cover replacement of both mutable fields, including clearing the nullable description. - Update and delete tests cover missing identifiers without mutating the database. - Delete tests verify the returned row and its absence after commit. - Failure tests verify that a surrounding transaction rolls back all composed CRUD calls. - Optional-session read tests verify borrowed sessions remain open and owned sessions close without committing. - Decorated write tests verify supplied transactions remain caller-owned and omitted sessions do not imply a commit. - Composition tests pass one active session through several CRUD calls and verify one atomic commit or rollback.