📚 Learning Objectives

By the end of this lesson, you will be able to: - Use OVER (PARTITION BY … ORDER BY …) to compute rankings, running totals, moving averages and row-to-row differences without collapsing rows - Choose between ROW_NUMBER, RANK, DENSE_RANK and NTILE, and use LAG/LEAD/FIRST_VALUE/LAST_VALUE - Control window frames with ROWS BETWEEN … and avoid the LAST_VALUE default-frame trap - Structure multi-step queries with common table expressions (WITH), and walk hierarchies or generate series with recursive CTEs - Group statements into transactions with START TRANSACTION, COMMIT, ROLLBACK and SAVEPOINT - Explain ACID, the four isolation levels and the anomalies each one prevents, and demonstrate them with two MySQL sessions - Recognise and handle lock waits and deadlocks in InnoDB

🎯 Prerequisites

  • Completed Lessons 1-17 (especially Lesson 10 Aggregation, Lesson 11 Subqueries, Lesson 15 Performance)
  • MySQL 8.0 or later (window functions and CTEs were added in 8.0; they do not exist in 5.7)
  • The course database from setup_database.sql

📖 Table of Contents

  1. Why This Lesson Exists
  2. Window Functions: the OVER Clause
  3. Ranking Functions
  4. Aggregates as Window Functions
  5. Window Frames: ROWS and RANGE
  6. LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTILE
  7. Common Table Expressions
  8. Recursive CTEs
  9. Transactions: START, COMMIT, ROLLBACK, SAVEPOINT
  10. ACID and Isolation Levels
  11. Locks, Lock Waits and Deadlocks
  12. Transactions from Python (SQLAlchemy)
  13. Best Practices
  14. Common Pitfalls
  15. Exercises

1. Why This Lesson Exists

Lesson 10 introduced window functions in one section, Lesson 11 used subqueries for "per-group" questions, and Lesson 6 mentioned transactions in passing. The three topics here are what separate "can query a table" from "can write the reports and the write paths a real application needs":

  • Window functions answer "rank within group", "running total", "compared with the previous month" in one pass, where subqueries need a self-join per question.
  • CTEs make multi-step queries readable and add recursion, which plain SQL otherwise lacks.
  • Transactions make multi-statement writes safe when many users work at once.

The teaching data set

Results below are shown for this subset of the course database so you can verify your output:

students:  1 Alice 3.90 | 2 Bob 3.40 | 3 Carol 3.70 | 4 Dan 2.90 | 5 Eve 3.40
courses:   CS101 (3 cr) | MATH201 (4 cr) | CS201 (3 cr) | CS301 (3 cr)

enrollments (enrollment_date, student, course, grade)
2024-01-10 Alice CS101 92 | 2024-01-12 Bob CS101 85 | 2024-01-15 Eve CS101 85
2024-02-02 Carol CS101 78 | 2024-02-05 Alice MATH201 88 | 2024-02-07 Carol MATH201 95
2024-03-01 Dan MATH201 70 | 2024-03-03 Bob MATH201 88

2. Window Functions: the OVER Clause

A window function computes a value for each row using a set of related rows (its window) without collapsing the rows the way GROUP BY does.

function_name(args) OVER (
    [PARTITION BY expr, ...]        -- split rows into independent groups
    [ORDER BY expr [ASC|DESC], ...] -- order within each partition
    [frame_clause]                  -- which rows around the current row (Section 5)
)

GROUP BY collapses, OVER keeps every row

-- GROUP BY: one row per course
SELECT c.course_code, COUNT(*) AS n, ROUND(AVG(e.grade), 2) AS avg_grade
FROM enrollments e JOIN courses c ON c.course_id = e.course_id
GROUP BY c.course_code;
-- CS101 4 85.00 | MATH201 4 85.25

-- OVER: every enrollment row, with its course's statistics attached
SELECT c.course_code, s.first_name, e.grade,
       ROUND(AVG(e.grade) OVER (PARTITION BY c.course_code), 2)          AS course_avg,
       ROUND(e.grade - AVG(e.grade) OVER (PARTITION BY c.course_code), 2) AS diff_from_avg,
       COUNT(*) OVER (PARTITION BY c.course_code)                         AS class_size
FROM enrollments e
JOIN students s ON s.student_id = e.student_id
JOIN courses  c ON c.course_id  = e.course_id
ORDER BY c.course_code, e.grade DESC, s.first_name;
+-------------+------------+-------+------------+---------------+------------+
| course_code | first_name | grade | course_avg | diff_from_avg | class_size |
+-------------+------------+-------+------------+---------------+------------+
| CS101       | Alice      | 92.00 |      85.00 |          7.00 |          4 |
| CS101       | Bob        | 85.00 |      85.00 |          0.00 |          4 |
| CS101       | Eve        | 85.00 |      85.00 |          0.00 |          4 |
| CS101       | Carol      | 78.00 |      85.00 |         -7.00 |          4 |
| MATH201     | Carol      | 95.00 |      85.25 |          9.75 |          4 |
| MATH201     | Alice      | 88.00 |      85.25 |          2.75 |          4 |
| MATH201     | Bob        | 88.00 |      85.25 |          2.75 |          4 |
| MATH201     | Dan        | 70.00 |      85.25 |        -15.25 |          4 |
+-------------+------------+-------+------------+---------------+------------+

The same question in Lesson 11 needed a correlated subquery per column. Here the window is computed once per partition.

Where window functions may appear

Only in the SELECT list and ORDER BY. They are evaluated after WHERE, GROUP BY and HAVING, so you cannot filter on them directly — wrap the query in a CTE or derived table (Section 7).


3. Ranking Functions

SELECT c.course_code, s.first_name, e.grade,
       ROW_NUMBER() OVER (PARTITION BY c.course_code ORDER BY e.grade DESC, s.first_name) AS row_num,
       RANK()       OVER (PARTITION BY c.course_code ORDER BY e.grade DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY c.course_code ORDER BY e.grade DESC) AS dense_rnk
FROM enrollments e
JOIN students s ON s.student_id = e.student_id
JOIN courses  c ON c.course_id  = e.course_id
ORDER BY c.course_code, row_num;
+-------------+------------+-------+---------+-----+-----------+
| course_code | first_name | grade | row_num | rnk | dense_rnk |
+-------------+------------+-------+---------+-----+-----------+
| CS101       | Alice      | 92.00 |       1 |   1 |         1 |
| CS101       | Bob        | 85.00 |       2 |   2 |         2 |
| CS101       | Eve        | 85.00 |       3 |   2 |         2 |   <- tie
| CS101       | Carol      | 78.00 |       4 |   4 |         3 |
| MATH201     | Carol      | 95.00 |       1 |   1 |         1 |
| MATH201     | Alice      | 88.00 |       2 |   2 |         2 |
| MATH201     | Bob        | 88.00 |       3 |   2 |         2 |   <- tie
| MATH201     | Dan        | 70.00 |       4 |   4 |         3 |
+-------------+------------+-------+---------+-----+-----------+
Function Ties Gaps after ties Use for
ROW_NUMBER() broken arbitrarily unless ORDER BY is unique — de-duplication, pagination, "exactly one row per group"
RANK() same rank yes (1, 2, 2, 4) competition ranking
DENSE_RANK() same rank no (1, 2, 2, 3) "top 3 distinct scores"

Always add a tie-breaker to ROW_NUMBER's ORDER BY (here s.first_name); otherwise the same query can return different numbering on different runs.

Top-N per group

Window functions cannot be used in WHERE, so rank in a CTE and filter outside:

WITH ranked AS (
    SELECT c.course_code, s.first_name, e.grade,
           DENSE_RANK() OVER (PARTITION BY c.course_code ORDER BY e.grade DESC) AS dense_rnk
    FROM enrollments e
    JOIN students s ON s.student_id = e.student_id
    JOIN courses  c ON c.course_id  = e.course_id
)
SELECT course_code, first_name, grade
FROM ranked
WHERE dense_rnk <= 2
ORDER BY course_code, grade DESC, first_name;
-- CS101: Alice 92, Bob 85, Eve 85   MATH201: Carol 95, Alice 88, Bob 88

De-duplication: keep the latest enrollment per student

WITH numbered AS (
    SELECT e.*,
           ROW_NUMBER() OVER (PARTITION BY student_id ORDER BY enrollment_date DESC, enrollment_id DESC) AS rn
    FROM enrollments e
)
SELECT student_id, course_id, enrollment_date
FROM numbered
WHERE rn = 1
ORDER BY student_id;
-- 1 → MATH201 2024-02-05 | 2 → MATH201 2024-03-03 | 3 → MATH201 2024-02-07 | 4 → MATH201 2024-03-01 | 5 → CS101 2024-01-15

The same pattern with DELETE removes duplicate rows while keeping one.


4. Aggregates as Window Functions

Any aggregate (SUM, AVG, COUNT, MIN, MAX) becomes a window function with OVER. Adding ORDER BY inside OVER turns it into a running aggregate.

Running total of credits per student

SELECT s.first_name, c.course_code, e.enrollment_date, c.credits,
       SUM(c.credits) OVER (PARTITION BY s.student_id
                            ORDER BY e.enrollment_date
                            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_credits
FROM enrollments e
JOIN students s ON s.student_id = e.student_id
JOIN courses  c ON c.course_id  = e.course_id
ORDER BY s.first_name, e.enrollment_date;
+------------+-------------+-----------------+---------+-----------------+
| first_name | course_code | enrollment_date | credits | running_credits |
+------------+-------------+-----------------+---------+-----------------+
| Alice      | CS101       | 2024-01-10      |       3 |               3 |
| Alice      | MATH201     | 2024-02-05      |       4 |               7 |
| Bob        | CS101       | 2024-01-12      |       3 |               3 |
| Bob        | MATH201     | 2024-03-03      |       4 |               7 |
| Carol      | CS101       | 2024-02-02      |       3 |               3 |
| Carol      | MATH201     | 2024-02-07      |       4 |               7 |
| Dan        | MATH201     | 2024-03-01      |       4 |               4 |
| Eve        | CS101       | 2024-01-15      |       3 |               3 |
+------------+-------------+-----------------+---------+-----------------+

Share of total

SELECT d.dept_name, d.budget,
       ROUND(100 * d.budget / SUM(d.budget) OVER (), 1) AS pct_of_total
FROM departments d;

An empty OVER () makes the window the whole result set.


5. Window Frames: ROWS and RANGE

When OVER has an ORDER BY, each row's window is a frame of rows around it:

ROWS BETWEEN <start> AND <end>
  UNBOUNDED PRECEDING | n PRECEDING | CURRENT ROW | n FOLLOWING | UNBOUNDED FOLLOWING
Frame Meaning
(default, with ORDER BY) RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — from the start through all peers of the current row
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW running total, physical rows
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 3-row moving window
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING centred 3-row window
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING whole partition (needed for LAST_VALUE)

ROWS counts physical rows; RANGE groups rows with equal ORDER BY values (peers) together. With duplicate dates, a RANGE running total jumps by the whole group at once while ROWS increases row by row. Say ROWS explicitly when you mean rows.

Moving average

WITH monthly AS (
    SELECT DATE_FORMAT(enrollment_date, '%Y-%m') AS month, COUNT(*) AS enrollments
    FROM enrollments
    GROUP BY month
)
SELECT month, enrollments,
       ROUND(AVG(enrollments) OVER (ORDER BY month
                                    ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 2) AS moving_avg_2
FROM monthly
ORDER BY month;
-- 2024-01 3 3.00 | 2024-02 3 3.00 | 2024-03 2 2.50

The first row has only itself in its frame, so its "2-month average" is just its own value — decide whether that is acceptable or whether you should drop the first n-1 rows.


6. LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTILE

Month-over-month change with LAG

WITH monthly AS (
    SELECT DATE_FORMAT(enrollment_date, '%Y-%m') AS month, COUNT(*) AS enrollments
    FROM enrollments
    GROUP BY month
)
SELECT month, enrollments,
       LAG(enrollments)  OVER (ORDER BY month) AS prev_month,
       enrollments - LAG(enrollments) OVER (ORDER BY month) AS `change`,
       ROUND(100.0 * (enrollments - LAG(enrollments) OVER (ORDER BY month))
                   / LAG(enrollments) OVER (ORDER BY month), 1) AS pct_change
FROM monthly
ORDER BY month;
+---------+-------------+------------+--------+------------+
| month   | enrollments | prev_month | change | pct_change |
+---------+-------------+------------+--------+------------+
| 2024-01 |           3 |       NULL |   NULL |       NULL |
| 2024-02 |           3 |          3 |      0 |        0.0 |
| 2024-03 |           2 |          3 |     -1 |      -33.3 |
+---------+-------------+------------+--------+------------+

LAG(col, n, default) looks n rows back (default 1) and returns default (default NULL) when there is no such row; LEAD looks forward. Note that months with no enrollments are missing, so LAG compares with the previous existing month — Section 8 fixes this with a generated month series.

FIRST_VALUE / LAST_VALUE and the default-frame trap

SELECT c.course_code, s.first_name, e.grade,
       FIRST_VALUE(s.first_name) OVER w AS top_student,
       LAST_VALUE(s.first_name)  OVER w AS wrong_bottom,
       LAST_VALUE(s.first_name)  OVER (PARTITION BY c.course_code
                                       ORDER BY e.grade DESC, s.first_name
                                       ROWS BETWEEN UNBOUNDED PRECEDING
                                                AND UNBOUNDED FOLLOWING) AS bottom_student
FROM enrollments e
JOIN students s ON s.student_id = e.student_id
JOIN courses  c ON c.course_id  = e.course_id
WINDOW w AS (PARTITION BY c.course_code ORDER BY e.grade DESC, s.first_name)
ORDER BY c.course_code, e.grade DESC, s.first_name;
+-------------+------------+-------+-------------+--------------+----------------+
| course_code | first_name | grade | top_student | wrong_bottom | bottom_student |
+-------------+------------+-------+-------------+--------------+----------------+
| CS101       | Alice      | 92.00 | Alice       | Alice        | Carol          |
| CS101       | Bob        | 85.00 | Alice       | Bob          | Carol          |
| CS101       | Eve        | 85.00 | Alice       | Eve          | Carol          |
| CS101       | Carol      | 78.00 | Alice       | Carol        | Carol          |
| MATH201     | Carol      | 95.00 | Carol       | Carol        | Dan            |
| ...                                                                             |
+-------------+------------+-------+-------------+--------------+----------------+

With the default frame (start … current row) LAST_VALUE is always the current row. Give it an explicit frame that extends to UNBOUNDED FOLLOWING. The WINDOW w AS (...) clause names a window so it can be reused by several functions.

NTILE: buckets

SELECT first_name, gpa, NTILE(2) OVER (ORDER BY gpa DESC) AS half
FROM students
ORDER BY gpa DESC, first_name;
-- Alice 1 | Carol 1 | Bob 1 | Eve 2 | Dan 2   (5 rows → buckets of 3 and 2)

NTILE(4) gives quartiles, NTILE(100) percentiles; earlier buckets get the extra rows when the count does not divide evenly.


7. Common Table Expressions

A CTE is a named result set that lives for one statement:

WITH name1 [(col, ...)] AS (SELECT ...),
     name2 AS (SELECT ... FROM name1 ...)   -- may reference earlier CTEs
SELECT ... FROM name2 ...;

Compared with a derived table (Lesson 11):

Derived table CTE
Readability inside-out top-down, each step named
Reuse in the same statement repeat the subquery reference by name as often as needed
Recursion no WITH RECURSIVE
Allowed in SELECT SELECT, UPDATE, DELETE, INSERT ... SELECT

Chaining steps

WITH course_stats AS (
    SELECT course_id, AVG(grade) AS avg_grade, COUNT(*) AS n
    FROM enrollments
    WHERE status = 'completed'
    GROUP BY course_id
),
above_avg AS (
    SELECT e.student_id, e.course_id
    FROM enrollments e
    JOIN course_stats cs ON cs.course_id = e.course_id
    WHERE e.grade > cs.avg_grade
)
SELECT s.first_name, COUNT(*) AS courses_above_average
FROM above_avg a
JOIN students s ON s.student_id = a.student_id
GROUP BY s.first_name
ORDER BY courses_above_average DESC, s.first_name;
-- Alice 2 | Bob 1 | Carol 1

MySQL may materialize a CTE (compute it once into a temporary table) or merge it into the outer query, exactly as it does for derived tables; EXPLAIN shows which. A CTE is not an index — if the same heavy CTE is used in many statements, consider a view (Lesson 12) or a summary table.


8. Recursive CTEs

A recursive CTE references itself. It has an anchor part (the starting rows) and a recursive part that is applied repeatedly until it produces no new rows:

WITH RECURSIVE cte AS (
    SELECT ... -- anchor
    UNION ALL
    SELECT ... FROM cte JOIN ... WHERE <termination condition>   -- recursive member
)
SELECT * FROM cte;

Generating a series

WITH RECURSIVE seq (n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM seq WHERE n < 5
)
SELECT n FROM seq;
-- 1 2 3 4 5

Filling in missing months

WITH RECURSIVE months AS (
    SELECT DATE('2024-01-01') AS month_start
    UNION ALL
    SELECT DATE_ADD(month_start, INTERVAL 1 MONTH) FROM months
    WHERE month_start < '2024-04-01'
),
monthly AS (
    SELECT DATE_FORMAT(enrollment_date, '%Y-%m-01') AS month_start, COUNT(*) AS n
    FROM enrollments
    GROUP BY month_start
)
SELECT DATE_FORMAT(m.month_start, '%Y-%m') AS month, COALESCE(mo.n, 0) AS enrollments
FROM months m
LEFT JOIN monthly mo ON mo.month_start = m.month_start
ORDER BY m.month_start;
-- 2024-01 3 | 2024-02 3 | 2024-03 2 | 2024-04 0   <- April now appears with 0

Feed this result into the LAG query of Section 6 and the month-over-month change becomes correct even across empty months.

Walking a hierarchy: course prerequisites

The course database has no hierarchy table, so create a small one for this example:

CREATE TABLE course_prerequisites (
    course_code VARCHAR(10) NOT NULL,
    prereq_code VARCHAR(10) NOT NULL,
    PRIMARY KEY (course_code, prereq_code)
);
INSERT INTO course_prerequisites VALUES
    ('CS201', 'CS101'), ('CS301', 'CS201'), ('CS301', 'MATH201');

WITH RECURSIVE chain AS (
    SELECT course_code, prereq_code, 1 AS depth,
           CAST(prereq_code AS CHAR(200)) AS path
    FROM course_prerequisites
    WHERE course_code = 'CS301'                    -- anchor: direct prerequisites
    UNION ALL
    SELECT p.course_code, p.prereq_code, chain.depth + 1,
           CONCAT(chain.path, ' -> ', p.prereq_code)
    FROM course_prerequisites p
    JOIN chain ON p.course_code = chain.prereq_code -- prerequisites of prerequisites
)
SELECT prereq_code, depth, path
FROM chain
ORDER BY depth, prereq_code;
+-------------+-------+-----------------+
| prereq_code | depth | path            |
+-------------+-------+-----------------+
| CS201       |     1 | CS201           |
| MATH201     |     1 | MATH201         |
| CS101       |     2 | CS201 -> CS101  |
+-------------+-------+-----------------+

The CAST(... AS CHAR(200)) in the anchor matters: MySQL fixes the column type from the anchor, and without it the growing path would be truncated to the anchor's width.

Safety

  • A cycle (A requires B, B requires A) recurses forever. MySQL stops at cte_max_recursion_depth (default 1000) with error 3636; raise it only deliberately: SET SESSION cte_max_recursion_depth = 10000;.
  • To survive cycles in data, carry the path and add WHERE FIND_IN_SET(p.prereq_code, chain.path_csv) = 0 to the recursive member.
  • Use UNION DISTINCT instead of UNION ALL to stop when a row repeats (slower, but terminates on cycles).

9. Transactions: START, COMMIT, ROLLBACK, SAVEPOINT

A transaction groups statements so that either all of them take effect or none of them do.

START TRANSACTION;                                   -- or BEGIN

UPDATE departments SET budget = budget - 50000 WHERE dept_name = 'Computer Science';
UPDATE departments SET budget = budget + 50000 WHERE dept_name = 'Mathematics';

-- Check the invariant before committing
SELECT dept_name, budget FROM departments WHERE dept_name IN ('Computer Science', 'Mathematics');

COMMIT;                                              -- make both changes permanent
-- or ROLLBACK;                                      -- undo both

If the connection drops between the two UPDATEs, InnoDB rolls the first one back automatically — money never disappears.

Autocommit

By default MySQL runs with autocommit = 1: every single statement is its own transaction. START TRANSACTION suspends autocommit until COMMIT/ROLLBACK. You can also turn it off for a session:

SET autocommit = 0;   -- now nothing is permanent until you COMMIT

SAVEPOINT: partial rollback

START TRANSACTION;
INSERT INTO students (first_name, last_name, email) VALUES ('Grace', 'Hopper', 'grace@example.com');
SAVEPOINT after_student;

INSERT INTO enrollments (student_id, course_id) VALUES (LAST_INSERT_ID(), 999);  -- fails: no course 999
ROLLBACK TO SAVEPOINT after_student;   -- undo only the enrollment; Grace stays

COMMIT;                                -- Grace is committed, the bad enrollment never was

Statements that commit implicitly

DDL (CREATE, ALTER, DROP, TRUNCATE), LOCK TABLES, and START TRANSACTION itself implicitly commit the current transaction in MySQL. Never mix schema changes into a transaction you expect to roll back.


10. ACID and Isolation Levels

Property Meaning Who provides it in MySQL
Atomicity all or nothing undo log, ROLLBACK
Consistency constraints hold before and after constraints + your transaction boundaries
Isolation concurrent transactions do not see each other's half-done work MVCC snapshots + locks
Durability once committed, survives a crash redo log (innodb_flush_log_at_trx_commit = 1)

The anomalies

Anomaly What you observe
Dirty read you read a row another transaction has changed but not yet committed (it may roll back)
Non-repeatable read you read the same row twice in one transaction and get different values because someone committed in between
Phantom read you run the same WHERE twice and get new rows because someone inserted and committed in between
Lost update two transactions read the same value, both compute a new one, the second UPDATE overwrites the first

The four isolation levels

Level Dirty Non-repeatable Phantom Notes
READ UNCOMMITTED possible possible possible almost never appropriate
READ COMMITTED no possible possible default in PostgreSQL, Oracle, SQL Server
REPEATABLE READ no no possible per the standard; InnoDB prevents them for plain reads via consistent snapshots and for locking reads via next-key locks MySQL/InnoDB default
SERIALIZABLE no no no plain SELECTs become SELECT ... FOR SHARE; most blocking
SELECT @@transaction_isolation;                               -- REPEATABLE-READ
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;       -- for this connection
SET GLOBAL  TRANSACTION ISOLATION LEVEL READ COMMITTED;       -- for new connections

See it yourself: two sessions

Open two mysql clients (A and B) on the course database.

-- Session A                                   -- Session B
START TRANSACTION;
SELECT gpa FROM students WHERE student_id = 2; -- 3.40
                                               UPDATE students SET gpa = 3.50 WHERE student_id = 2;
                                               -- (autocommit) committed immediately
SELECT gpa FROM students WHERE student_id = 2;
-- REPEATABLE READ: still 3.40  (snapshot taken at A's first read)
-- READ COMMITTED : now 3.50    (each statement sees the latest commit)
COMMIT;
SELECT gpa FROM students WHERE student_id = 2; -- 3.50 in both cases

Under REPEATABLE READ, session A's snapshot is established by its first consistent read, not by START TRANSACTION (use START TRANSACTION WITH CONSISTENT SNAPSHOT to take it immediately).

Preventing lost updates

-- Wrong: read, compute in the application, write
SELECT budget FROM departments WHERE dept_id = 1;      -- 500000
-- (another session also reads 500000 and writes 450000)
UPDATE departments SET budget = 480000 WHERE dept_id = 1;   -- overwrites their change

-- Right 1: compute in the UPDATE itself (atomic)
UPDATE departments SET budget = budget - 20000 WHERE dept_id = 1;

-- Right 2: lock the row while you decide
START TRANSACTION;
SELECT budget FROM departments WHERE dept_id = 1 FOR UPDATE;   -- others wait here
UPDATE departments SET budget = budget - 20000 WHERE dept_id = 1;
COMMIT;

-- Right 3: optimistic locking with a version column
UPDATE departments SET budget = 480000, version = version + 1
WHERE dept_id = 1 AND version = 7;   -- 0 rows affected → someone else changed it, retry

11. Locks, Lock Waits and Deadlocks

InnoDB uses row locks. Plain SELECTs take no locks (they read a snapshot); UPDATE, DELETE, SELECT ... FOR UPDATE and SELECT ... FOR SHARE lock the rows they touch — and, under REPEATABLE READ, the gaps between index records (next-key locks) to stop phantoms.

SELECT ... FOR UPDATE;              -- exclusive lock, others' writes wait
SELECT ... FOR SHARE;               -- shared lock, others may read-lock but not write
SELECT ... FOR UPDATE NOWAIT;       -- fail immediately (error 3572) instead of waiting
SELECT ... FOR UPDATE SKIP LOCKED;  -- skip rows someone else holds (job queues)

Lock wait timeout

If a row you need is locked, your statement waits up to innodb_lock_wait_timeout seconds (default 50) and then fails with error 1205 Lock wait timeout exceeded. The usual cause is a transaction left open in another session — a forgotten COMMIT in a client, or an application that opened a transaction and then waited on the user.

Deadlocks

-- Session A                                   -- Session B
START TRANSACTION;                             START TRANSACTION;
UPDATE students SET gpa = 3.5 WHERE student_id = 1;
                                               UPDATE students SET gpa = 3.6 WHERE student_id = 2;
UPDATE students SET gpa = 3.5 WHERE student_id = 2;   -- waits for B
                                               UPDATE students SET gpa = 3.6 WHERE student_id = 1;   -- waits for A
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

InnoDB detects the cycle immediately and rolls back one of the transactions (the one that changed fewer rows). Your application must be prepared to retry a transaction that fails with 1213. SHOW ENGINE INNODB STATUS\G prints the "LATEST DETECTED DEADLOCK" section with both statements.

Avoiding deadlocks:

  1. Touch rows in a consistent order in every transaction (e.g. ascending primary key).
  2. Keep transactions short; never wait for user input inside one.
  3. Lock everything you need up front with one SELECT ... FOR UPDATE ... ORDER BY id.
  4. Index the columns in your WHERE clauses — an UPDATE without a usable index locks every row it scans, not just the ones it changes.

12. Transactions from Python (SQLAlchemy)

Lesson 16 introduced sessions; here is the minimal correct pattern for the two-UPDATE transfer, with a retry on deadlock:

from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
import time

engine = create_engine("mysql+pymysql://user:pass@localhost/university")

def transfer_budget(from_dept, to_dept, amount, retries=3):
    for attempt in range(retries):
        try:
            with engine.begin() as conn:            # BEGIN … COMMIT, ROLLBACK on exception
                conn.execute(text("""
                    UPDATE departments SET budget = budget - :amt
                    WHERE dept_name = :d AND budget >= :amt"""),
                    {"amt": amount, "d": from_dept})
                conn.execute(text("""
                    UPDATE departments SET budget = budget + :amt
                    WHERE dept_name = :d"""),
                    {"amt": amount, "d": to_dept})
            return True
        except OperationalError as exc:
            if exc.orig.args[0] in (1213, 1205) and attempt < retries - 1:   # deadlock / lock wait
                time.sleep(0.1 * (attempt + 1))
                continue
            raise

engine.begin() commits when the block exits normally and rolls back on any exception. The budget >= :amt guard moves the business rule into the atomic UPDATE, so no separate SELECT is needed and no lost update is possible. Check result.rowcount if you need to know whether the guard rejected the transfer.


13. Best Practices

  1. Add a tie-breaker to every ROW_NUMBER window so results are deterministic.
  2. State the frame explicitly (ROWS BETWEEN …) for running totals and LAST_VALUE.
  3. Rank in a CTE, filter outside — window functions cannot appear in WHERE or HAVING.
  4. Name repeated windows with the WINDOW clause.
  5. Anchor recursive CTEs with the right column types (CAST), and keep a depth guard.
  6. Keep transactions short and never hold one open across user interaction or network calls.
  7. Write updates as SET col = col ± x or use FOR UPDATE / a version column to prevent lost updates.
  8. Retry on 1213 and 1205 in application code; treat them as normal events, not bugs.
  9. Know your isolation level (SELECT @@transaction_isolation) and that DDL commits implicitly.

14. Common Pitfalls

Pitfall Symptom Fix
WHERE ROW_NUMBER() OVER (...) = 1 syntax/semantic error rank in a CTE, filter outside
LAST_VALUE with default frame always returns the current row ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
RANGE default with duplicate dates running total jumps by whole groups use ROWS
LAG over months with gaps compares with the wrong month generate the series with a recursive CTE, LEFT JOIN
Recursive CTE never ends error 3636 after 1000 levels fix the termination condition; guard against cycles
Truncated path in recursive CTE strings cut off CAST(... AS CHAR(n)) in the anchor
ALTER TABLE inside a transaction earlier changes silently committed run DDL outside transactions
Read-modify-write in the application lost updates under load atomic UPDATE, FOR UPDATE, or version column
Transaction left open in a client other sessions hang, then error 1205 COMMIT/ROLLBACK; check information_schema.INNODB_TRX
UPDATE without an index on the WHERE column whole table locked add the index

15. Exercises

  1. For every enrollment show the student's name, the course, the grade and the student's best grade so far in chronological order (running maximum).
  2. Find, for each course, the student with the second-highest grade. Decide whether RANK or DENSE_RANK fits and explain your choice.
  3. Produce a report of enrollments per month from January to June 2024 that shows every month (0 where there were none) together with the month-over-month change.
  4. Write a recursive CTE that lists all courses that depend on CS101, directly or indirectly, using the course_prerequisites table from Section 8.
  5. Write a transaction that enrols student 5 in course CS201 only if the course currently has fewer than 30 enrollments, in a way that stays correct when two advisers run it at the same time.
  6. Two sessions both run UPDATE enrollments SET grade = grade + 5 WHERE course_id = 2; and UPDATE enrollments SET grade = grade + 5 WHERE course_id = 1; but in opposite orders. What can happen, and how do you prevent it?

Solutions

1.

SELECT s.first_name, c.course_code, e.enrollment_date, e.grade,
       MAX(e.grade) OVER (PARTITION BY e.student_id ORDER BY e.enrollment_date
                          ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS best_so_far
FROM enrollments e
JOIN students s ON s.student_id = e.student_id
JOIN courses  c ON c.course_id  = e.course_id
ORDER BY s.first_name, e.enrollment_date;
-- Alice: 92, 92 | Bob: 85, 88 | Carol: 78, 95 | Dan: 70 | Eve: 85
2. DENSE_RANK() = 2 returns the second-highest distinct grade (CS101: Bob and Eve with 85; MATH201: Alice and Bob with 88). RANK() = 2 gives the same here, but if two students tied for first, RANK would skip 2 entirely and return nobody, so DENSE_RANK is the robust choice for "second-highest score". 3.
WITH RECURSIVE months AS (
    SELECT DATE('2024-01-01') AS m
    UNION ALL
    SELECT DATE_ADD(m, INTERVAL 1 MONTH) FROM months WHERE m < '2024-06-01'
),
monthly AS (
    SELECT DATE_FORMAT(enrollment_date, '%Y-%m-01') AS m, COUNT(*) AS n
    FROM enrollments GROUP BY m
),
filled AS (
    SELECT months.m, COALESCE(monthly.n, 0) AS n
    FROM months LEFT JOIN monthly ON monthly.m = months.m
)
SELECT DATE_FORMAT(m, '%Y-%m') AS month, n,
       n - LAG(n) OVER (ORDER BY m) AS `change`
FROM filled ORDER BY m;
-- 2024-01 3 NULL | 2024-02 3 0 | 2024-03 2 -1 | 2024-04 0 -2 | 2024-05 0 0 | 2024-06 0 0
4. Reverse the join direction: start from rows whose prereq_code = 'CS101' and follow course_code upward.
WITH RECURSIVE dependents AS (
    SELECT course_code, 1 AS depth FROM course_prerequisites WHERE prereq_code = 'CS101'
    UNION ALL
    SELECT p.course_code, d.depth + 1
    FROM course_prerequisites p JOIN dependents d ON p.prereq_code = d.course_code
)
SELECT DISTINCT course_code, depth FROM dependents ORDER BY depth, course_code;
-- CS201 (1), CS301 (2)
5. Lock the course row so concurrent advisers serialize on it, then count and insert inside the same transaction:
START TRANSACTION;
SELECT course_id FROM courses WHERE course_code = 'CS201' FOR UPDATE;   -- serializes on this course
SELECT COUNT(*) INTO @n FROM enrollments e
JOIN courses c ON c.course_id = e.course_id WHERE c.course_code = 'CS201';
-- application (or a stored procedure) checks @n < 30, then:
INSERT INTO enrollments (student_id, course_id)
SELECT 5, course_id FROM courses WHERE course_code = 'CS201';
COMMIT;
Without the FOR UPDATE, two advisers can both count 29 under REPEATABLE READ and both insert, giving 31. A UNIQUE (student_id, course_id) constraint additionally prevents double enrolment of the same student. 6. Each session locks the rows of one course and then waits for the rows of the other: a deadlock. InnoDB detects it, rolls back one session with error 1213, and the other completes. Prevent it by updating in the same order in every code path (e.g. course_id ascending), or by doing both updates in one statement: UPDATE enrollments SET grade = grade + 5 WHERE course_id IN (1, 2);, and always retry on 1213.


🎯 Summary

  • OVER (PARTITION BY … ORDER BY … frame) adds per-group calculations to every row; GROUP BY collapses rows.
  • ROW_NUMBER / RANK / DENSE_RANK differ only in how they treat ties; LAG/LEAD compare neighbouring rows; LAST_VALUE needs an explicit frame.
  • CTEs name intermediate steps; recursive CTEs generate series and walk hierarchies, with cte_max_recursion_depth as a safety net.
  • Transactions give all-or-nothing writes; InnoDB defaults to REPEATABLE READ with snapshot reads and row/next-key locks.
  • Prevent lost updates with atomic UPDATEs, FOR UPDATE or version columns; keep transactions short, order row access consistently, and retry on deadlock.

🔄 Next Steps

  • Revisit Lesson 15 and run EXPLAIN on your window-function and CTE queries
  • Lesson 16-17: apply the transaction patterns through SQLAlchemy sessions and the Unit of Work pattern
  • Build the course project's reporting endpoints with CTEs and window functions instead of application-side loops

📚 Additional Resources