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 |
Expected output (RANK: equal sales share a rank; running totals accumulate in descending amount order):
| 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 |
Tea and Juice in Beverages both total 3000, so they share rank 2 (rank 4 is skipped)
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 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 category, rank_in_cat; -- Ascending category, then rank within each category /* 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 category, rank_in_cat → Sort and return */
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.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 |
Expected output (products without a category are shown as 'Uncategorized'):
| 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 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 |
Expected output (Combine both tables, ordered by sent_at descending):
| 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.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 |
|---|---|---|---|
| (NEW) | U01 | 1500 | pending |
| product_id | name | stock |
|---|---|---|
| P01 | Coffee | 100 |
State of each table after a successful transaction (after COMMIT):
| Table | Change |
|---|---|
| orders | A new order row (order_id=1001, user_id=U01, total=1500) is added |
| order_items | A line item (order_id=1001, product_id=P01, qty=3, price=500) is added |
| products | P01 stock changes from 100 → 97 (three units removed) |
If an error occurs partway through, ROLLBACK returns every table to its pre-BEGIN state
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) /* 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 */
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.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_profiles after UPSERT:
| 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) |
U01: existing row → name updated and email retained / U02: absent from sync data, so unchanged / U03: new row → INSERT
-- 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.