The whole course has run SQL from psql: you type, you hit Enter, you read the result. In a real application none of that happens. There's a web process serving hundreds of requests per second, each with a few milliseconds of budget, that can't afford to open a connection, that runs its queries from code in another language, that often doesn't even write them —an ORM generates them— and in which a design mistake shows up not as a message but as "the site has been slow since yesterday".

This lesson is that environment: how an application connects, how it executes and how it manages its transactions, what an ORM gives you and what it takes away, the four antipatterns that kill a website —starting with the N+1 that 08-04 left pending—, the patterns that do work and the checklist for when something goes wrong. With it the module closes and you're ready for the final project.

Contents

  1. The connection and the pool
  2. Running SQL from code
  3. ORM versus hand-written SQL
  4. The antipatterns that kill a website
  5. Useful patterns
  6. Operations: deployment, monitoring and timeouts
  7. "The site is slow": a checklist
  8. Common Mistakes and Tips
  9. Exercises
  10. Module conclusion

  1. The connection and the pool

A connection to PostgreSQL starts with a connection string, and the URI format is the canonical one:

postgresql://web_prod:[email protected]:5432/greenstore?sslmode=verify-full&application_name=gs-web&connect_timeout=5

Four things to always look at: the userweb_prod, not the superuser or the owner (11-03)—; the sslmode, which must be at least require and preferably verify-full, because prefer silently accepts an unencrypted connection; the application_name, which will show up in pg_stat_activity and tell you which service is firing the query that's choking the database; and the fact that the password is never in the repository, but in an environment variable or a secrets manager.

Why you need a pool

Opening a connection to PostgreSQL is expensive. It isn't just a socket: the server creates an operating-system process per connection, negotiates TLS, authenticates and initializes its memory. That's on the order of tens of milliseconds, against the 0.2 ms the query you were about to run takes. If you open and close a connection per request, 99 % of the time goes on the connection.

And it isn't fixed by opening lots and leaving them open: every idle connection consumes memory and max_connections (100 by default) is a limit that, once exceeded, makes requests fail. The solution is a pool: a small set of already-open connections that requests borrow and give back.

flowchart LR
    P1["request 1"] --> POOL
    P2["request 2"] --> POOL
    P3["request 3"] --> POOL
    PN["request N"] --> POOL
    POOL["<b>pool</b><br/>10 open connections<br/>borrow and return"] --> DB[("PostgreSQL<br/>10 processes")]

The size surprises everybody: it's small. A widely used starting rule is cores × 2 + disk spindles, which for an ordinary machine gives between 10 and 20 connections, not two hundred. The reason is that the database doesn't go faster by receiving more requests at once: if it has 8 cores, 200 simultaneous queries don't run in parallel, they fight. A small pool queues in the application, which is where you can wait in an orderly fashion, instead of saturating the server.

Pool parameter What it controls Starting value
Maximum size Simultaneous connections to the server 10-20 per application instance
Wait time to get a connection How long a request waits before failing 2-5 s (better to fail fast than to hang)
Maximum connection lifetime Periodic recycling 30 min: avoids leaks and makes controlled failure easier
Maximum idle time When the spare ones are closed 10 min

⚠️ Careful with the multiplication. The limit that matters is the total: 8 application instances with a pool of 20 is 160 connections, plus the background worker's, plus the reporting ones. That number has to fit in max_connections, with room to spare for administrative connections.

PgBouncer

When there are many instances, or when the platform creates a process per request (PHP's classic model), you put an external pooler in front: PgBouncer sits between the application and PostgreSQL and multiplexes hundreds of client connections over a few real ones.

Mode When the connection goes back to the pool Use
session When the client disconnects Compatible with everything; multiplexes very little
transaction At the end of each transaction The usual one: heavy multiplexing
statement At the end of each statement Very aggressive; forbids multi-statement transactions

transaction mode has small print and you need to know it: since the connection changes between transactions, everything that lives in the session stops working — named prepared statements, session SETs (use SET LOCAL), temporary tables, LISTEN/NOTIFY and 09-05's session advisory locks. If your ORM uses named prepared statements, you have to disable them or use a PgBouncer version that supports them.

  1. Running SQL from code

Three pieces, using what you learned in 11-03 and in module 9:

# 1. Parameterized query: the latest-orders listing, in ONE query
SQL_LATEST = """
SELECT o.id, o.order_date, c.name || ' ' || c.last_name AS customer, o.status,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total,
       COUNT(ol.id) AS lines
FROM   orders        AS o
JOIN   customers     AS c  ON c.id = o.customer_id
LEFT   JOIN order_lines AS ol ON ol.order_id = o.id
GROUP  BY o.id, o.order_date, c.name, c.last_name, o.status
ORDER  BY o.order_date DESC, o.id DESC
LIMIT  %(limit)s"""

with pool.connection() as conn, conn.cursor() as cur:
    cur.execute(SQL_LATEST, {"limit": 5})
    rows = cur.fetchall()
id order_date customer status total lines
20 2026-02-21 Camille Dubois pending 22.60 2
19 2026-02-09 Pau Llorens Vidal paid 26.73 1
18 2026-01-27 Ana Belmonte Roca paid 28.10 2
17 2026-01-13 Sofia Moreira Costa shipped 47.00 2
16 2025-12-19 Javier Ortega Ruiz shipped 31.18 2

One query, five rows, everything the screen needs: the customer's name, the status, the total and the number of lines. Keep this query in mind, because section 4 is going to compare it with the version that fires one more query for every order in the list.

RETURNING: getting the id without a second query

When you insert, you need the generated id. The right way is RETURNING (05-02), not a subsequent SELECT MAX(id) —which is also wrong under concurrency:

cur.execute("""INSERT INTO orders (customer_id, employee_id, order_date, status, payment_method, shipping_cost)
               VALUES (%s, %s, CURRENT_DATE, 'pending', %s, %s)
               RETURNING id, order_date""",
            (customer_id, None, payment_method, shipping))
order_id, date = cur.fetchone()

The transaction from the application

09-03's pattern, written in code. Everything that has to happen together goes inside the same block:

conn = pool.getconn()
try:
    with conn:                                  # opens a transaction; commits on clean exit
        with conn.cursor() as cur:
            cur.execute(SQL_INSERT_ORDER, (...))
            order_id = cur.fetchone()[0]
            cur.executemany(SQL_INSERT_LINE, lines)        # several rows, one round trip
            cur.execute(SQL_DEDUCT_STOCK, (...))
except UniqueViolation:                          # expected errors: a message to the user
    raise BusinessError("That order already exists")
except Exception:
    conn.rollback(); raise                       # unexpected: roll back and propagate
finally:
    pool.putconn(conn)                           # ⬅️ ALWAYS return the connection to the pool

Four rules you learn through incidents: rollback on every error, because an open transaction holds locks and blocks VACUUM (09-02); return the connection in the finally, or the pool runs dry and the whole application freezes; keep the transaction as short as possible (section 4); and tell expected errors from unexpected ones — a UNIQUE violation is a message to the user, not a 500.

  1. ORM versus hand-written SQL

An ORM (Object-Relational Mapper) translates between tables and the language's objects: Order.objects.filter(status="paid") becomes a SELECT.

Gives you Takes away
Automatic mapping row ↔ object, with no boilerplate Control over the generated SQL: you don't know what runs until you look
Migrations integrated with the model (05-06) Performance predictability: an innocent change triggers an N+1
Security by default: it always parameterizes (11-03) Analytical queries: windows, CTEs and FILTER are expressed badly or not at all
Productivity on CRUD, which is 80 % of the code One more layer to learn, debug and upgrade
Portability between engines and an identity cache The illusion that you don't need to know SQL
ORM Environment Note
SQLAlchemy Python Two layers: Core (expressive SQL) and ORM. The most powerful for escaping the ORM without leaving it
Django ORM Python Very productive and integrated; select_related / prefetch_related for the N+1
Prisma Node/TypeScript Declarative schema and generated types; $queryRaw for hand-written SQL
Hibernate / JPA Java The veteran; JOIN FETCH against the N+1 and lazy loading by default
Eloquent PHP/Laravel Very readable; with() for eager loading

The course's criterion: an ORM for CRUD, hand-written SQL for reports and critical queries. It isn't a cowardly middle ground: it's that the two are different problems. "Save an order and its lines" is exactly what an ORM does well. "Monthly revenue with running total, moving average and change" is exactly what it does badly.

And here's the concrete example, section 2's query: a JOIN to two tables, a LEFT JOIN that mustn't lose orders, a GROUP BY, an amount expression with a discount and an ORDER BY with a tie-breaker. No ORM writes it well unaided: either it makes three queries, or it fetches whole objects to count their lines in memory, or it generates a GROUP BY with every column of the model. Write it by hand, keep it in a .sql file in the repository (11-02) and run it through the ORM's driver — they all let you. And when you do, keep parameterizing: raw()/text()/$queryRaw is exactly where SQL injection comes back (11-03).

  1. The antipatterns that kill a website

4.1. N+1 — closes 08-04

Symptom The screen takes seconds; the log shows dozens or hundreds of queries per request, all of them lightning fast; it doesn't appear in the slow-query ranking
Cause You query the list, and then one query per item to fetch a related value. Almost always generated by an ORM when you touch a property inside a loop
Fix A JOIN, or the ORM's eager loading
# ⚠️ INCORRECT: 1 + 20 = 21 queries
orders = Order.objects.all()[:20]                # 1 query
for o in orders:
    print(o.customer.name)                       # ⬅️ 1 query per iteration, invisible in the code

# ✅ CORRECT: 1 query
orders = Order.objects.select_related("customer").all()[:20]

21 queries against 1 to render the same table. And what makes the N+1 serious is how it scales, because the cost isn't each query's time but the fixed cost you pay on every one: network round trip, parsing, planning:

Items 1 ms latency 20 ms latency (database in another region)
20 with N+1 ~25 ms ~420 ms
500 with N+1 ~520 ms ~10 s
Any number with a JOIN ~2-4 ms ~25 ms

How to detect it: count the queries per request. A counter in the middleware logging "this request made 61 queries" finds an N+1 in five minutes; pg_stat_statements gives it away as a query with a tiny mean_exec_time and an absurd number of calls, which is why you sort by total time. The tools for each environment: django-debug-toolbar, Rails Bullet, Laravel Telescope, Hibernate statistics.

4.2. Fetching all the rows and paginating in memory

Symptom Memory usage through the roof; the screen is fine in development and dies in production
Cause SELECT * FROM orders and then rows[100:120] in the language. With 20 orders it works; with 2 million, it doesn't
Fix WHERE, ORDER BY and LIMIT in SQL. And for deep pages, keyset instead of OFFSET (02-06, 08-04)
-- ⚠️ Page 1000 with OFFSET: reads and throws away 20,000 rows
SELECT id, order_date FROM orders ORDER BY order_date DESC, id DESC LIMIT 20 OFFSET 20000;

-- ✅ Keyset: it positions itself in the index. Constant cost, whatever the page
SELECT id, order_date FROM orders
WHERE (order_date, id) < (:last_date, :last_id)
ORDER BY order_date DESC, id DESC LIMIT 20;

Over the course's 20 orders, the second one with (:last_date, :last_id) = ('2026-01-13', 17) returns orders 16, 15, 14, 13 and 12 with LIMIT 5: exactly the page after section 2's, without rereading anything.

4.3. An open transaction waiting on something slow

Symptom Locks, waits, idle in transaction in pg_stat_activity, tables that never get cleaned up
Cause BEGIN … a call to the payment gateway (2 s) … COMMIT. The transaction holds locks while waiting on a third party
Fix The external call goes outside the transaction. First the call, then a short transaction recording the result

It's 09-01's point taken into the real world, and the damage goes beyond that request: a long transaction stops VACUUM cleaning up old row versions across the whole database (09-02), so a single slow request degrades every other one. Watch idle in transaction and set idle_in_transaction_session_timeout.

4.4. Missing indexes on what the website filters by

Symptom All fine until the table grows; Seq Scan in the plan (08-05)
Cause The site filters and sorts by unindexed columns, and PostgreSQL doesn't index foreign keys automatically (08-01)
Fix Indexes on the FKs (order_lines(order_id), orders(customer_id)) and on what the screen sorts by (orders(order_date DESC, id DESC))

The systematic way to find them: list the screens, and for each one write down the WHERE and the ORDER BY it runs. That list is your list of candidate indexes — and only that, because every surplus index slows down all writes (08-02).

  1. Useful patterns

  • Cursor pagination for infinite scroll and APIs. The response carries an opaque next_cursor (the last row's tuple, encoded) and the client sends it back. Constant cost and no repeated or lost rows when somebody inserts while the user is browsing.
  • Optional filters with 11-01's pattern, and a defensive LIMIT on every user-facing query: even if the interface only lets you ask for 50, the API must impose a maximum (LEAST(:limit, 100)). Without it, somebody will ask for ?limit=1000000 on your busiest day.
  • Caching for what changes little and is read a lot: the catalogue, the categories, the home page. And with the usual warning: invalidation is the hard problem. Start with time-based expiry, which is simple and predictable, and leave event-based invalidation for when you really need it.
  • Job queues with SELECT ... FOR UPDATE SKIP LOCKED (09-05). Several worker processes take tasks from the same table without treading on each other or waiting: each skips the rows already locked by another. It's the canonical pattern for sending emails, generating invoices or processing images without setting up a separate queueing system.
UPDATE jobs SET status = 'in_progress', taken_at = now()
WHERE  id = (SELECT id FROM jobs WHERE status = 'pending'
             ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, payload;
  • JSON straight from PostgreSQL (10-06): jsonb_build_object + jsonb_agg return the order with its lines nested in one row and one query, instead of fetching flat rows and reassembling them in the language. When it pays off: deeply nested responses the application only passes straight through. When it doesn't: if the application has to walk, validate or transform the object —then prefer typed rows—, if the JSON gets huge, or if mixing it with the ORM forces you to maintain two ways of reading the same thing.

  1. Operations: deployment, monitoring and timeouts

Backwards-compatible migrations

During a deployment, the new code and the old code coexist, if only for a few seconds. If the migration and the code are deployed together and the migration breaks the previous schema, the old version fails for as long as the change lasts. Hence 05-06's expand/contract, in three deployments:

Phase What you do Compatible with
1. Expand Add the new column nullable or with a DEFAULT; write to both Old and new code
2. Migrate and deploy Backfill the data in batches; the new code reads the new column New code
3. Contract When nobody uses the old one: NOT NULL, drop the old column

And two operational details that prevent an outage: CREATE INDEX CONCURRENTLY, because a normal CREATE INDEX blocks the table's writes while it's built; and a short lock_timeout before an ALTER TABLE, so the migration fails in three seconds instead of queueing every query behind a lock it can't get (09-05).

Monitoring and timeouts

SELECT LEFT(query, 60) AS query_text, calls, ROUND(total_exec_time::numeric, 1) AS ms_total,
       ROUND(mean_exec_time::numeric, 2) AS ms_avg
FROM   pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

Sort by total_exec_time, not by the average: it's the only thing that makes the N+1 visible, since its average is tiny and its total is enormous. And three settings that should be in place from day one:

Setting What for Starting value
statement_timeout No web query should run for minutes 5-15 s on the application's role
idle_in_transaction_session_timeout Kill forgotten open transactions 30-60 s
log_min_duration_statement Log anything over a threshold 200-1000 ms

Set them per role: ALTER ROLE web_prod SET statement_timeout = '10s'; lets reports and migrations, on other roles, have their own limits.

  1. "The site is slow": a checklist

In this order, because it goes from the most likely and cheapest to the rarest and most expensive:

  1. Is it everything or one screen? If it's one, look at its queries; if it's everything, suspect the server, the pool or a lock.
  2. How many queries does that request make? A counter. If it's dozens, it's an N+1 and you're done.
  3. Is the pool running dry? If requests are waiting to get a connection, the problem isn't in the database: it's the pool, or transactions that never close. Check idle in transaction.
  4. pg_stat_statements by total time. The top three queries usually explain 80 % of the load.
  5. EXPLAIN ANALYZE the suspect (08-05). A Seq Scan where there should be an index? Estimates far from reality?
  6. Are there locks? pg_locks with pg_stat_activity: a query in Lock isn't slow, it's waiting (09-05).
  7. Have the statistics or the volume changed? Was there a bulk load without an ANALYZE (08-04)?
  8. Are too many rows being fetched? A fast query returning 200,000 rows saturates the network and the application's memory.
  9. And only then, the server: CPU, memory, disk, pending VACUUM, number of connections.

The rule is 08-04's applied to the web: measure before you touch. And count queries, not just milliseconds, because the most common problem of all appears in no slow-query ranking.

Common Mistakes and Tips

  • Opening a connection per request. The connection cost dwarfs the query's. Use a pool.
  • Configuring the pool with 200 connections "just in case". The database doesn't go faster for receiving more at once; a small pool queues where waiting is possible. And remember to multiply by the number of instances.
  • Not returning the connection to the pool. A return in the middle of a try with no finally drains the pool and freezes the whole application.
  • Leaving a transaction open during an external call. Locks, idle in transaction and a VACUUM that can't do its job.
  • Trusting the ORM to do the right thing. Look at the SQL it generates: nearly all of them have a logging mode. What you don't look at, you don't know. And using raw() with concatenation: that's where injection comes back (11-03).
  • Paginating with OFFSET in an API. Slow on deep pages and with repeated or lost rows if somebody inserts.
  • Not putting a LIMIT on a user-facing query. Somebody will ask for a million rows.
  • Deploying a migration and code together with a breaking change. Expand/contract, and CREATE INDEX CONCURRENTLY.
  • Tip: log the number of queries and the database time of every request. With those two metrics, this section's problems show up before a user sees them.
  • Tip: set application_name in the connection string. When the database is suffering, you'll know which service is hammering it instead of guessing.
  • Tip: keep complex queries in .sql files in the repository, not embedded between lines of code. They review better, they can be tested in psql and they can be formatted (11-02).

Exercises

Exercise 1

The customer page shows their details, their orders and, for each order, its lines with the product's name and its category. The log says 23 queries per load on Lucía's page.

  1. Where exactly do the 23 come from? (Hint: Lucía has 3 orders, of 3 lines each.)
  2. Reduce the page to two queries and write them.
  3. Why two and not one?

Exercise 2

Your application runs on 6 instances, each with a pool of 25 connections. There's also a background worker process with 10 and a reporting server with 5. max_connections is set to 100.

  1. What's the problem and what error will users see?
  2. Give three solutions, from the cheapest to the most expensive.
  3. If you choose PgBouncer in transaction mode, what do you have to review in the code?

Exercise 3

An API returns an order's detail with its customer and its lines. The team is debating between (a) three queries and reassembling in code, (b) one query with a JOIN and reassembling, (c) one query returning the JSON already built (10-06).

  1. Write option (c) for order 1 and say what it returns.
  2. Give one argument for and one against each option.
  3. Which would you choose if the API has to return 50 orders in the same response?

Solutions

Solution 1

1. 1 for the customer + 1 for their list of orders + 3 (one per order, to fetch its lines) + 9 (one per line, for the product's name) + 9 (one per line, for the product's category) = 23. It's a nested N+1: each level of the template multiplies the previous one, which is why the number grows so fast. With a customer with 20 orders of 5 lines it would be 1 + 1 + 20 + 100 + 100 = 222 queries for a single screen.

2. Two queries: one for the orders' headers and another for all their lines at once, with the product already joined.

-- (a) The customer and their orders with totals, in one query
SELECT o.id, o.order_date, o.status, COUNT(ol.id) AS lines,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM   orders AS o LEFT JOIN order_lines AS ol ON ol.order_id = o.id
WHERE  o.customer_id = %(customer_id)s
GROUP  BY o.id, o.order_date, o.status ORDER BY o.order_date DESC;

-- (b) ALL the lines of ALL those orders, in one go
SELECT ol.order_id, p.name AS product, ol.quantity,
       ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2) AS amount
FROM   order_lines AS ol JOIN products AS p ON p.id = ol.product_id
WHERE  ol.order_id = ANY(%(ids)s)
ORDER  BY ol.order_id, ol.id;

For Lucía (customer 1), the first returns 3 rows —orders 1, 5 and 15, with €42.10, €32.10 and €33.40, which add up to her canonical €107.60— and the second, 9 rows. Notice = ANY(%(ids)s) with an array: it's the right way to pass a list of ids without building the SQL by concatenation (11-03).

3. Because header and detail have different cardinalities. With a single query, the order's total would repeat on every one of its lines and you'd have to deduplicate it in code, or you'd have to aggregate the lines into JSON. Two queries, each at the granularity of what it returns, are clearer and more efficient than one that multiplies rows. Every ORM's "eager loading" rule is exactly this: one query per level, not one per item.

Solution 2

1. 6 × 25 + 10 + 5 = 165 possible connections against a max_connections of 100. As soon as there's load, PostgreSQL will reject new ones with FATAL: sorry, too many clients already, and users will see intermittent 500 errors — intermittent and therefore hard to diagnose, because they only appear at the peaks. Worse still, it can even stop you connecting to administer the server if there are no reserved slots left (superuser_reserved_connections).

2. (a) Lower the pool size to 10 per instance: 6 × 10 + 10 + 5 = 75, with room to spare. It's free, immediate and probably won't make performance worse, because 165 simultaneous queries don't fit on the machine's cores anyway. (b) Put PgBouncer in transaction mode: hundreds of client connections over 20 real ones. (c) Raise max_connections and the server's memory: it's the most expensive, it needs a restart and it only moves the problem, because every connection is a process with its own memory.

3. With transaction the connection changes between transactions, so you have to review: named prepared statements (many drivers use them by default: disable them or use the PgBouncer version that supports them), session SETs (switch to SET LOCAL inside the transaction — including 11-01's audit SET app.username and 11-03's RLS one), temporary tables, LISTEN/NOTIFY and session advisory locks (09-05). Anything that relies on "still being in the same session" stops working.

Solution 3

1. It's 10-06's query: jsonb_build_object with the id, the date and the shipping, the customer nested in another object and the lines in a correlated jsonb_agg. It returns one row and one column with order 1 complete: Lucía Martínez Soler, Spain, and her three lines —olive oil €23.90, rice €11.70 and chamomile tea €6.50— adding up to the order's €42.10.

2. (a) Three queries: for, it's the simplest and each query is trivial to cache and index; against, that's three network round trips. (b) JOIN and reassemble: for, a single round trip and typed data the application can transform; against, it repeats the header on every line and forces you to group in code, which is manual, error-prone work. (c) JSON from PostgreSQL: for, one round trip and zero reassembly code; against, the application receives an opaque blob it can't transform without parsing it, the typing is lost and the API's response shape gets coupled to the SQL — changing a field of the contract means touching the query.

3. With 50 orders, (c), for one specific reason: it's the only one that doesn't grow in round trips and doesn't force you to group 50 headers with their ~120 lines in code. Option (a) would turn into an N+1 if done per order —you'd have to rewrite it as two queries with = ANY(...), which is exercise 1's solution— and (b) would return 120 rows with the header repeated. The condition for (c) being a good idea is still the same: that the API forwards the JSON as is. If it has to touch it, (a) in its two-query version comes back.

Module conclusion

This is how SQL reaches a real application:

  • The connection carries a least-privilege user, TLS (verify-full, not prefer) and an application_name, and the password is never in the repository. And it always goes through a pool, because opening a connection costs tens of milliseconds and each one is a process on the server. The size is small —10-20 per instance— and you have to multiply it by the number of instances. PgBouncer in transaction mode multiplexes hundreds of clients over a few real connections, at the price of losing everything that lives in the session.
  • From code: parameterized queries (11-03), RETURNING for the id (05-02) and the try / commit / except rollback / finally putconn block (09-03), with the transaction as short as possible and the connection always returned.
  • ORM versus SQL: the ORM gives mapping, migrations, security by default and productivity; it takes away control of the SQL and performance predictability. The criterion: an ORM for CRUD, hand-written SQL for reports and critical queries — like the latest-orders listing, which no ORM writes well.
  • The antipatterns: the N+1, which turns 1 query into 21 or 501 and appears in no slow-query ranking —closing 08-04's promise—; fetching everything and paginating in memory, fixed with LIMIT and keyset; the open transaction waiting on an external API, which holds locks and slows down VACUUM across the whole database; and missing indexes on the columns the site filters by, starting with the foreign keys.
  • The patterns: cursor pagination, optional filters, a defensive LIMIT on every exposed query, caching with time-based expiry, queues with SKIP LOCKED and JSON straight from PostgreSQL when the API only forwards it.
  • Operations: backwards-compatible migrations in expand/contract, CREATE INDEX CONCURRENTLY, a lock_timeout before an ALTER, pg_stat_statements sorted by total time, and statement_timeout and idle_in_transaction_session_timeout per role. Plus the nine-point checklist for when "the site is slow", which starts by counting queries.

And with that module 11 closes and, with it, the learning. In five lessons you've gone from knowing the language to knowing the craft: the use cases that reappear in every project and which tool solves each one; the best practices of naming, formatting, design, reliability and process, with their catalogue of antipatterns and their checklist; security, with SQL injection and its one real defence, PostgreSQL's role and permission model and the handling of personal data; data analysis, where window functions turned into reports and where you learned that defining and validating matter more than querying; and web development, where the SQL actually runs.

One thing is left, and it's the only one you can't learn by reading: doing it yourself. In module 12, Final project, you'll build a complete system from start to finish: the project description and its business context, the requirements it has to meet, the step-by-step implementation —model, DDL, data, queries, indexes, views, security and performance—, the annotated solutions with each decision justified one by one, and the presentation of the results. Everything from twelve modules, together and in a single piece of work. It's time to stop following a course and start building.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved