How ORMs work: build a tiny one in ~50 lines
An ORM is a translator between two worlds you already know — objects with attributes, and tables with rows. You'll build one: a Model base class that introspects itself, generates parameterized INSERT and SELECT statements, and hydrates rows back into objects. Three simulators animate the mapping, the SQL generation, and the round trip.
mindmap — quick refresh
Prerequisites: Object-oriented Python (classes,
self, class attributes), PostgreSQL 101, and SSR part 2 — you'll reuse its database andpsycopg. Time: ~45 minutes.
Django's deepest magic trick is the line Student.objects.filter(course="AI") — no SQL in sight, objects come back. Here's the fact that dispels it: the mapping between objects and rows is mechanical. A class names a table, its attributes name columns, each instance is a row. Anything mechanical can be done by a program — and a program that translates between objects and rows is an ORM: an Object-Relational Mapper. Django's is ~10,000 lines. The one you're about to write is ~50, and it does the two moves that matter: turn an object into an INSERT, and turn a SELECT back into objects.
The gap an ORM bridges
You already live in both worlds. In Python you'd write s = Student(name="Asha", course="AI") and read s.name; in PostgreSQL the same fact is a row in a table, reached with SELECT name FROM students. Same data, two shapes — and code that manually converts between them (build SQL string, run it, unpack tuples, repeat everywhere) is the boilerplate ORMs exist to delete. Look at the two shapes side by side and the correspondence is one-to-one:
Once you see that every arrow in that mapping is a rule — lowercase the class name and add s; one column per attribute; one row per instance — the rest of this guide is just writing the rules down as code.
A model is a class that knows its shape
The first job: a base class whose subclasses can answer "what table am I?" and "what are my columns?" — by reading themselves. Save as tinyorm.py:
# tinyorm.py — the object↔row mapping, mechanized
import psycopg
DB = "dbname=learn" # same database as SSR part 2
class Model:
def __init__(self, **values):
for field in self._fields():
setattr(self, field, values.get(field))
def __repr__(self):
pairs = ", ".join(f"{f}={getattr(self, f)!r}" for f in self._fields())
return f"{type(self).__name__}({pairs})"
@classmethod
def _table(cls):
return cls.__name__.lower() + "s" # Student → "students"
@classmethod
def _fields(cls):
return list(cls.__annotations__) # the annotated class attributes
A concrete model is now just a shape declaration — the same annotation style you met with @dataclass:
class Student(Model):
name: str
course: str
Try it in a REPL: Student._table() returns "students", Student._fields() returns ["name", "course"], and Student(name="Asha", course="AI") gives an object with both attributes set. Nothing has touched the database yet — but the class can now describe itself, and that self-description is the raw material every ORM feature is built from. (This is the OOP guide's machinery earning its keep: class attributes, classmethods, and introspection.)
save(): object → INSERT
First translation, outbound. To store an object we need INSERT INTO <table> (<columns>) VALUES (...) — and every piece of that sentence is already in the class's self-description:
# inside Model
def save(self):
cols = self._fields()
holes = ", ".join(["%s"] * len(cols)) # "%s, %s"
sql = (f"INSERT INTO {self._table()} "
f"({', '.join(cols)}) VALUES ({holes})")
with psycopg.connect(DB) as conn:
conn.execute(sql, [getattr(self, c) for c in cols])
Note what is — and isn't — pasted into the SQL string. Table and column names come from the class (we wrote them; they're trusted). The values never enter the string: they travel separately through %s placeholders, exactly the parameterized-query rule from SSR part 2. That's why Student(name="x'); DROP TABLE students;--", course="AI").save() stores a weird name instead of destroying your table:
Two lines of insight hiding in those eight lines of code: SQL is built from metadata, and data is shipped around the SQL, never through it.
filter(): kwargs → SELECT → objects
Second translation, inbound — and the one that makes ORMs feel magical. Student.filter(course="AI") must become SELECT name, course FROM students WHERE course = %s, run it, then turn each returned tuple back into a Student. Keyword arguments are just a dict, so each key becomes a col = %s condition:
# inside Model
@classmethod
def filter(cls, **conditions):
sql = f"SELECT {', '.join(cls._fields())} FROM {cls._table()}"
if conditions:
where = " AND ".join(f"{col} = %s" for col in conditions)
sql += f" WHERE {where}"
with psycopg.connect(DB) as conn:
rows = conn.execute(sql, list(conditions.values())).fetchall()
return [cls(**dict(zip(cls._fields(), row))) for row in rows]
The last line is hydration — the inbound half of the mapping. Each row tuple gets zipped with the field names into keyword arguments, and cls(**...) builds a live object from them. Watch the full round trip:
That's the complete loop: Student(name="Asha", course="AI").save() goes object → SQL → row, and Student.filter(course="AI") comes back row → tuple → object. Fifty lines, no magic — every Django queryset you'll ever run is this loop with more rules.
What Django's ORM adds
Our tiny ORM is real, but Django's earns its 10,000 lines. The additions worth knowing by name:
| Tiny ORM | Django ORM |
|---|---|
you CREATE TABLE by hand | migrations — generated from the model classes |
save() always INSERTs | pk tracking — save() chooses INSERT vs UPDATE |
filter() runs SQL immediately | lazy QuerySets — SQL runs only when you iterate |
exact-match WHERE only | lookups (price__gt=100), joins (select_related) |
cls.__name__.lower() + "s" | proper pluralization, Meta.db_table override |
| one hardcoded connection | connection pooling, multiple databases, transactions |
One name worth keeping: this design — the object itself knows how to save and query itself — is the Active Record pattern (Fowler, 2003). Django, Rails, and Laravel all use it. When you later meet SQLAlchemy's different style (separate mapper objects), you'll be looking at the alternative, Data Mapper. Same gap, two schools of bridge-building.
Takeaways
- The object↔row mapping is mechanical — class ↔ table, attribute ↔ column, instance ↔ row — and an ORM is just that mapping written as code.
- A model describes itself —
cls.__name__and__annotations__give table and columns; every ORM feature is built from that self-description. - SQL comes from metadata; values ship separately — names you wrote go in the string, user data goes through
%splaceholders. This one habit is your SQL-injection immunity. - Hydration closes the loop —
zip(fields, row)+cls(**kwargs)turns tuples back into live objects; that's all "objects come back" means. - Django adds rules, not magic — migrations, pk tracking, lazy querysets, joins. The Active Record core is exactly what you just built.
References
- Django Software Foundation. (n.d.). Models (Django documentation). Retrieved August 17, 2026, from https://docs.djangoproject.com/en/stable/topics/db/models/
- Django Software Foundation. (n.d.). QuerySet API reference (Django documentation). Retrieved August 17, 2026, from https://docs.djangoproject.com/en/stable/ref/models/querysets/
- Fowler, M. (2003). Patterns of enterprise application architecture. Addison-Wesley.
- The Psycopg Team. (n.d.). Psycopg 3 documentation. Retrieved August 17, 2026, from https://www.psycopg.org/psycopg3/docs/