UPDATE changes column values in existing rows. The most important safety rule is to restrict the target rows with WHERE. Omitting WHERE updates every row in the table.
UPDATE table_name -- Target table SET col1 = 'new_value', -- Columns and values to update (comma-separated for multiple columns) col2 = NOW() -- Multiple columns can be updated at once WHERE id = 1; -- Required! Omitting it updates every row (a catastrophic bug)
UPDATE users SET status = 'inactive' changes every user to inactive. Before running an UPDATE, first use SELECT with the same WHERE clause to verify the affected rows.Common batch patterns include marking expired subscriptions inactive and invalidating expired tokens in bulk.
In the subscriptions table, find subscriptions whose end_date is today or earlier and whose status is 'active', then update their status to 'expired'. At the same time, set updated_at to the current time and use RETURNING to return the subscription_id and status of each updated row.
| subscription_id | user_id | status | end_date | updated_at |
|---|---|---|---|---|
| 1 | U01 | active | 2024-05-20 | 2024-04-01 |
| 2 | U02 | active | 2024-06-30 | 2024-05-01 |
| 3 | U03 | active | 2024-05-31 | 2024-04-15 |
| 4 | U04 | expired | 2024-04-01 | 2024-04-02 |
| 5 | U05 | active | 2024-07-15 | 2024-05-10 |
Rows returned by RETURNING (the two active subscriptions with end_date ≤ 2024-06-01):
| subscription_id | status (after update) |
|---|---|
| 1 | expired |
| 3 | expired |
IDs 2 and 5 have future end dates; ID 4 is already expired, so all three are excluded.
UPDATE subscriptions SET status = 'expired', updated_at = NOW() WHERE -- A carefully scoped WHERE clause prevents a full-table update end_date <= CURRENT_DATE -- Expired rows AND status = 'active' -- Active rows only (avoids needless updates) RETURNING subscription_id, status; -- Return updated rows /* Logical execution order: 1. FROM subscriptions → Read rows 2. WHERE → Filter rows 3. SET status='expired' → Update column values 4. RETURNING → Return updated rows */
LEGEND
① FROM
FROM subscriptionsRead all five subscription rows. The current date is 2024-06-01.| subscription_id | user_id | status | end_date | updated_at |
|---|---|---|---|---|
| 1 | U01 | active | 2024-05-20 | 2024-04-01 |
| 2 | U02 | active | 2024-06-30 | 2024-05-01 |
| 3 | U03 | active | 2024-05-31 | 2024-04-15 |
| 4 | U04 | expired | 2024-04-01 | 2024-04-02 |
| 5 | U05 | active | 2024-07-15 | 2024-05-10 |
WHERE end_date <= CURRENT_DATE AND status='active'. CRON plus this UPDATE and RETURNING lets the application log exactly how many subscriptions changed.AND status='active' predicate excludes rows already processed, so rerunning the batch produces the same result and is safe after a duplicate execution.CURRENT_DATE contains a date without a time, while NOW() returns a timestamp. Use the former for date comparisons and the latter for update timestamps.UPDATE subscriptions SET status='expired' marks all five subscriptions expired. Before an UPDATE, run a matching SELECT COUNT(*) FROM subscriptions WHERE ... and review the count.SET stats = 'expired' fails if the column does not exist, but can silently change the wrong data if a similarly named column exists. Review column names before execution.AND status = 'active' makes already-processed rows ineligible, so a retry is safe. When writing an UPDATE batch, always ask what happens if it stops halfway and starts again.DELETE physically removes rows from a table. A soft delete keeps the row and records the deletion time in a deleted_at column. Soft deletion is common in API development because the data can be restored and audited.
-- Hard delete: permanently remove a row DELETE FROM table_name -- Target table WHERE id = 1; -- Required; omitting it deletes every row -- Soft delete: keep the row and record its deletion time UPDATE table_name SET deleted_at = NOW() -- Change NULL to the current timestamp WHERE id = 1 AND deleted_at IS NULL; -- Only rows not yet deleted (idempotent)
WHERE deleted_at IS NULL.In the comments table, soft-delete the comment with comment_id=3 by recording the current time in deleted_at. Also write a query that returns only active comments, excluding rows that have been soft-deleted.
| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | Great article | NULL |
| 2 | 10 | Very helpful | NULL |
| 3 | 11 | Inappropriate comment | NULL |
| 4 | 11 | Thank you | 2024-05-01 |
Active comments after the soft delete (only rows where deleted_at IS NULL):
| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | Great article | NULL |
| 2 | 10 | Very helpful | NULL |
ID 3 is deleted by this operation, and ID 4 was already deleted, so both are excluded.
-- ① Soft-delete comment 3 by recording the current time UPDATE comments SET deleted_at = NOW() -- Record the deletion time; do not remove the row WHERE comment_id = 3 AND deleted_at IS NULL; -- Only undeleted rows -- ② Return active comments whose deleted_at is NULL SELECT comment_id, post_id, body FROM comments WHERE deleted_at IS NULL -- Undeleted rows only ORDER BY comment_id ASC; /* Logical execution order for the SELECT: 1. FROM comments → Read rows 2. WHERE deleted_at IS NULL → Filter rows 3. SELECT → Evaluate columns 4. ORDER BY comment_id → Sort and return */
LEGEND
① FROM
FROM comments before the soft deleteRead all four comments. ID 4 has already been soft-deleted.| comment_id | body | deleted_at |
|---|---|---|
| 1 | Great article | NULL |
| 2 | Very helpful | NULL |
| 3 | Inappropriate comment | NULL |
| 4 | Thank you | 2024-05-01 |
LEGEND
① FROM
FROM comments after the soft deleteRead the comments after IDs 3 and 4 have deletion timestamps.| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | Great article | NULL |
| 2 | 10 | Very helpful | NULL |
| 3 | 11 | Inappropriate comment | 2024-06-01 |
| 4 | 11 | Thank you | 2024-05-01 |
WHERE deleted_at IS NULL unless deleted rows are explicitly intended. An ORM default scope or view can apply this rule consistently.DELETE FROM comments removes every row. Check the intended count with SELECT COUNT(*) WHERE ... before running production DELETE statements.is_deleted = true/false loses when deletion occurred. A TIMESTAMPTZ deleted_at represents both active rows (NULL) and deleted rows (timestamp).email and deleted_at, or appending a deletion timestamp such as _deleted_<timestamp> to the archived value.A CTE (Common Table Expression) defines a temporary named result set with WITH that later parts of the query can reference. CTEs make complex batch queries easier to read by separating them into clear steps.
WITH cte1 AS ( -- Define the first CTE SELECT col1, col2 FROM table_a WHERE ... ), cte2 AS ( -- Separate CTEs with commas SELECT col1 FROM cte1 -- Reference an earlier CTE WHERE ... ) SELECT * FROM cte2; -- Finish with the main query
Identify users who placed at least two orders in the last 30 days as VIP customers. Using the orders and users tables, split the query into two CTE stages and return each VIP user's user_id, name, and order_count in descending order of order_count. Assume the current date is 2024-06-01.
| order_id | user_id | ordered_at |
|---|---|---|
| 1 | U01 | 2024-05-10 |
| 2 | U01 | 2024-05-20 |
| 3 | U02 | 2024-05-25 |
| 4 | U03 | 2024-04-01 |
| 5 | U01 | 2024-05-28 |
| 6 | U02 | 2024-05-29 |
| 7 | U03 | 2024-03-15 |
| user_id | name |
|---|---|
| U01 | Tanaka Taro |
| U02 | Sato Hanako |
| U03 | Suzuki Ichiro |
Return VIP users with at least two orders in the last 30 days, sorted by order_count descending:
| user_id | name | order_count |
|---|---|---|
| U01 | Tanaka Taro | 3 |
| U02 | Sato Hanako | 2 |
U03 has no orders in the last 30 days and is excluded.
WITH recent_orders AS ( -- CTE 1: Count each user's orders in the last 30 days SELECT user_id, COUNT(*) AS order_count FROM orders WHERE ordered_at >= CURRENT_DATE - INTERVAL '30 days' -- last 30 days GROUP BY user_id ), vip_users AS ( -- CTE 2: Keep users with at least two orders SELECT user_id, order_count FROM recent_orders -- Read the result of the first CTE WHERE order_count >= 2 ) SELECT v.user_id, u.name, v.order_count FROM vip_users v INNER JOIN users u -- join names to VIP users ON v.user_id = u.user_id ORDER BY v.order_count DESC; /* Logical execution order: 1. CTE recent_orders → Build recent-order counts 2. CTE vip_users → Keep counts of at least two 3. FROM vip_users v → Read rows 4. INNER JOIN users u → Join matching rows 5. SELECT → Evaluate columns 6. ORDER BY order_count DESC → Sort and return */
LEGEND
① CTE: FROM + WHERE
recent_orders AS (FROM orders WHERE ordered_at >= 30 )Keep orders dated 2024-05-02 or later. U03's older orders are outside the 30-day window.| order_id | user_id | ordered_at | within 30 days? |
|---|---|---|---|
| 1 | U01 | 2024-05-10 | |
| 2 | U01 | 2024-05-20 | |
| 3 | U02 | 2024-05-25 | |
| 4 | U03 | 2024-04-01 | |
| 5 | U01 | 2024-05-28 | |
| 6 | U02 | 2024-05-29 | |
| 7 | U03 | 2024-03-15 |
recent_orders and vip_users tell the reader what each stage does and often remove the need for explanatory comments.SELECT * FROM orders carries unused data into later stages. Select only the columns required by downstream logic for better readability and performance.EXISTS evaluates to TRUE when its subquery returns at least one row and FALSE otherwise. It is frequently used in batch jobs to select rows that have related data or rows that have not yet been processed.
SELECT col1 FROM table_a a WHERE EXISTS ( -- TRUE when the subquery returns a row SELECT 1 -- The selected value is irrelevant FROM table_b b WHERE b.a_id = a.id -- Correlate with the outer row ); WHERE NOT EXISTS (...) -- TRUE when the subquery returns no rows
From the users table, retrieve users who have never placed an order—that is, users with no matching row in orders. Return user_id and name, sorted by user_id ascending.
| user_id | name |
|---|---|
| 1 | Tanaka Taro |
| 2 | Sato Hanako |
| 3 | Suzuki Ichiro |
| 4 | Yamada Jiro |
| 5 | Ito Saburo |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 5000 |
| 102 | 3 | 3200 |
| 103 | 1 | 8800 |
Users who have never placed an order (no matching row in orders):
| user_id | name |
|---|---|
| 2 | Sato Hanako |
| 4 | Yamada Jiro |
| 5 | Ito Saburo |
User 1 has two orders and user 3 has one, so both are excluded.
SELECT u.user_id, u.name FROM users u WHERE NOT EXISTS ( -- Keep only users with no orders SELECT 1 -- The value does not matter for an existence test FROM orders o WHERE o.user_id = u.user_id -- Correlate orders with the outer user ) ORDER BY u.user_id ASC; /* Logical execution order: 1. FROM users u → Read rows 2. NOT EXISTS (subquery) → Check for matching orders 3. SELECT → Evaluate columns 4. ORDER BY u.user_id → Sort and return */
LEGEND
① FROM
FROM users uRead all five users. The correlated NOT EXISTS subquery is evaluated for each user.| user_id | name |
|---|---|
| 1 | Tanaka Taro |
| 2 | Sato Hanako |
| 3 | Suzuki Ichiro |
| 4 | Yamada Jiro |
| 5 | Ito Saburo |
SELECT 1 is therefore conventional and communicates the intent more clearly than SELECT *.WHERE user_id NOT IN (...) can evaluate to UNKNOWN for every row and return nothing. NOT EXISTS is safer for nullable data.WHERE EXISTS (SELECT 1 FROM orders) is true for every user whenever orders has any row. The correlated predicate o.user_id = u.user_id is essential.CASE WHEN is SQL's if-else expression. It can be used in SELECT, WHERE, ORDER BY, GROUP BY, and many other contexts. It is common in APIs for converting stored values into labels and in batch aggregation for conditional counts.
-- Simple CASE: Branch on enumerated column values: CASE col1 WHEN 'val1' THEN 'displayA' -- col1 = 'val1' WHEN 'val2' THEN 'displayB' -- col1 = 'val2' ELSE 'Other' -- When nothing matches (NULL if omitted) END -- Always close CASE with END -- Searched CASE: Branch on any condition; more flexible: CASE WHEN col2 > 100 THEN 'Large' -- Comparison operators can be used WHEN col2 > 50 THEN 'Medium' -- Evaluate top to bottom and return the first matching THEN ELSE 'Small' END
For each row in orders, assign a purchase rank based on amount: premium for 10000 or more, standard for 5000 or more, and basic otherwise. Also convert status codes to display labels. Return order_id, amount, rank_label, and status_label, sorted by ordered_at descending.
| order_id | amount | status | ordered_at |
|---|---|---|---|
| 1 | 12000 | completed | 2024-05-20 |
| 2 | 4800 | pending | 2024-05-18 |
| 3 | 7500 | cancelled | 2024-05-15 |
| 4 | 500 | completed | 2024-05-10 |
| 5 | 5000 | pending | 2024-05-08 |
Expected output, with amount mapped to a rank and status mapped to an English label:
| order_id | amount | rank_label | status_label |
|---|---|---|---|
| 1 | 12000 | premium | Completed |
| 2 | 4800 | basic | Pending |
| 3 | 7500 | standard | Cancelled |
| 4 | 500 | basic | Completed |
| 5 | 5000 | standard | Pending |
SELECT order_id, amount, CASE -- purchase rank WHEN amount >= 10000 THEN 'premium' WHEN amount >= 5000 THEN 'standard' -- Evaluate top to bottom (use the first match) ELSE 'basic' END AS rank_label, CASE status -- Branch on a column value (simple CASE) WHEN 'completed' THEN 'Completed' WHEN 'pending' THEN 'Pending' WHEN 'cancelled' THEN 'Cancelled' ELSE 'Unknown' -- Fallback for unexpected values END AS status_label FROM orders ORDER BY ordered_at DESC; /* Logical execution order: 1. FROM orders → Read rows 2. SELECT → Evaluate rank_label and status_label 3. ORDER BY ordered_at → Sort and return */
LEGEND
① FROM
FROM ordersRead all five orders. The CASE expressions are evaluated for each row in SELECT.| order_id | amount | status | ordered_at |
|---|---|---|---|
| 1 | 12000 | completed | 2024-05-20 |
| 2 | 4800 | pending | 2024-05-18 |
| 3 | 7500 | cancelled | 2024-05-15 |
| 4 | 500 | completed | 2024-05-10 |
| 5 | 5000 | pending | 2024-05-08 |
'completed' in the database and use CASE to produce a response label. This can keep a simple one-query transformation close to the data.SUM(CASE WHEN status='completed' THEN amount ELSE 0 END) calculate a conditional total and are frequently combined with GROUP BY for KPI reports.ELSE 'Unknown'.WHEN amount >= 5000 THEN 'standard' WHEN amount >= 10000 THEN 'premium', 12000 matches the first branch and never reaches premium. Order searched CASE conditions from most specific to least specific.