Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 18 – Using SQL from application code

ORMs, query builders and migration tools

What ORMs, query builders and migration tools do, how to read the SQL an ORM sends, when to write SQL by hand, and how to version schema changes.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: Three tools, three jobs · What an ORM sends, logged · A query builder composes SQL · Migrations keep schema changes in order. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Read the SQL an ORM generates
  • Decide when to write SQL by hand
  • Apply versioned schema changes with a migration tool

Before you start

ORMs, query builders and migration tools all sit between your code and the database, and each does a different job. An ORM (object-relational mapper) turns rows into objects and writes the SQL for you, so customer.orders becomes a SELECT you never typed. A query builder puts SQL together from method calls and keeps the values apart as parameters, without turning rows into objects. A migration tool keeps the schema’s history as numbered changes and applies each one once, on every database. All three generate or run SQL, so the skill they share is reading what they send: turn on the SQL log, count the statements, and write SQL by hand where a tool expresses the query badly.

Three tools, three jobs

Tool You write It produces
ORM classes and method calls SQL, and objects from rows
Query builder method calls SQL text and parameters
Migration tool numbered changes the same schema everywhere
Tool Examples
ORM SQLAlchemy’s ORM, Django’s ORM, Hibernate, Prisma Client
Query builder SQLAlchemy Core, Knex, Kysely, jOOQ
Migration tool Alembic, Django migrations, Flyway, Prisma Migrate
  • An ORM maps tables to classes and rows to objects, and loads related objects when your code reads them.
  • A query builder composes SQL from code, so optional filters and sorting stay safe and readable.
  • A migration tool applies numbered schema changes in order and records which ones each database has run.

Each has its typical trouble: N+1 queries from lazy loading in ORMs, a composed query that the engine plans badly in query builders, and an applied migration that someone edited, or a schema changed by hand, with migration tools.

ORMs and query builders are not either-or: SQLAlchemy has both, its ORM built on its Core query builder, and most ORMs let you drop down to a builder or to raw SQL for one query. Migration tools are separate from both, though ORMs usually ship one: Alembic for SQLAlchemy, Django’s own migrations, Prisma Migrate for Prisma.

Code reaches the database through an ORM, a query builder or hand-written SQL, all via a driver; a migration tool changes the schema.Your codeORMobjects in and outQuery builderSQL frommethod callsDriverSQL and valuessent apartDatabaseMigration toolnumbered changesobjectsa querySQL by handgenerateseach changeonce

Three ways to produce SQL, and one tool for the schema

Text description of the diagram

The figure shows your application code at the top and the database at the bottom.

  1. To load and save objects, the code calls an ORM, which works with objects and generates the query through a query builder.
  2. To build a query, the code calls a query builder directly, which produces SQL text and its values from method calls.
  3. SQL written by hand goes straight to the database driver.

All three paths end in the same database driver, which sends the SQL and the values separately to the database. Beside them, a migration tool changes the database's schema with numbered changes and applies each change once.

What an ORM sends, logged

ORMs can log every statement they send: create_engine(url, echo=True) in SQLAlchemy, the django.db.backends logger in Django. This example uses a 70-line mapper, tinyorm.py, that loads related rows the way ORMs do by default: lazily, the first time your code reads them. The loop looks like ordinary Python:

Customers and their orders, with the SQL log on

orm/lazy_loading.py

import sqlite3

from mart import build
from tinyorm import Session

conn = sqlite3.connect(":memory:")
build(conn)

# Code that reads like plain Python: each customer of Pune and their orders.
session = Session(conn, echo=True)
customers = session.customers("Pune")
for customer in customers:
    print(f"   {customer.name}: orders {[o.order_id for o in customer.orders]}")
print(f"{len(session.log)} statements for {len(customers)} customers: 1 for the list, then 1 per customer")

Output

SQL> SELECT customer_id, name FROM customers WHERE city = ? ORDER BY name ['Pune']
SQL> SELECT order_id, status FROM orders WHERE customer_id = ? ORDER BY order_id [1]
   Asha Kulkarni: orders [101, 103]
SQL> SELECT order_id, status FROM orders WHERE customer_id = ? ORDER BY order_id [5]
   Farah Qureshi: orders [105, 107]
SQL> SELECT order_id, status FROM orders WHERE customer_id = ? ORDER BY order_id [7]
   Lakshmi Iyer: orders [106, 110]
4 statements for 3 customers: 1 for the list, then 1 per customer

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy_loading.py

orm/tinyorm.py

"""A tiny object mapper, enough to see what an ORM sends: rows become objects, and related rows load on first use.

Real ORMs (SQLAlchemy, Django's ORM, Hibernate) do far more, but they load relationships in the same three ways:
lazily when the attribute is first read (the usual default), with a second SELECT and an IN list, or with a JOIN.
"""
import sqlite3


class Session:
    """Runs and logs every statement, as an ORM's SQL echo does, and counts the rows it reads."""

    def __init__(self, conn, echo=False, lazy="select"):
        self.conn, self.echo, self.lazy, self.log, self.rows = conn, echo, lazy, [], 0
        conn.row_factory = sqlite3.Row

    def run(self, sql, params=()):
        text = " ".join(sql.split())
        self.log.append(text)
        if self.echo:
            print("SQL>", text, list(params))
        rows = self.conn.execute(sql, params).fetchall()
        self.rows += len(rows)
        return rows

    def customers(self, city, load=None):
        """Customers of a city, with their orders loaded lazily, by IN list ("selectin") or by JOIN ("joined")."""
        if load == "joined":
            rows = self.run(
                "SELECT c.customer_id, c.name, o.order_id, o.status FROM customers c "
                "LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE c.city = ? ORDER BY c.name, o.order_id",
                (city,),
            )
            found = {}
            for r in rows:
                c = found.setdefault(r["customer_id"], Customer(self, r, orders=[]))
                if r["order_id"] is not None:
                    c._orders.append(Order(r))
            return list(found.values())
        found = [Customer(self, r) for r in self.run(
            "SELECT customer_id, name FROM customers WHERE city = ? ORDER BY name", (city,))]
        if load == "selectin" and found:
            ids = [c.customer_id for c in found]
            rows = self.run(
                f"SELECT order_id, customer_id, status FROM orders WHERE customer_id IN ({', '.join('?' * len(ids))}) "
                "ORDER BY order_id",
                ids,
            )
            by_id = {c.customer_id: c for c in found}
            for c in found:
                c._orders = []
            for r in rows:
                by_id[r["customer_id"]]._orders.append(Order(r))
        return found


class Customer:
    def __init__(self, session, row, orders=None):
        self._session, self.customer_id, self.name, self._orders = session, row["customer_id"], row["name"], orders

    @property
    def orders(self):
        if self._orders is None:
            if self._session.lazy == "raise":
                raise RuntimeError(f"Customer.orders of {self.name} was not loaded, and lazy loading is turned off")
            self._orders = [Order(r) for r in self._session.run(
                "SELECT order_id, status FROM orders WHERE customer_id = ? ORDER BY order_id", (self.customer_id,))]
        return self._orders


class Order:
    def __init__(self, row):
        self.order_id, self.status = row["order_id"], row["status"]

orm/mart.py

"""The mart practice shop with a few more customers and orders, built in any sqlite3 connection."""

SCHEMA = """
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  city        TEXT    NOT NULL
);
CREATE TABLE products (
  product_id  INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  category    TEXT    NOT NULL,
  price_paise INTEGER NOT NULL
);
CREATE TABLE orders (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers (customer_id),
  status      TEXT    NOT NULL
);
CREATE TABLE order_items (
  order_id    INTEGER NOT NULL REFERENCES orders (order_id),
  product_id  INTEGER NOT NULL REFERENCES products (product_id),
  quantity    INTEGER NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (order_id, product_id)
);
"""

CUSTOMERS = [
    (1, "Asha Kulkarni", "Pune"), (2, "Vikram Nair", "Kochi"), (3, "Meera Rathore", "Jaipur"),
    (4, "Tenzin Dorjee", "Gangtok"), (5, "Farah Qureshi", "Pune"), (6, "Rohan Das", "Kochi"),
    (7, "Lakshmi Iyer", "Pune"), (8, "Arjun Mehta", "Jaipur"),
]
PRODUCTS = [
    (1, "Basmati rice 1 kg", "Grains", 14999), (2, "Toor dal 500 g", "Pulses", 8950),
    (3, "Masala chai 250 g", "Beverages", 12000), (4, "Coconut oil 1 L", "Oils", 21500),
    (5, "Ragi flour 1 kg", "Grains", 6800),
]
ORDERS = [
    (101, 1, "delivered"), (102, 2, "delivered"), (103, 1, "placed"), (104, 3, "cancelled"),
    (105, 5, "delivered"), (106, 7, "delivered"), (107, 5, "delivered"), (108, 6, "placed"),
    (109, 8, "delivered"), (110, 7, "cancelled"),
]
ORDER_ITEMS = [
    (101, 1, 2), (101, 2, 1), (102, 4, 1), (103, 3, 2), (103, 5, 1), (104, 2, 3), (105, 3, 1),
    (105, 4, 2), (106, 1, 1), (107, 5, 3), (108, 2, 2), (109, 1, 1), (109, 3, 1), (110, 4, 1),
]


def build(conn):
    """Creates the four tables and their rows, then commits."""
    conn.executescript(SCHEMA)
    conn.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
    conn.executemany("INSERT INTO products VALUES (?, ?, ?, ?)", PRODUCTS)
    conn.executemany("INSERT INTO orders VALUES (?, ?, ?)", ORDERS)
    conn.executemany("INSERT INTO order_items VALUES (?, ?, ?)", ORDER_ITEMS)
    conn.commit()

Reading customer.orders ran a query each time, so three customers cost four statements: one for the list and one per customer. That is the N+1 pattern, the most common performance problem of ORM code. Nothing in the loop shows it; only the log does. With 3 customers it is harmless, but a page of 200 customers sends 201 statements, each a round trip to the database.

A query builder composes SQL

A builder suits queries whose shape depends on the input, such as a product search with optional filters. The small builder below adds one condition per filter given and passes every value as a parameter:

Optional filters, one SELECT each time Python · orm/builder.py
import sqlite3

from mart import build

conn = sqlite3.connect(":memory:")
build(conn)

SORTS = {"name": "name", "cheapest": "price_paise, name", "dearest": "price_paise DESC, name"}


def find_products(category=None, max_paise=None, sort="name"):
    """One SELECT built from the filters that were given: SQL with placeholders, and the values beside it."""
    where, params = [], []
    if category is not None:
        where.append("category = ?")
        params.append(category)
    if max_paise is not None:
        where.append("price_paise <= ?")
        params.append(max_paise)
    sql = "SELECT name, price_paise FROM products"
    if where:
        sql += " WHERE " + " AND ".join(where)
    sql += " ORDER BY " + SORTS[sort]  # a name chosen from a fixed set, never taken from the input
    print("SQL>", sql, params)
    return [name for name, _ in conn.execute(sql, params)]


print(find_products())
print(find_products(category="Grains"))
print(find_products(max_paise=13000, sort="dearest"))
try:
    find_products(sort="price_paise; DROP TABLE products")
except KeyError as e:
    print("refused sort:", e)

Output

SQL> SELECT name, price_paise FROM products ORDER BY name []
['Basmati rice 1 kg', 'Coconut oil 1 L', 'Masala chai 250 g', 'Ragi flour 1 kg', 'Toor dal 500 g']
SQL> SELECT name, price_paise FROM products WHERE category = ? ORDER BY name ['Grains']
['Basmati rice 1 kg', 'Ragi flour 1 kg']
SQL> SELECT name, price_paise FROM products WHERE price_paise <= ? ORDER BY price_paise DESC, name [13000]
['Masala chai 250 g', 'Toor dal 500 g', 'Ragi flour 1 kg']
refused sort: 'price_paise; DROP TABLE products'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 builder.py

Each call printed a different statement, and the values never entered the SQL text. The sort order is a column name, which a placeholder cannot hold, so the builder takes it from a fixed set; the attempt to sort by price_paise; DROP TABLE products was refused before any SQL ran. Knex and Kysely work the same way for JavaScript and TypeScript: knex('products').where('category', 'Grains') compiles to where "category" = ? with 'Grains' bound separately.

Migrations keep schema changes in order

A schema changes for as long as an application lives: a column is added, an index created, a table split. A migration tool keeps each change in its own numbered file, applies the files in order, and records in a table inside the database which ones have run, so development, test and production reach the same schema the same way. Treat an applied migration as history: when it turns out to be wrong, write a new migration that fixes it.

  • Alembic writes each change as a Python script with an upgrade() function and can draft it from SQLAlchemy models.
  • Django migrations are files that makemigrations writes from model changes and migrate applies.
  • Flyway runs versioned SQL files in order, exactly once each, and keeps a checksum of every file it ran.
  • Prisma Migrate generates SQL migration files from the Prisma schema and records which ones each database ran.

Check yourself

3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 3 A page lists 30 customers with their orders, and the ORM's SQL log shows 31 SELECT statements. What is going on?

    Choose one answer.

    Show the answer to question 1

    Answer: One query loads the customers, then each customer's orders load lazily with one more query: N+1

    Lazy loading runs a query the first time each customer's orders are read, so 30 customers cost 1 + 30 statements. Loading the orders up front, with one IN query or a join, brings it down to 2 or 1.

  2. Question 2 of 3 What does a query builder give you that formatting the SQL yourself with f-strings does not?

    Choose one answer.

    Show the answer to question 2

    Answer: Statements put together from parts, with every value sent as a parameter

    A builder composes SQL text from method calls and keeps the values apart as parameters, so optional filters are easy and injection-safe. Mapping rows to objects is an ORM's job, and the database still plans the query.

  3. Question 3 of 3 A migration that already ran in production created a column with the wrong type. What should you do?

    Choose one answer.

    Show the answer to question 3

    Answer: Write a new migration that changes the column, and leave the applied one as it is

    An applied migration is history: other databases have run it as written, and tools such as Flyway keep its checksum to notice edits. A new migration moves every environment forward the same way.

Every ORM offers the same three ways to load related rows, under different names. This part measures them on 40 customers, maps them to the calls of SQLAlchemy and Django, and turns accidental lazy loads into test failures.

  • Three strategies, counted in statements and rows
  • Making accidental lazy loads fail
  • Where N+1 hides, and where lazy loading is fine

1 runnable example (Python) · about 3 minutes

Opening this part with your pass…

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Migrations that cannot drift: order, checksums and a failure drill Pro: opens with a Pro, Premium or Ultimate pass

Migrations fail in two ways: a file that changed after it ran, and a change that cannot work on the data already in the table. This part runs both on a small migration runner and shows the sequence of steps that changes a live table safely.

In this part:

  • A runner that records checksums, run four times
  • Changing a column on a live table, step by step
  • Never let the ORM sync a production schema

1 runnable example (001_create_customers.sql, 002_add_email.sql, 003_index_city.sql, run_migrations.py, tinymigrate.py) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

When to write SQL by hand, with numbers Pro: opens with a Pro, Premium or Ultimate pass

An ORM is the right tool for loading and saving objects one at a time. Bulk changes and reports are where hand-written SQL is shorter, faster and easier to check. This part measures one of each and shows how to run your own SQL safely from each ORM.

In this part:

  • One UPDATE instead of 302
  • Running your own SQL safely from an ORM
  • Your turn: replace an N+1 loop with one query
  • One query instead of an ORM's N+1 loop
    • Results of the sample tests

1 runnable example (Python) · 1 exercise with sample tests · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is lazy loading in an ORM, and what is eager loading?

    Lazy loading fetches related rows only when your code first reads them: loading 30 customers sends one query, and reading each customer's orders sends one more query per customer, which is the N+1 pattern when it happens in a loop. It is the usual default, because many pages never touch the related rows. Eager loading fetches the related rows up front, together with the parents, either with a second query that uses an IN list of the parents' keys or with a join in the first query. SQLAlchemy calls these selectinload and joinedload; Django calls them prefetch_related and select_related. Choose eager loading when you know the page will read the relation for every row.

  2. Mid to senior

    A list page of 200 orders got slow after a release that only changed its template, to show each order's customer name and city. The ORM's log now shows 201 statements per request instead of 1. How do you find the cause, fix it, and stop it from happening again?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A developer fixed a wrong default value by editing migration 0042, which had already run in production last week, and the fix works on their laptop. Today the production deploy stops because the migration tool reports that 0042 has changed. What went wrong, how do you get the deploy moving, and what rule do you set?

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    Your team writes all data access through an ORM. A monthly revenue report per category, with growth against the previous month, takes 40 seconds and sends about 12,000 queries. Someone proposes rewriting it in hand-written SQL. How do you decide, and if you rewrite it, how do you keep it safe and maintainable?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • ORMs map rows to objects, query builders compose SQL from code, and migration tools apply numbered schema changes.
  • Turn on the SQL log and count the statements: lazy loading in a loop is the N+1 pattern.
  • An applied migration is history: fix mistakes with a new migration.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.