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, in the same folder as part 2's server, with that project's (.venv) active — this file imports the psycopg you installed onto that shelf:
# tinyorm.py — the object↔row mapping, mechanized
import psycopg
DB = "dbname=university user=postgres password=YOUR_PASSWORD host=127.0.0.1" # same DSN 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
(DB is part 2's connection string with the same YOUR_PASSWORD swap — the ORM talks to the very database your server renders from.)
Three pieces of Python are earning their keep in those twenty lines. @classmethod makes a method that receives the class itself as cls instead of an instance — the right shape for questions like "what table am I?", which belong to Student, not to any one student. In def __init__(self, **values), the ** collects whatever keyword arguments the caller passed into a dict — and this guide later uses the other direction too: at a call site, ** spreads a dict back out into keyword arguments. And setattr(self, field, ...) is ordinary attribute assignment for when the attribute's name lives in a variable (getattr, in __repr__, is the matching read).
A concrete model is now just a shape declaration — the same annotation style you met with @dataclass. Add it below Model in tinyorm.py:
class Student(Model):
name: str
course: str
Try it in the REPL — the interactive >>> prompt from the OOP guide. Open a terminal in the folder holding tinyorm.py and run python3 (Windows: py); from tinyorm import Student works because Python looks in the current folder first, and exit() leaves when you're done:
>>> from tinyorm import Student
>>> Student._table()
'students'
>>> Student._fields()
['name', 'course']
>>> Student(name="Asha", course="AI")
Student(name='Asha', course='AI')
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 and attribute lookup — plus the @classmethod newcomer glossed above.)
save(): object → INSERT
First translation, outbound — but one minute of setup first. In part 2 you picked every student's id by hand; an ORM expects the database to assign identities, and PostgreSQL's SERIAL type does exactly that. In pgAdmin, click the university database in the tree, open a Query Tool on it, and rebuild the table:
DROP TABLE students;
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name text NOT NULL,
course text NOT NULL
);
INSERT INTO students (name, course) VALUES
('Asha', 'AI'),
('Ravi', 'AI'),
('Meena', 'Data Science');
Note the INSERT names its columns and never mentions id — the table numbers its own rows now. And part 2's server doesn't care: it SELECTs the same three columns, so refresh http://127.0.0.1:8000/students and the page still renders. Nothing broke; the table just learned to count.
Now the translation. 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])
(["%s"] * len(cols) is a list of one %s per column — multiplying a list repeats it.) 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. zip pairs two sequences element by element — field names with row values, ("name", "course") against ('Asha', 'AI') — and cls(**...) is the ** spreading direction from earlier: the resulting dict fans out into keyword arguments, and a live object comes back. Watch the full round trip:
Now run the loop end to end. Start a fresh REPL — one already open won't see the methods you just added to the file:
>>> from tinyorm import Student
>>> Student(name="Tashi", course="AI").save()
>>> Student.filter(course="AI")
[Student(name='Asha', course='AI'), Student(name='Ravi', course='AI'), Student(name='Tashi', course='AI')]
>>> Student.filter(course="Data Science")
[Student(name='Meena', course='Data Science')]
That's the complete loop: Student(name="Tashi", course="AI").save() goes object → SQL → row (refresh part 2's page and Tashi is there), 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/