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.
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
What you’ll learn · Before you start
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
On this page
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.
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.
- To load and save objects, the code calls an ORM, which works with objects and generates the query through a query builder.
- To build a query, the code calls a query builder directly, which produces SQL text and its values from method calls.
- 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:
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() Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
makemigrationswrites from model changes andmigrateapplies. - 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.
Loading related rows: lazy, IN list or join, measured Pro: opens with a Pro, Premium or Ultimate pass
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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.
-
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?
-
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?
-
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?
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
- SQLAlchemy 2.0: Relationship Loading Techniques (SQLAlchemy project)
- SQLAlchemy 2.0: Engine Configuration (create_engine echo) (SQLAlchemy project)
- SQLAlchemy 2.0: Describing Databases with MetaData (create_all) (SQLAlchemy project)
- Alembic: Auto Generating Migrations (SQLAlchemy project)
- Django documentation: QuerySet API reference (select_related, prefetch_related) (Django Software Foundation)
- Django documentation: Testing tools (assertNumQueries) (Django Software Foundation)
- Django documentation: Migrations (Django Software Foundation)
- Flyway: Versioned migrations (Redgate)
- Prisma: Migration histories (Prisma Data)
- Prisma: Raw queries (Prisma Data)
- Knex.js: Query Builder (Knex.js)
- Kysely: Introduction (Kysely)
- sqlite3: DB-API 2.0 interface for SQLite databases (set_trace_callback) (Python Software Foundation)
- SQLite: ALTER TABLE (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress