// python · databases

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 ORM — a mechanical translator: objects ↔ rows the gap it bridges program thinks in objects, DB thinks in rows class ↔ table, attribute ↔ column, instance ↔ row the mapping is mechanical → a program can do it a model knows its shape class name → table name (student → students) __annotations__ → column list introspection: the class reads ITSELF save() = object → INSERT columns + %s holes from the field list values ship SEPARATELY — parameterized, injection-safe SERIAL id — the database assigns identity never f-string a value into SQL filter() = kwargs → SELECT → objects each kwarg becomes col = %s in WHERE rows zip back into instances — hydration the round trip: object → row → object what Django adds migrations write the CREATE TABLE for you pk tracking: save() knows INSERT vs UPDATE lazy QuerySets, joins via select_related the pattern has a name: Active Record

Prerequisites: Object-oriented Python (classes, self, class attributes), PostgreSQL 101, and SSR part 2 — you'll reuse its database and psycopg. 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 ORMDjango ORM
you CREATE TABLE by handmigrations — generated from the model classes
save() always INSERTspk tracking — save() chooses INSERT vs UPDATE
filter() runs SQL immediatelylazy QuerySets — SQL runs only when you iterate
exact-match WHERE onlylookups (price__gt=100), joins (select_related)
cls.__name__.lower() + "s"proper pluralization, Meta.db_table override
one hardcoded connectionconnection 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 %s placeholders. 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/