Building a Tiny ORM on psycopg to See What an ORM Does
, 6 min read
Building a Tiny ORM on psycopg to See What an ORM Does
I use ORMs all the time without thinking much about what happens between my objects and the database. To find out, I wrote a small Python app against the Postgres cluster in my homelab. It started with raw SQL and no abstraction. I added layers one at a time, each time something hurt.
This post walks through those steps: raw psycopg, a generic data access object, a decorator that describes tables, and finally mappers and repositories. Each step fixes a problem the previous one exposed. At the end I'm left with a decent picture of what an ORM does for me, and a clear reason to stop building my own.
The setup
| Item | Value |
|---|---|
| Language | Python 3.12+, managed with uv |
| Driver | psycopg 3 |
| Database | Postgres on my homelab Kubernetes cluster |
| Domain | Users, items and orders, a small order-management app |
Step 1: raw SQL with psycopg
psycopg makes the first step easy. Open a connection, get a cursor and run SQL:
with psycopg.connect(settings.pg_url) as conn, conn.cursor() as cursor:
cursor.execute(
"SELECT * FROM users WHERE national_identifier = %s", ("111111111",)
)
user = cursor.fetchone() # a tuple: (id, name, national_identifier)
cursor.execute(
"INSERT INTO orders (user_id, status) VALUES (%s, %s)",
(user[0], "IN_PROGRESS"),
)
conn.commit()
This works, and it shows clearly what goes to the database. Two problems appear quickly:
- Nothing is reusable. Every query is a one-off string. Inserting into
itemsmeans writing a newINSERTwith a new column list, even though it follows the same pattern asusers. - Results are tuples.
user[0]is the id only because of the column order in the table. Add a column and that code silently breaks.
What I wanted was one API to interact with any table, so the rest of the code doesn't build SQL strings.
Step 2: dataclasses and a generic data access object
The first abstraction has two parts.
The first part is a set of dataclasses that mirror the tables, one per table, with one field per column:
@dataclass
class User:
name: str
national_identifier: str
id: UUID | None = None
The second part is a generic data access object that builds SQL from those dataclasses. It's generic over the row type T and the primary-key type K, so one class serves every table:
class DBAPI[T, K]:
def __init__(self, conn: Connection, model_class: type[T]) -> None: ...
def insert(self, entity: T) -> T: ...
def fetch_all(self) -> Iterable[T]: ...
def fetch_by_id(self, id: K) -> T | None: ...
def delete_all(self) -> None: ...
Two psycopg features do most of the work:
psycopg.sqlcomposes queries safely. Table and column names go throughsql.Identifier, which quotes them, and values go through placeholders, so nothing is concatenated into the query text.insertreads the column list fromdataclasses.fields(entity)and skips fields that areNone. That lets the database fill defaults like generated ids, andRETURNING *hands them back.class_rowturns rows into objects. Withrow_factory=class_row(model_class), psycopg builds the dataclass from each row, so code readsuser.idinstead ofuser[0].
names = [f.name for f in dataclasses.fields(entity) if getattr(entity, f.name) is not None]
values = [getattr(entity, n) for n in names]
statement = sql.SQL("insert into {table} ({fields}) values ({}) returning *").format(
sql.SQL(", ").join(sql.Placeholder() * len(names)),
table=sql.Identifier(table_name),
fields=sql.SQL(", ").join(map(sql.Identifier, names)),
)
This alone gives a lot of flexibility. Inserting a user and inserting an item now go through the same code:
users = DBAPI[User, UUID](conn, User)
items = DBAPI[Item, int](conn, Item)
users.insert(User("Test user", "123456789"))
items.fetch_all()
Step 3: describing tables with a decorator
The generic version soon hits the places where tables differ. The K type parameter already covers different primary-key types, such as a UUID for users and an int for items. The SQL also needs two facts the dataclass doesn't carry:
- The table name. The class is
User, but the table isusers. Items live initemsand order lines inorders_items. - The primary-key column.
fetch_by_idneeds to know which column to filter on. It'sidfor users butcodefor items.
I could pass these into every DBAPI constructor, but then each call site has to know schema details. Instead I attached the metadata to the class with a decorator, much like the table and column annotations that ORMs such as JPA use:
@table("items", "code")
@dataclass
class Item:
name: str
description: str
value: Decimal
quantity: int
code: int | None = None
The decorator doesn't change the class. It records the metadata in a module-level registry and returns the class unchanged:
registry: dict[str, TableMetadata] = {}
def table(name: str, pk: str = "id"):
def decorate(cls):
registry[cls.__qualname__] = TableMetadata(name, pk, cls)
return cls
return decorate
DBAPI looks the class up in the registry when it's created, and raises if the class was never decorated. Every query then reads the table and primary-key column from that metadata:
statement = sql.SQL("select * from {table} where {id} = {id_value}").format(
table=sql.Identifier(self._tableMetadata.table_key),
id=sql.Identifier(self._tableMetadata.table_p_key),
id_value=sql.Literal(id),
)
The call site stays DBAPI[Item, int](conn, Item), with no schema arguments. The class describes itself, and the data access object reads that description. That is the core pattern behind declarative ORMs.
Step 4: mappers and a repository layer
With table access sorted, a different problem showed up. The objects the application wants to work with aren't the same as the rows in the database.
| Concept | Application object | Table row |
|---|---|---|
| Item code | str, with validation rules |
int identity column starting at 10000 |
| Order owner | a whole User |
a user_id foreign key |
| Timestamps | optional, set by the app or the database | filled by column defaults |
If the application used the row dataclasses directly, every business rule would be shaped by the table layout. So I split them in two:
- Domain objects in
domain/. They're plain dataclasses with business rules, such asItem.validate()andOrder.create_order(), and no database knowledge. - Row models in
models/. They're decorated with@tableand match the tables exactly.
Something has to convert between the two. That's a mapper, a small class with two methods:
class UserMapper:
def to_table(self, input: User) -> UserRow:
return UserRow(name=input.name, national_identifier=input.national_identifier)
def from_table(self, output: UserRow) -> User:
return User(id=output.id, name=output.name,
national_identifier=output.national_identifier)
On top of the mapper and the DBAPI sits a repository. It takes domain objects in, converts them, calls the DBAPI and converts the results back. The rest of the app never sees a row model:
class ItemRepository:
def __init__(self, dao: DBAPI[ItemRow, int], mapper: Mapper[Item, ItemRow]): ...
def insert(self, entity: Item) -> Item:
if entity.code is not None and self._dao.fetch_by_id(int(entity.code)):
raise ValueError("Item already exists")
inserted = self._dao.insert(self._mapper.to_table(entity))
return self._mapper.from_table(inserted)
The repository is also where the API starts to speak the application's language instead of generic database operations. It works in domain types, and it holds rules like "an item code must be unique". Above it, managers such as UserManager.create_user and OrderManager.place_order expose business operations. They depend on a Repository Protocol instead of a concrete class, so a test can swap in an in-memory version.
A single call now flows through every layer:
UserManager.create_user(User) application
-> UserRepository.insert(User) repository
-> UserMapper.to_table(User) -> UserRow mapper
-> DBAPI[UserRow, UUID].insert(UserRow) data access
-> INSERT ... RETURNING * psycopg -> Postgres
<- UserRow -> UserMapper.from_table -> User
Where this leaves me
Building these layers by hand showed the behind the scenes of what an ORM does for me: it generates SQL from class metadata, turns rows into objects, and keeps the domain separate from the schema. It also showed me what's still missing, and every gap is a large piece of work:
- Relationships. An order row can hold
user_id, but not aUser. Loading related objects without N+1 queries is a feature of its own. - A unit of work.
DBAPIcommits after every statement, so placing an order with several line items can't be atomic. - Queries and migrations. There's no filtering, ordering or pagination, and the tables are created with raw
CREATE TABLEstrings.
Building those isn't the goal of my project, so the next step is moving to SQLAlchemy. The domain, the application layer and the mappers stay as they are. The @table registry, DBAPI and the commit-per-statement model are what get replaced.
- python
- postgres
- psycopg
- orm
- architecture
- homelab