Show HN: Repositron a typed CRUD for SQLAlchemy with no per-table boilerplate

felipeadeildo1 pts0 comments

repositron - repositron

Skip to content

Introduction

Guides

API reference

Design notes

repositron¶

A typed, generic repository base for SQLAlchemy 2.0.

Full CRUD, with zero per-table boilerplate .

Get started<br>Guides

Every SQLAlchemy project ends up with the same folder: one repository class per<br>table, each wrapping select(...) / session.scalars(...) in the same get,<br>the same list, the same pagination math. repositron writes that layer once,<br>generically, and types it against your model and your return shape.

Hand-written, per table Declared once

from sqlalchemy import select

class TaskRepository:<br>def __init__(self, session: Session) -> None:<br>self.session = session

def get(self, id: int) -> TaskDTO | None:<br>row = self.session.scalars(select(Task).where(Task.id == id)).first()<br>if row is None:<br>return None<br>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]:<br>stmt = select(Task)<br>if status is not None:<br>stmt = stmt.where(Task.status == status)<br>rows = self.session.scalars(stmt).all()<br>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:<br>task = self.session.scalars(select(Task).where(Task.id == id)).first()<br>if task is None:<br>return False<br>if assignee_id is not None: # and how do you unassign on purpose?<br>task.assignee_id = assignee_id<br>self.session.flush()<br>return True

# ...count, delete, first, pagination, then again for the next ten tables.

from dataclasses import dataclass<br>from repositron import Repository, UNSET, UnsetType

@dataclass(frozen=True, slots=True)<br>class TaskDTO: # light, detached, serializes straight to JSON<br>id: int<br>title: str<br>status: str<br>assignee_id: int | None

@dataclass<br>class TaskCreate:<br>workspace_id: int<br>title: str

@dataclass<br>class TaskUpdate:<br>title: str | UnsetType = UNSET # absent leaves it; None sets NULL<br>status: str | UnsetType = UNSET<br>assignee_id: int | None | UnsetType = UNSET

class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate]):<br>...

Every method from the other tab now exists, typed against TaskDTO, with no<br>further code.

What you get&para;

Typed end to end

repo.list() is list[TaskDTO], and your editor knows it. No casts, no<br>Any. 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<br>pick between readable and powerful.

Filtering

Updates that write NULL on purpose

UNSET leaves a column alone; None sets it to NULL. The is not None<br>pattern cannot tell those apart. repositron can.

Updating rows

Load only what you need

repo[Card].list() selects just that shape's columns, for one call, without<br>touching the injected repository.

Projection

Pagination that refuses to lie

list_paginated requires order_by and raises if you forget, turning a<br>production heisenbug into an error at the call site.

Pagination

One dependency

Just sqlalchemy>=2.0. Dataclass DTOs add nothing else; Pydantic is detected<br>only if your DTO is one.

Install&para;

uvpip

uv add repositron

pip install repositron

Python 3.13+ and sqlalchemy>=2.0.

Get started

Back to top

Copyright &copy; 2026 Felipe Adeildo

none task status assignee_id repositron session

Related Articles