A typed, generic repository base for SQLAlchemy 2.0.
Full CRUD, with zero per-table boilerplate.
Every SQLAlchemy project ends up with the same folder: one repository class per
table, each wrapping select(...) / session.scalars(...) in the same get,
the same list, the same pagination math. repositron writes that layer once,
generically, and types it against your model and your return shape.
Hand-written, per table Declared once
from sqlalchemy import select
class TaskRepository:
def __init__(self, session: Session) -> None:
self.session = session
def get(self, id: int) -> TaskDTO | None:
row = self.session.scalars(select(Task).where(Task.id == id)).first()
if row is None:
return None
return TaskDTO(id=row.id, title=row.title, status=row.status, assignee_id=row.assignee_id)
def list(self, *, status: str | None = None) -> list[TaskDTO]:
stmt = select(Task)
if status is not None:
stmt = stmt.where(Task.status == status)
rows = self.session.scalars(stmt).all()
return [TaskDTO(id=r.id, title=r.title, status=r.status, assignee_id=r.assignee_id) for r in rows]
def update(self, id: int, *, assignee_id: int | None = None) -> bool:
task = self.session.scalars(select(Task).where(Task.id == id)).first()
if task is None:
return False
if assignee_id is not None: # and how do you unassign on purpose?
task.assignee_id = assignee_id
self.session.flush()
return True
# ...count, delete, first, pagination, then again for the next ten tables.
from dataclasses import dataclass
from repositron import Repository, UNSET, UnsetType
@dataclass(frozen=True, slots=True)
class TaskDTO: # light, detached, serializes straight to JSON
id: int
title: str
status: str
assignee_id: int | None
@dataclass
class TaskCreate:
workspace_id: int
title: str
@dataclass
class TaskUpdate:
title: str | UnsetType = UNSET # absent leaves it; None sets NULL
status: str | UnsetType = UNSET
assignee_id: int | None | UnsetType = UNSET
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate]):
...
Every method from the other tab now exists, typed against TaskDTO, with no
further code.
What you get¶
-
Typed end to end
repo.list()islist[TaskDTO], and your editor knows it. No casts, noAny. The return value is the same object your API serializes. -
Two ways to filter, one call
Equality by keyword and arbitrary SQLAlchemy expressions, combined. You never pick between readable and powerful.
-
Updates that write NULL on purpose
UNSETleaves a column alone;Nonesets it toNULL. Theis not Nonepattern cannot tell those apart. repositron can. -
Load only what you need
repo[Card].list()selects just that shape's columns, for one call, without touching the injected repository. -
Pagination that refuses to lie
list_paginatedrequiresorder_byand raises if you forget, turning a production heisenbug into an error at the call site. -
One dependency
Just
sqlalchemy>=2.0. Dataclass DTOs add nothing else; Pydantic is detected only if your DTO is one.
Install¶
Python 3.13+ and sqlalchemy>=2.0.

























