Window functions — Return rankings and running totals in one query with ROW_NUMBER, RANK, and SUM OVER
Window functions assign a rank within a group or a running total to each row without aggregating rows. Unlike GROUP BY, they preserve the original row count while adding aggregate values.
ROW_NUMBER() OVER ( -- Assign consecutive numbers within a group (no duplicates) PARTITION BY grp_col -- PARTITION BY: Group boundary (equivalent to GROUP BY) ORDER BY sort_col DESC -- ORDER BY: Determine the order within the group ) AS rn RANK() OVER (...) -- Equal values receive the same rank (the next rank is skipped: 1,1,3) DENSE_RANK() OVER (...) -- Equal values receive the same rank (no skip: 1,1,2) SUM(col) OVER ( -- Running total (accumulated row by row within the partition) PARTITION BY grp_col ORDER BY sort_col )
From the sales table, calculate the sales ranking within each product category (ties receive the same rank) and the running sales total within each category. Sort by category, rank, and running total.
| sale_id | category | product | amount |
|---|---|---|---|
| 1 | Beverages | Coffee | 5000 |
| 2 | Beverages | Tea | 3000 |
| 3 | Beverages | Juice | 3000 |
| 4 | Food | Bread | 8000 |
| 5 | Food | Cake | 6000 |
| 6 | Food | Cookies | 4000 |
| category | product | amount | rank_in_cat | running_total |
|---|---|---|---|---|
| Beverages | Coffee | 5000 | 1 | 5000 |
| Beverages | Tea | 3000 | 2 | 8000 |
| Beverages | Juice | 3000 | 2 | 11000 |
| Food | Bread | 8000 | 1 | 8000 |
| Food | Cake | 6000 | 2 | 14000 |
| Food | Cookies | 4000 | 3 | 18000 |
SELECT category, product, amount, RANK() OVER ( -- Rank within each category PARTITION BY category -- Partition by category ORDER BY amount DESC -- descending amount ) AS rank_in_cat, -- Equal values share a rank; the next rank is skipped (1,1,3) SUM(amount) OVER ( -- Running total within each category PARTITION BY category ORDER BY amount DESC, sale_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- Range from the first row through the current row ) AS running_total -- running total FROM sales ORDER BY CASE category WHEN 'Beverages' THEN 1 WHEN 'Food' THEN 2 END, rank_in_cat, sale_id; -- Exercise category order; sale_id breaks rank ties /* Logical execution order (window-function query): 1. FROM sales → Read rows 2. PARTITION BY category → partition the windows 3. RANK() OVER (...) → Evaluate the window function (preserve the row count) 4. SUM(amount) OVER (...) → Evaluate the window function (preserve the row count) 5. SELECT → Evaluate columns 6. ORDER BY CASE category WHEN 'Beverages' THEN 1 WHEN 'Food' THEN 2 END, rank_in_cat, sale_id → Output in the specified category order, rank order, and sale_id order for ties */
LEGEND
① FROM
FROM salesRead all six rows from the sales table. The next step partitions the windows by category with PARTITION BY category.| sale_id | category | product | amount |
|---|---|---|---|
| 1 | Beverages | Coffee | 5000 |
| 2 | Beverages | Tea | 3000 |
| 3 | Beverages | Juice | 3000 |
| 4 | Food | Bread | 8000 |
| 5 | Food | Cake | 6000 |
| 6 | Food | Cookies | 4000 |
WHERE RANK() OVER (...) <= 3 causes an error. Window functions can be used only in SELECT and ORDER BY. To filter, calculate the window value in a CTE or subquery and put WHERE in the outer query.Three-table JOIN — Build a detailed API query by joining orders, products, and categories
Most production APIs need to join multiple tables. JOINs can be chained, so it is important to understand both join types and join order.
SELECT a.col, b.col, c.col FROM table_a a -- Starting table INNER JOIN table_b b -- Join only rows that match on both sides ON a.b_id = b.id LEFT JOIN table_c c -- Keep a rows even without a match in c (fill with NULL) ON b.c_id = c.id;
WHERE c.id IS NULL returns only rows with no match.Join the three tables order_items (order line items), products (products), and categories (categories), and attach product and category names to each order item. Include products without a category and show category_name as 'Uncategorized'.
| item_id | order_id | product_id | qty | price |
|---|---|---|---|---|
| 1 | 101 | P01 | 2 | 500 |
| 2 | 101 | P02 | 1 | 1200 |
| 3 | 102 | P03 | 3 | 300 |
| 4 | 102 | P01 | 1 | 500 |
| product_id | name | category_id |
|---|---|---|
| P01 | Coffee | C01 |
| P02 | Sandwich | C02 |
| P03 | New Product A | NULL |
| category_id | name |
|---|---|
| C01 | Beverages |
| C02 | Food |
| item_id | order_id | product_name | category_name | qty | subtotal |
|---|---|---|---|---|---|
| 1 | 101 | Coffee | Beverages | 2 | 1000 |
| 2 | 101 | Sandwich | Food | 1 | 1200 |
| 3 | 102 | New Product A | Uncategorized | 3 | 900 |
| 4 | 102 | Coffee | Beverages | 1 | 500 |
SELECT oi.item_id, oi.order_id, p.name AS product_name, COALESCE(c.name, 'Uncategorized') AS category_name, -- Uncategorized = a product with no LEFT JOIN match oi.qty, oi.price * oi.qty AS subtotal -- Subtotal = unit price × quantity FROM order_items oi INNER JOIN products p -- Products are required, so use INNER JOIN ON oi.product_id = p.product_id LEFT JOIN categories c -- Keep products without categories (LEFT JOIN) ON p.category_id = c.category_id ORDER BY oi.item_id; /* Logical execution order (three-table JOIN query): 1. FROM order_items oi → Read rows 2. INNER JOIN products p ON ... → Join (matching rows only) 3. LEFT JOIN categories c ON ...→ Join (keep every row from the left table) 4. SELECT → Evaluate columns (subtotal) 5. ORDER BY oi.item_id → Sort and return */
LEGEND
① FROM order_items
FROM order_items oiRead all order_items (4 rows). INNER JOIN products on product_id, then LEFT JOIN categories on category_id.| item_id | order_id | product_id | qty | price |
|---|---|---|---|---|
| 1 | 101 | P01 | 2 | 500 |
| 2 | 101 | P02 | 1 | 1200 |
| 3 | 102 | P03 | 3 | 300 |
| 4 | 102 | P01 | 1 | 500 |
oi (order_items), p (products), and c (categories) are common. If column names collide, qualify the column with its alias.WHERE c.name = 'Beverages' for a column from the right table of a LEFT JOIN, the NULL row (New Product A) is removed and the query behaves like an INNER JOIN. Take care not to defeat the purpose of LEFT JOIN.UNION / UNION ALL — Combine multiple queries vertically to build a multi-source report
UNION combines the results of multiple SELECT statements vertically. UNION ALL combines every row, including duplicates, while UNION without ALL removes duplicate rows.
SELECT col1, col2 FROM table_a -- upper SELECT UNION ALL -- Combine all rows vertically, including duplicates (faster than UNION) SELECT col1, col2 FROM table_b; -- Lower SELECT (match its column count and types to the upper SELECT) SELECT col1, col2 FROM table_a UNION -- Combine rows vertically after removing duplicates (DISTINCT runs internally) SELECT col1, col2 FROM table_b;
Use JOIN to place different tables side by side (add columns), and UNION to stack them vertically (add rows).
The system has two notification tables: email_notifications and push_notifications. Combine notifications from both tables into one result, return the list in descending sent_at order, and add a notification type ('email'/'push') to each row.
| id | user_id | subject | sent_at |
|---|---|---|---|
| 1 | U01 | Order confirmation | 2024-05-10 09:00 |
| 2 | U02 | Announcement | 2024-05-12 14:00 |
| 3 | U01 | Shipping notification | 2024-05-15 11:00 |
| id | user_id | message | sent_at |
|---|---|---|---|
| 1 | U01 | Coupon available! | 2024-05-11 10:00 |
| 2 | U03 | New product arrival | 2024-05-14 16:00 |
| notification_type | user_id | content | sent_at |
|---|---|---|---|
| U01 | Shipping notification | 2024-05-15 11:00 | |
| push | U03 | New product arrival | 2024-05-14 16:00 |
| U02 | Announcement | 2024-05-12 14:00 | |
| push | U01 | Coupon available! | 2024-05-11 10:00 |
| U01 | Order confirmation | 2024-05-10 09:00 |
SELECT 'email' AS notification_type, -- Add the type as a fixed value user_id, subject AS content, -- Normalize the column name to content sent_at FROM email_notifications UNION ALL -- Stack vertically without deduplication (fast) SELECT 'push' AS notification_type, -- Add the type as a fixed value user_id, message AS content, -- Match the column name used by email sent_at FROM push_notifications ORDER BY sent_at DESC; -- Sort the entire UNION result newest first /* Logical execution order (UNION ALL query): 1. Upper SELECT from email_notifications → Read rows 2. Lower SELECT from push_notifications → Read rows 3. UNION ALL → combine the sets 4. ORDER BY sent_at DESC → Sort and return */
LEGEND
① Upper SELECT
SELECT 'email' AS notification_type, ... FROM email_notificationsRead three rows from email_notifications and add the fixed string 'email' as notification_type. Normalize subject as content.| notification_type | user_id | content | sent_at |
|---|---|---|---|
| U01 | Order confirmation | 2024-05-10 09:00 | |
| U02 | Announcement | 2024-05-12 14:00 | |
| U01 | Shipping notification | 2024-05-15 11:00 |
'email' AS notification_type lets you identify the source table after combining the data. This pattern is common in batch reports and audit-log generation.CAST(amount AS TEXT) or ::TEXT so they match.SELECT ... FROM a ORDER BY sent_at UNION ALL SELECT ... FROM b can be a syntax error or behave unexpectedly. Write ORDER BY only after the final SELECT.Transactions — Process multiple updates atomically with BEGIN, COMMIT, and ROLLBACK
A transaction treats multiple SQL statements as one unit of work. It starts with BEGIN, commits with COMMIT when everything succeeds, and rolls back all changes with ROLLBACK if an error occurs (atomicity).
BEGIN; -- Start the transaction (START TRANSACTION also works) UPDATE table_a SET col = val; -- operation1 INSERT INTO table_b VALUES (...); -- operation2 -- If an error occurs here ↓ COMMIT; -- Commit all operations (persist them in the database) -- Or -- ROLLBACK; -- Cancel all operations (return to the pre-BEGIN state)
Implement the e-commerce order flow (① INSERT an order row into orders, ② INSERT a line item into order_items, and ③ UPDATE inventory in products) as one atomic transaction. If any one of the three operations fails, make sure all of them are rolled back.
| order_id | user_id | total_amount | status |
|---|
| order_id | product_id | qty | price |
|---|
| product_id | name | stock |
|---|---|---|
| P01 | Coffee | 100 |
Expected output 1:
| order_id | user_id | total_amount | status |
|---|---|---|---|
| 1001 | U01 | 1500 | pending |
Expected output 2:
| order_id | product_id | qty | price |
|---|---|---|---|
| 1001 | P01 | 3 | 500 |
Expected output 3:
| product_id | name | stock |
|---|---|---|
| P01 | Coffee | 97 |
BEGIN; -- Start the transaction (uncommitted until COMMIT) -- ① Add the order header to orders INSERT INTO orders (order_id, user_id, total_amount, status) VALUES (1001, 'U01', 1500, 'pending'); -- ② Add the order line item to order_items INSERT INTO order_items (order_id, product_id, qty, price) VALUES (1001, 'P01', 3, 500); -- ③ Decrease products inventory by the purchased quantity UPDATE products SET stock = stock - 3 -- Decrease stock by 3 (relative update) WHERE product_id = 'P01'; -- WHERE is required (without it, every row is updated) COMMIT; -- All operations succeeded → commit permanently (visible to other sessions) -- Verify the committed state SELECT order_id, user_id, total_amount, status FROM orders ORDER BY order_id; SELECT order_id, product_id, qty, price FROM order_items ORDER BY order_id, product_id; SELECT product_id, name, stock FROM products ORDER BY product_id; /* Logical execution order (success path): 1. BEGIN → Start the transaction 2. INSERT orders → Insert rows (uncommitted) 3. INSERT order_items → Insert rows (uncommitted) 4. UPDATE products → Update the row (uncommitted) 5. COMMIT → Commit the changes permanently 6. Three SELECTs → Verify the committed tables */
LEGEND
① BEGIN
BEGIN; — Start the transactionBEGIN starts the transaction. Every INSERT/UPDATE after this is uncommitted (awaiting COMMIT), and other sessions cannot see these changes.| step | operation | orders | order_items | products.stock |
|---|---|---|---|---|
| START | BEGIN | unchanged | unchanged | 100 |
SAVEPOINT sp1; partway through a transaction, then use ROLLBACK TO SAVEPOINT sp1; to undo only the changes up to that point. It is useful for retrying part of a long transaction.INSERT ON CONFLICT (UPSERT) — Make batch processing safe with idempotent data registration
UPSERT (INSERT + UPDATE) performs “INSERT if absent, UPDATE if present” in one statement. In PostgreSQL, use INSERT ... ON CONFLICT.
INSERT INTO table_name (col1, col2) VALUES ('val1', 'val2') ON CONFLICT (unique_col) -- Specify the column whose unique constraint may be violated DO UPDATE SET -- The UPDATE operation to run on conflict col2 = EXCLUDED.col2; -- EXCLUDED = the new value that was about to be inserted -- When nothing should be done on conflict: ON CONFLICT (unique_col) DO NOTHING; -- Ignore the error and leave the existing row unchanged
Synchronize profile data obtained from an external API into the user_profiles table. INSERT a new row when user_id is absent; when it exists, update only name and updated_at (do not change email).
| user_id (UNIQUE) | name | updated_at | |
|---|---|---|---|
| U01 | Taro Tanaka | tanaka@example.com | 2024-04-01 |
| U02 | Hanako Sato | sato@example.com | 2024-04-15 |
| user_id | name | |
|---|---|---|
| U01 | Taro Tanaka (renamed) | tanaka@example.com |
| U03 | Ichiro Suzuki | suzuki@example.com |
| user_id | name | updated_at | |
|---|---|---|---|
| U01 | Taro Tanaka (renamed) ← updated | tanaka@example.com (unchanged) | 2024-06-01 (updated) |
| U02 | Hanako Sato (unchanged) | sato@example.com | 2024-04-15 (unchanged) |
| U03 | Ichiro Suzuki ← new | suzuki@example.com | 2024-06-01 (new) |
-- Sync U01: existing row → update name and updated_at (email is unchanged) INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ( 'U01', 'Taro Tanaka (renamed)', 'tanaka@example.com', NOW() -- Set the sync timestamp ) ON CONFLICT (user_id) -- What to do when user_id conflicts (an existing row is present) DO UPDATE SET -- Run UPDATE when a conflict occurs name = EXCLUDED.name, -- EXCLUDED: keyword for referencing the new value that was about to be inserted updated_at = EXCLUDED.updated_at; -- Leaving email out of SET preserves its original value -- Sync U03: new row → INSERT (no ON CONFLICT occurs) INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ( 'U03', 'Ichiro Suzuki', 'suzuki@example.com', NOW() ) ON CONFLICT (user_id) -- Same UPSERT syntax, but no conflict → normal INSERT DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at; /* Logical execution order (UPSERT / U01 case): 1. INSERT INTO user_profiles → Check the unique constraint 2. Detect a unique-constraint violation → Conflict with the existing row 3. ON CONFLICT DO UPDATE → Update the existing row Logical execution order (UPSERT / U03 case): 1. INSERT INTO user_profiles → Check the unique constraint 2. No conflict → No constraint is violated 3. normal INSERT → Insert rows */
LEGEND
① INSERT attempt (U01)
INSERT INTO user_profiles VALUES ('U01', ...)We attempt to INSERT U01 (Taro Tanaka, renamed). The database checks the UNIQUE constraint on user_id='U01'. U01 exists in the existing rows, so the ON CONFLICT clause fires.| user_id | name | updated_at | |
|---|---|---|---|
| U01 (existing) | Taro Tanaka | tanaka@example.com | 2024-04-01 |
| U02 (existing) | Hanako Sato | sato@example.com | 2024-04-15 |
LEGEND
① INSERT attempt (U03)
INSERT INTO user_profiles VALUES ('U03', ...)We attempt to INSERT U03 (Ichiro Suzuki). The database checks the UNIQUE constraint on user_id='U03'. U03 does not exist, so there is no conflict and it is processed as a normal INSERT.| user_id | name | updated_at | |
|---|---|---|---|
| U01 | Taro Tanaka (renamed) | tanaka@example.com | 2024-06-01 |
| U02 | Hanako Sato | sato@example.com | 2024-04-15 |
INSERT ... ON DUPLICATE KEY UPDATE; PostgreSQL uses ON CONFLICT ... DO UPDATE).EXCLUDED.column_name to reference the new value being inserted. user_profiles.column_name references the existing row. For example, SET click_count = user_profiles.click_count + EXCLUDED.click_count can add to the existing value.ON CONFLICT DO NOTHING ignores the error and keeps the existing row unchanged. Use it for master-data initialization when only the first INSERT should take effect, or when duplicate inserts are possible but should not become errors.IF EXISTS(SELECT ...) THEN UPDATE ELSE INSERT can encounter a duplicate-key error when another thread inserts between SELECT and INSERT in a multithreaded environment (a TOCTOU race). UPSERT is safer because it handles the operation atomically in one statement.ON CONFLICT (user_id) errors unless user_id has a UNIQUE or primary-key constraint. Always add a database-level unique constraint to columns used with ON CONFLICT.updated_at = NOW() also records when synchronization occurred and helps investigate incidents.