DISTINCT removes duplicate rows from a SELECT result. COUNT(DISTINCT column) counts unique non-NULL values. It is the foundation for answering “how many kinds of values are there?” rather than simply “how many rows are there?”
SELECT DISTINCT col1, col2 -- Remove duplicate col1+col2 combinations FROM table_name; SELECT COUNT(DISTINCT col1) -- Count unique col1 values (NULL is excluded) FROM table_name;
COUNT(*) counts all rows, COUNT(DISTINCT col) counts unique values, and COUNT(col) without DISTINCT counts non-NULL rows. Understand all three precisely.Measuring unique users visiting a site or the number of different products purchased is common in every kind of API and batch aggregation.
From the access_logs table, return in one row (1) the number of unique users in May 2024 (deduplicated user_id count) and (2) the total number of accesses. In a separate query, return the deduplicated list of user IDs that accessed the site, ordered by user_id ascending.
| log_id | user_id | page | accessed_at |
|---|---|---|---|
| 1 | U01 | /home | 2024-05-01 |
| 2 | U02 | /items | 2024-05-03 |
| 3 | U01 | /items | 2024-05-05 |
| 4 | U03 | /home | 2024-05-10 |
| 5 | U01 | /cart | 2024-05-12 |
| 6 | U02 | /home | 2024-05-20 |
| 7 | U04 | /items | 2024-06-01 |
1. Expected output for the aggregate query (May 2024 only; June's U04 is excluded):
| unique_users | total_access |
|---|---|
| 3 | 6 |
2. User ID list (deduplicated, ascending):
| user_id |
|---|
| U01 |
| U02 |
| U03 |
U04 is excluded because its access was in June. U01 accessed three times but counts as one distinct user.
-- 1. Aggregate unique users and total accesses SELECT COUNT(DISTINCT user_id) AS unique_users, -- Number of distinct user_id values COUNT(*) AS total_access -- All rows (total accesses) FROM access_logs WHERE accessed_at >= '2024-05-01' -- From May 1 AND accessed_at < '2024-06-01'; -- Before June 1 -- 2. Deduplicated, ascending list of accessing user IDs SELECT DISTINCT user_id -- Remove duplicate user_id values FROM access_logs WHERE accessed_at >= '2024-05-01' -- From May 1 AND accessed_at < '2024-06-01' -- Before June 1 ORDER BY user_id ASC; /* Logical execution order (1. aggregate query): 1. FROM access_logs → Read rows 2. WHERE accessed_at ... → Filter rows 3. SELECT → Evaluate aggregate functions (unique_users, total_access) Logical execution order (2. DISTINCT query): 1. FROM access_logs → Read rows 2. WHERE accessed_at ... → Filter rows 3. SELECT DISTINCT user_id → Remove duplicates 4. ORDER BY user_id ASC → Sort and return */
LEGEND
① FROM
FROM access_logsRead all seven rows from access_logs. The next WHERE step keeps only rows from May.| log_id | user_id | page | accessed_at |
|---|---|---|---|
| 1 | U01 | /home | 2024-05-01 |
| 2 | U02 | /items | 2024-05-03 |
| 3 | U01 | /items | 2024-05-05 |
| 4 | U03 | /home | 2024-05-10 |
| 5 | U01 | /cart | 2024-05-12 |
| 6 | U02 | /home | 2024-05-20 |
| 7 | U04 | /items | 2024-06-01 |
LEGEND
① FROM + WHERE
FROM access_logs WHERE MayFilter access_logs with WHERE to keep May rows. U04's June access is excluded, leaving six rows.| log_id | user_id | accessed_at |
|---|---|---|
| 1 | U01 | 2024-05-01 |
| 2 | U02 | 2024-05-03 |
| 3 | U01 | 2024-05-05 |
| 4 | U03 | 2024-05-10 |
| 5 | U01 | 2024-05-12 |
| 6 | U02 | 2024-05-20 |
SELECT DISTINCT user_id, page deduplicates by user-and-page pair.COUNT(*) = all rows including NULL, COUNT(col) = non-NULL rows, and COUNT(DISTINCT col) = unique values. Analytics and KPI dashboard APIs may use all three.SELECT DISTINCT user_id when you only need a user ID list. Use SELECT user_id, COUNT(*) ... GROUP BY user_id when you also need each user’s access count.SELECT DISTINCT user_id.COUNT(*) includes duplicates, so “unique users = COUNT(*)” is wrong. If U01 accessed three times, it contributes three rows. Always use COUNT(DISTINCT user_id) for unique users.COUNT(DISTINCT user_id) gives an accurate count when only logged-in users are included. Guests and users on multiple devices can cause overcounting or undercounting. Keep the KPI definition and SQL implementation together and review them as one specification.In SQL, NULL represents a missing or unknown value. Arithmetic with NULL and string concatenation with NULL both produce NULL. Because a NULL in an API response can cause unexpected behavior, explicit NULL handling is essential.
COALESCE(col, 'default value') -- Evaluate left to right and return the first non-NULL value col IS NULL -- Test for NULL (= NULL is invalid; always use IS NULL) col IS NOT NULL -- Test that a value is not NULL NULLIF(col, 0) -- Return NULL when col is 0 (for example, to prevent division by zero)
WHERE col = NULL is always false and matches no rows. Use IS NULL or IS NOT NULL for NULL comparisons. This is one of SQL’s most important rules.From the users table, return all users. Display 'Not registered' when phone is NULL, and use name as a fallback when display_name is NULL. In a separate query, return the number of users whose phone is NULL.
| user_id | name | display_name | phone |
|---|---|---|---|
| 1 | Taro Tanaka | Tanaka | 090-1111-2222 |
| 2 | Hanako Sato | NULL | 080-3333-4444 |
| 3 | Ichiro Suzuki | Suzuki | NULL |
| 4 | Jiro Yamada | NULL | NULL |
1. Expected output (all users, NULLs handled):
| user_id | shown_name | phone_display |
|---|---|---|
| 1 | Tanaka | 090-1111-2222 |
| 2 | Hanako Sato | 080-3333-4444 |
| 3 | Suzuki | Not registered |
| 4 | Jiro Yamada | Not registered |
2. Number of users whose phone is NULL:
| null_phone_count |
|---|
| 2 |
-- 1. Return all users with safe defaults for NULLs SELECT user_id, COALESCE(display_name, name) AS shown_name, -- Use name when display_name is NULL COALESCE(phone, 'Not registered') AS phone_display -- Use 'Not registered' when NULL FROM users ORDER BY user_id; -- 2. Count users whose phone is NULL SELECT COUNT(*) AS null_phone_count -- Count after filtering with IS NULL FROM users WHERE phone IS NULL; -- Test NULL with IS NULL (= NULL is invalid) /* Logical execution order (1. NULL-default query): 1. FROM users → Read rows 2. SELECT → Evaluate columns (correct NULLs with COALESCE) 3. ORDER BY user_id → Sort and return Logical execution order (2. NULL-count query): 1. FROM users → Read rows 2. WHERE phone IS NULL → Filter rows 3. SELECT null_phone_count → Evaluate the aggregate */
LEGEND
① FROM
FROM usersRead all four rows from users. display_name and phone each contain NULL values.| user_id | name | display_name | phone |
|---|---|---|---|
| 1 | Taro Tanaka | Tanaka | 090-1111-2222 |
| 2 | Hanako Sato | NULL | 080-3333-4444 |
| 3 | Ichiro Suzuki | Suzuki | NULL |
| 4 | Jiro Yamada | NULL | NULL |
LEGEND
① FROM
FROM usersRead all four rows from users. WHERE will identify the rows whose phone is NULL (user_id=3,4).| user_id | name | phone |
|---|---|---|
| 1 | Taro Tanaka | 090-1111-2222 |
| 2 | Hanako Sato | 080-3333-4444 |
| 3 | Ichiro Suzuki | NULL |
| 4 | Jiro Yamada | NULL |
==. NULL = NULL is not TRUE; it is NULL (unknown). Always use IS NULL or IS NOT NULL for NULL checks.COALESCE(nickname, display_name, name, 'Anonymous'). It evaluates left to right and returns the first non-NULL value; the final fixed value is used if all arguments are NULL.SUM(amount) / NULLIF(COUNT(*), 0) makes NULLIF return NULL when COUNT(*) is 0, preventing a division-by-zero error and safely calculating an average for an empty database.SUM, AVG, and COUNT(col) ignore NULLs. If a phone column has five rows and two are NULL, COUNT(phone) returns 3. You can use this to count non-NULL values.= NULL asks whether an unknown value equals NULL, so the result is never TRUE and no rows match. Always use IS NULL.price * quantity is NULL, the result is NULL. Returning a NULL total to an API can break frontend display or calculations. Protect it with defaults such as COALESCE(price, 0) * COALESCE(quantity, 0).LIKE performs partial, prefix, and suffix string matching. ILIKE (a PostgreSQL extension) is LIKE without case sensitivity. It is a basic building block for a search API such as GET /products?q=keyword.
WHERE col LIKE '%keyword%' -- Partial match (% = any string of zero or more characters) WHERE col LIKE 'keyword%' -- Prefix match (starts with keyword) WHERE col LIKE '%keyword' -- Suffix match (ends with keyword) WHERE col LIKE 'k_yword' -- _ = any one character (one-character wildcard) WHERE col ILIKE '%Keyword%' -- Case-insensitive partial match (PostgreSQL)
LIKE '%keyword%' (with a leading %) cannot use a normal index and scans every row. For serious full-text search on large tables, consider a pg_trgm GIN index or a search engine such as Elasticsearch.From the products table, return products whose name contains 'coffee', ordered by price from low to high (case-insensitive). In a separate query, count products whose SKU code (sku) starts with 'BEV-'.
| product_id | name | sku | price |
|---|---|---|---|
| 1 | Iced Coffee | BEV-001 | 350 |
| 2 | Green Tea | BEV-002 | 250 |
| 3 | Hot Coffee | BEV-003 | 380 |
| 4 | Chocolate Cake | FOOD-001 | 480 |
| 5 | Coffee Jelly | FOOD-002 | 320 |
| 6 | Orange Juice | BEV-004 | 280 |
1. Products whose name contains 'coffee' (price ascending):
| product_id | name | sku | price |
|---|---|---|---|
| 5 | Coffee Jelly | FOOD-002 | 320 |
| 1 | Iced Coffee | BEV-001 | 350 |
| 3 | Hot Coffee | BEV-003 | 380 |
2. Number of products whose SKU starts with 'BEV-':
| bev_count |
|---|
| 4 |
-- 1. Return products whose name contains 'coffee', ordered by price SELECT product_id, name, sku, price FROM products WHERE name ILIKE '%coffee%' -- Case-insensitive partial match (PostgreSQL) ORDER BY price ASC; -- 2. Count products whose sku starts with 'BEV-' SELECT COUNT(*) AS bev_count FROM products WHERE sku LIKE 'BEV-%'; -- Prefix match (starts with 'BEV-') /* Logical execution order (1. ILIKE partial-match query): 1. FROM products → Read rows 2. WHERE name ILIKE ... → Filter rows 3. SELECT → Evaluate columns 4. ORDER BY price ASC → Sort and return Logical execution order (2. LIKE prefix-count query): 1. FROM products → Read rows 2. WHERE sku LIKE 'BEV-%' → Filter rows 3. SELECT bev_count → Evaluate the aggregate */
LEGEND
① FROM
FROM productsRead all six rows from products. The next WHERE keeps rows whose name contains 'coffee'.| product_id | name | sku | price |
|---|---|---|---|
| 1 | Iced Coffee | BEV-001 | 350 |
| 2 | Green Tea | BEV-002 | 250 |
| 3 | Hot Coffee | BEV-003 | 380 |
| 4 | Chocolate Cake | FOOD-001 | 480 |
| 5 | Coffee Jelly | FOOD-002 | 320 |
| 6 | Orange Juice | BEV-004 | 280 |
LEGEND
① FROM
FROM productsRead all six rows from products. The next WHERE keeps SKU codes beginning with 'BEV-'.| product_id | name | sku | price |
|---|---|---|---|
| 1 | Iced Coffee | BEV-001 | 350 |
| 2 | Green Tea | BEV-002 | 250 |
| 3 | Hot Coffee | BEV-003 | 380 |
| 4 | Chocolate Cake | FOOD-001 | 480 |
| 5 | Coffee Jelly | FOOD-002 | 320 |
| 6 | Orange Juice | BEV-004 | 280 |
% matches any string of zero or more characters, while _ matches exactly one character. For example, CAT and CUT match C_T, but COAT does not.?q=coffee and binds it with a placeholder, for example WHERE name ILIKE % || $1 || %. Placeholders (bound variables) are mandatory for SQL-injection protection.LOWER(col) LIKE LOWER(%key%).prefix% may use a B-tree index. A partial-match pattern with % on both sides, %keyword%, usually cannot. Consider pg_trgm for large searches.DROP TABLE products can execute arbitrary SQL. Always use placeholders such as $1.LIKE %keyword% over millions of rows can force a full scan and time out an API. Use pg_trgm plus a GIN index or a dedicated full-text search engine.IN tests whether a column value appears in a specified list or subquery result set. NOT IN returns rows that are not included. EXISTS checks whether a subquery returns at least one row.
WHERE col IN ('a', 'b', 'c') -- col matches one of 'a', 'b', or 'c' WHERE col IN (SELECT id FROM t) -- Included in the subquery result set WHERE col NOT IN ('x', 'y') -- col is neither 'x' nor 'y' WHERE EXISTS ( -- Check whether the subquery finds one or more rows SELECT 1 FROM t WHERE t.id = outer.id -- Correlate with the outer query )
WHERE col IS NOT NULL to the subquery or use NOT EXISTS.1. From the orders table, return orders whose status is 'completed' or 'pending'. 2. From the same table, return orders whose purchase_id does not exist in the purchases table (unprocessed orders).
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
| purchase_id | processed_at |
|---|---|
| 1 | 2024-05-10 |
| 4 | 2024-05-11 |
1. Orders with completed or pending status (order_id ascending):
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
2. Unprocessed orders not present in purchases (order_id: 2, 3, 5):
| order_id | user_id | amount | status |
|---|---|---|---|
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 5 | U02 | 1200 | pending |
-- 1. Return completed or pending orders (using IN) SELECT order_id, user_id, amount, status FROM orders WHERE status IN ('completed', 'pending') -- Match either value concisely instead of OR ORDER BY order_id; -- 2. Orders not present in purchases (using NOT EXISTS) SELECT o.order_id, o.user_id, o.amount, o.status FROM orders o WHERE NOT EXISTS ( -- No subquery row means the order is absent SELECT 1 -- The value is irrelevant for an existence test FROM purchases p WHERE p.purchase_id = o.order_id -- Correlate with the outer query ) ORDER BY o.order_id; /* Logical execution order (1. IN query): 1. FROM orders → Read rows 2. WHERE status IN (...) → Filter rows 3. SELECT → Evaluate columns 4. ORDER BY order_id → Sort and return Logical execution order (2. NOT EXISTS query): 1. FROM orders o → Read rows 2. WHERE NOT EXISTS → Evaluate the subquery and filter rows 3. SELECT → Evaluate columns 4. ORDER BY o.order_id → Sort and return */
LEGEND
① FROM
FROM ordersRead all five rows from orders. status has three values: completed, pending, and cancelled.| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
LEGEND
① FROM orders
FROM orders oRead all five rows from the outer orders query. For each row, the subquery checks whether it exists in purchases.| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U02 | 5000 | pending |
| 3 | U01 | 8000 | cancelled |
| 4 | U03 | 2000 | completed |
| 5 | U02 | 1200 | pending |
WHERE status IN (completed, pending) means the same as two OR predicates. IN remains readable as the list grows.SELECT 1 and SELECT NULL are conventional and equivalent.WHERE order_id NOT IN (SELECT purchase_id FROM purchases) becomes an unknown comparison and returns no rows. Add WHERE purchase_id IS NOT NULL or use NOT EXISTS.WHERE id IN (1,2,3,...,10000) makes SQL parsing expensive. Insert the IDs into a temporary table and join it instead.Date and time functions are essential for aggregating logs over periods and scheduling batch jobs. Learn the main PostgreSQL functions.
NOW() -- Current timestamp (with time zone) CURRENT_DATE -- Today’s date (without a time) DATE_TRUNC('month', col) -- Truncate col to the first instant of its month (year/day/hour also work) EXTRACT(YEAR FROM col) -- Extract only the year (MONTH, DAY, DOW, and others also work) col ::DATE -- Cast a timestamp to a date col + INTERVAL '7 days' -- Timestamp seven days later col - INTERVAL '1 month' -- Timestamp one month earlier
DATE_TRUNC('month', NOW()) as the inclusive lower bound and DATE_TRUNC('month', NOW()) + INTERVAL '1 month' as the exclusive upper bound.From the orders table, aggregate the number of orders and total amount per month. Display the month in 'YYYY-MM' format and show the newest month first. Also write a separate query that returns only orders from the past 30 days.
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
1. Monthly aggregation (newest month first):
| month | order_count | total_amount |
|---|---|---|
| 2024-06 | 1 | 4500 |
| 2024-05 | 3 | 11200 |
| 2024-04 | 2 | 8000 |
2. Orders from the past 30 days (reference date: 2024-06-03, ordered_at descending):
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 6 | U01 | 4500 | 2024-06-03 |
| 5 | U02 | 1200 | 2024-05-28 |
| 4 | U03 | 2000 | 2024-05-15 |
| 3 | U01 | 8000 | 2024-05-10 |
-- 1. Aggregate order count and total amount by month (newest first) SELECT TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month, -- Truncate to month start, then format as YYYY-MM COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY DATE_TRUNC('month', ordered_at) -- Group by month ORDER BY DATE_TRUNC('month', ordered_at) DESC; -- Newest month first -- 2. Return only orders from the past 30 days SELECT order_id, user_id, amount, ordered_at FROM orders WHERE ordered_at >= NOW() - INTERVAL '30 days' -- The last 30 days ORDER BY ordered_at DESC; /* Logical execution order (1. monthly aggregation): 1. FROM orders → Read rows 2. GROUP BY DATE_TRUNC → Group rows 3. SELECT → Evaluate COUNT and SUM 4. ORDER BY ... DESC → Sort and return Logical execution order (2. past-30-days query): 1. FROM orders → Read rows 2. WHERE ordered_at >= ... → Filter rows 3. SELECT → Evaluate columns 4. ORDER BY ordered_at DESC → Sort and return */
LEGEND
① FROM
FROM ordersRead all six rows from orders. ordered_at contains data from April through June 2024.| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
LEGEND
① FROM
FROM ordersRead all six rows from orders. WHERE will keep only rows within 30 days of the current time.| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | U01 | 3000 | 2024-04-05 |
| 2 | U02 | 5000 | 2024-04-20 |
| 3 | U01 | 8000 | 2024-05-10 |
| 4 | U03 | 2000 | 2024-05-15 |
| 5 | U02 | 1200 | 2024-05-28 |
| 6 | U01 | 4500 | 2024-06-03 |
EXTRACT(MONTH FROM ordered_at) → 5. DATE_TRUNC creates a timestamp key for grouping, so it is usually better for GROUP BY.NOW() - INTERVAL 30 days avoids hard-coded dates and is ideal for daily batches. Intervals such as 1 month, 1 year, and 6 hours are also available.WHERE EXTRACT(YEAR FROM ordered_at) = 2024 applies a function to ordered_at and can disable its index. Use a range instead: ordered_at >= 2024-01-01 AND ordered_at < 2025-01-01.AT TIME ZONE Asia/Tokyo.CURRENT_DATE - INTERVAL 1 day is standard. But a batch crossing midnight can see NOW change to a new day and shift its target range. A robust design passes the batch start time as a processing-reference timestamp and uses that parameter in WHERE clauses.