// 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 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:

# 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 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 itselfcls.__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 loopzip(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/