PostgreSQL Interview Questions and Answers Part 2 - GROUP BY, HAVING and FILTER

PostgreSQL Interview Q&A · Part 2 of 10

In this post we are going to cover GROUP BY and aggregation in PostgreSQL: WHERE versus HAVING, the three kinds of COUNT, the FILTER clause, the GROUP BY error everybody hits, string_agg, ROLLUP, and the median. These are 7 real PostgreSQL interview questions, asked the way they come up on the job.

Aggregation questions look easy, and that is why interviewers like them. Small details decide the answer: when a filter runs, what a NULL does to a count, and which columns you are allowed to select. We answer each question with the short answer first, then a picture of what happens to the rows, then the real query and its output.

Every query below is complete, so you can copy it, run it on the sample data and get exactly the output shown under it.

VIDEO COMING SOON

Part 2 of the Real Time PostgreSQL Interview Questions and Answers series on TechBrothersIT.

Let's create some sample data

We use a small shop database called TB Shop: departments, employees, customers, products, orders, order items and payments. The script below creates everything this post needs. It was written for and run on PostgreSQL 16.

Sample data - SQL script (PostgreSQL)
CREATE TABLE departments (dept_id INT PRIMARY KEY, dept_name VARCHAR(20));
INSERT INTO departments VALUES (10, 'Sales'), (20, 'Engineering'), (30, 'HR'), (40, 'Finance');

CREATE TABLE employees (
  emp_id     INT PRIMARY KEY,
  name       VARCHAR(20),
  dept_id    INT,            -- NULL = not assigned yet
  manager_id INT,            -- NULL = top of the company
  salary     INT,
  hire_date  DATE
);
INSERT INTO employees VALUES
  (1, 'Aisha',  NULL, NULL, 150000, '2018-03-01'),
  (2, 'Omar',   20,   1,    120000, '2019-06-15'),
  (3, 'Lina',   20,   2,     90000, '2021-01-10'),
  (4, 'Ravi',   20,   2,     90000, '2022-04-05'),
  (5, 'Mei',    20,   2,     75000, '2024-09-01'),
  (6, 'Sara',   10,   1,     95000, '2020-02-20'),
  (7, 'Daniel', 10,   6,    105000, '2021-07-01'),
  (8, 'Fatima', 10,   6,     60000, '2023-11-12'),
  (9, 'John',   30,   1,     70000, '2019-09-30'),
  (10,'Zara',   NULL, 9,     55000, '2026-01-05');

CREATE TABLE customers (
  customer_id INT PRIMARY KEY,
  name        VARCHAR(20),
  city        VARCHAR(20),
  region      VARCHAR(10),    -- NULL = unknown
  email       VARCHAR(40)
);
INSERT INTO customers VALUES
  (1, 'Sara Khan',  'Toronto', 'East', 'sara@mail.com'),
  (2, 'Omar Ali',   'Dallas',  'West', 'omar@mail.com'),
  (3, 'Lina Park',  'Seattle', 'West', 'lina@mail.com'),
  (4, 'Mei Chen',   'Boston',  'East', 'mei@mail.com'),
  (5, 'Ravi Patel', 'Austin',  NULL,   'ravi@mail.com'),
  (6, 'Chen Wu',    'Denver',  'West', 'chen@mail.com'),
  (7, 'Sara K.',    'Toronto', 'East', 'sara@mail.com');

CREATE TABLE products (product_id INT PRIMARY KEY, product VARCHAR(20), category VARCHAR(15), price NUMERIC(10,2));
INSERT INTO products VALUES
  (1, 'Laptop',   'Electronics', 1200.00),
  (2, 'Monitor',  'Electronics',  300.00),
  (3, 'Keyboard', 'Accessories',   50.00),
  (4, 'Mouse',    'Accessories',   25.00),
  (5, 'Desk',     'Furniture',    400.00),
  (6, 'Chair',    'Furniture',    250.00);

CREATE TABLE orders (
  order_id    INT PRIMARY KEY,
  customer_id INT,
  order_date  DATE,
  status      VARCHAR(10),
  amount      NUMERIC(10,2)
);
INSERT INTO orders VALUES
  (1001, 1, '2026-01-05', 'Delivered', 1250.00),
  (1002, 2, '2026-01-18', 'Delivered',  300.00),
  (1003, 4, '2026-02-02', 'Delivered',  650.00),
  (1004, 1, '2026-02-14', 'Cancelled',   50.00),
  (1005, 3, '2026-02-20', 'Delivered', 1200.00),
  (1006, 4, '2026-03-03', 'Shipped',    275.00),
  (1007, 5, '2026-03-15', 'Delivered',  400.00),
  (1008, 2, '2026-04-01', 'Delivered',   75.00),
  (1009, 4, '2026-04-22', 'Delivered',  250.00),
  (1010, 1, '2026-05-09', 'Shipped',    300.00),
  (1011, 3, '2026-05-30', 'Cancelled',  400.00),
  (1012, 7, '2026-06-11', 'Delivered',   50.00);

CREATE TABLE order_items (order_id INT, product_id INT, qty INT, unit_price NUMERIC(10,2));
INSERT INTO order_items VALUES
  (1001, 1, 1, 1200.00), (1001, 3, 1, 50.00),
  (1002, 2, 1, 300.00),
  (1003, 5, 1, 400.00), (1003, 6, 1, 250.00),
  (1004, 3, 1, 50.00),
  (1005, 1, 1, 1200.00),
  (1006, 6, 1, 250.00), (1006, 4, 1, 25.00),
  (1007, 5, 1, 400.00),
  (1008, 4, 3, 25.00),
  (1009, 6, 1, 250.00),
  (1010, 2, 1, 300.00),
  (1011, 5, 1, 400.00),
  (1012, 3, 1, 50.00);

CREATE TABLE payments (payment_id INT PRIMARY KEY, order_id INT, paid_amount NUMERIC(10,2));
INSERT INTO payments VALUES
  (1, 1001, 1250.00), (2, 1002, 300.00),
  (3, 1003,  400.00), (4, 1003, 250.00),   -- order 1003 was paid in two parts
  (5, 1005, 1200.00), (6, 1007, 400.00),
  (7, 1008,   75.00), (8, 1009, 250.00), (9, 1012, 50.00);

Question 1: Sales wants the customers whose delivered orders add up to more than 500. One filter is on the status and one is on the total. Where does each filter go?

Say this first

WHERE filters rows before they are grouped, so the status goes there. HAVING filters groups after the aggregate is calculated, so the total goes there.

Follow the rows through the query. We start with all 12 orders. WHERE runs first and keeps the 8 delivered rows. GROUP BY turns those rows into 6 groups, one per customer, and SUM is calculated for each group. Only now can HAVING run, and it keeps the 3 groups with a total above 500.

FROM orders12 rows
WHERE status = 'Delivered'row filter → 8 rows
GROUP BY customer_id6 groups, SUM per group
HAVING SUM(amount) > 500group filter → 3 groups
SELECT3 rows returned
WHERE cannot see SUM: the groups do not exist yet
HAVING can use SUM: it runs after grouping

Here is the query. The status filter is in WHERE, and the total is in HAVING. Three customers come back.

SQL - where and having
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE status = 'Delivered'
GROUP BY customer_id
HAVING SUM(amount) > 500
ORDER BY total DESC;
Output (PostgreSQL 16)3 rows
customer_idtotal
11250.00
31200.00
4900.00

If you put the aggregate in WHERE, Postgres stops you with a clear error: aggregate functions are not allowed in WHERE.

SQL - sum in where
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE SUM(amount) > 500
GROUP BY customer_id;
Error (PostgreSQL 16)ERROR: aggregate functions are not allowed in WHERE

One more tip. A filter on a plain column works in HAVING too, but put it in WHERE. Then Postgres removes those rows early, before it does the grouping work.

Interview tip

Plain column filters belong in WHERE: fewer rows to group, and an index can be used.

Question 2: A dashboard shows 7 customers, a second tile shows 6, and a third shows 2. All three use COUNT on the same customers table. How is that possible?

Say this first

They are three different counts. COUNT(*) counts rows. COUNT(column) counts rows where that column is not NULL. COUNT(DISTINCT column) counts the unique non-NULL values.

Look at the region column. COUNT star counts every row, so it returns 7. COUNT of region skips Ravi's NULL, so it returns 6. And COUNT DISTINCT region finds only two different values, East and West, so it returns 2.

customers
customer_idregionemail
1Eastsara@mail.com
2Westomar@mail.com
3Westlina@mail.com
4Eastmei@mail.com
5NULLravi@mail.com
6Westchen@mail.com
7Eastsara@mail.com
COUNT(*)every row
7
COUNT(region)skips the NULL
6
COUNT(DISTINCT region)East, West
2

Here are all the counts in one query. 7, 6 and 2, exactly as we saw. And COUNT DISTINCT email returns 6, because two customers share the same email address.

SQL - three counts
SELECT COUNT(*)               AS all_rows,
       COUNT(region)          AS with_region,
       COUNT(DISTINCT region) AS regions,
       COUNT(DISTINCT email)  AS emails
FROM customers;
Output (PostgreSQL 16)1 row
all_rowswith_regionregionsemails
7626

A related trap. When no rows match, COUNT returns zero, but SUM returns NULL. So wrap SUM in COALESCE when the report must show a zero.

SQL - empty set
SELECT COUNT(*)                 AS orders,
       SUM(amount)              AS total,
       COALESCE(SUM(amount), 0) AS total_safe
FROM orders
WHERE status = 'Returned';
Output (PostgreSQL 16)1 row
orderstotaltotal_safe
0NULL0

In an interview, say which count you mean. Rows, non-NULL values, or unique values. That one sentence shows you know the difference.

Interview tip

Say which one you mean: rows, non-NULL values or unique values.

Question 3: The operations manager wants one row per customer, with the number of delivered, shipped and cancelled orders side by side. How do you write it in PostgreSQL?

Say this first

Use an aggregate with the FILTER clause. Each COUNT(*) FILTER (WHERE ...) counts only the rows that pass its own condition, so one GROUP BY builds all the columns in one pass.

Take Sara Khan's three orders. Each aggregate looks at the same group of rows, but through its own filter. The delivered filter lets one row in. The shipped filter lets one row in. And the cancelled filter lets one row in. Three counts, one scan of the table.

Sara Khan · orders
order_idstatus
1001Delivered
1004Cancelled
1010Shipped
one group
FILTER (WHERE status = 'Delivered')
1
FILTER (WHERE status = 'Shipped')
1
FILTER (WHERE status = 'Cancelled')
1

Here is the full query. Three counts, each with its own FILTER. Every customer gets one row, and Chen Wu, who never ordered, shows zeros.

SQL - status counts
SELECT c.name,
  COUNT(*) FILTER (WHERE o.status = 'Delivered') AS delivered,
  COUNT(*) FILTER (WHERE o.status = 'Shipped')   AS shipped,
  COUNT(*) FILTER (WHERE o.status = 'Cancelled') AS cancelled
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name
ORDER BY c.customer_id;
Output (PostgreSQL 16)7 rows
namedeliveredshippedcancelled
Sara Khan111
Omar Ali200
Lina Park101
Mei Chen210
Ravi Patel100
Chen Wu000
Sara K.100

FILTER is standard SQL, but not every database has it. The portable version is SUM of a CASE expression. In Postgres, FILTER is shorter and easier to read.

Portable version:
SUM(CASE WHEN status = 'Shipped' THEN 1 ELSE 0 END)

Question 4: Your query fails with: column must appear in the GROUP BY clause or be used in an aggregate function. Why does Postgres say that, and why does the same query work when you group by the primary key?

Say this first

A group has many rows, but the result shows one. For a column that is not grouped and not aggregated, Postgres cannot know which value to show. Grouping by the primary key is the exception, because then every other column has exactly one value.

Group the customers by city. Toronto has two rows: Sara Khan and Sara K. The count is easy: two. But which name should Postgres print for Toronto? There are two candidates, and no rule to pick one, so it refuses.

customers
namecity
Sara KhanToronto
Sara K.Toronto
Omar AliDallas
Mei ChenBoston
GROUP BY city
one row per city
cityCOUNT(*)name
Toronto2???
Dallas1Omar Ali
Boston1Mei Chen
Two names in the group, one cell in the result: which one? Postgres will not guess.

Here is the failing query. Name is selected, but it is not in GROUP BY and not inside an aggregate. And this is the real error.

SQL - group by error
SELECT city, name, COUNT(*) AS customers
FROM customers
GROUP BY city;
Error (PostgreSQL 16)ERROR: column "customers.name" must appear in the GROUP BY clause or be used in an aggregate function

Now group by customer ID, the primary key. Name and city are not in GROUP BY, but the query runs. Postgres knows one customer ID means exactly one name and one city. This is called functional dependency.

SQL - group by pk
SELECT c.customer_id, c.name, c.city,
       SUM(o.amount) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id
ORDER BY c.customer_id;
Output (PostgreSQL 16)6 rows
customer_idnamecitytotal
1Sara KhanToronto1600.00
2Omar AliDallas375.00
3Lina ParkSeattle1600.00
4Mei ChenBoston1175.00
5Ravi PatelAustin400.00
7Sara K.Toronto50.00

So you have three legal choices: add the column to GROUP BY, wrap it in an aggregate like MIN or string_agg, or group by the table's primary key.

Interview tip

Fix it: group the column, aggregate it, or group by the primary key.

Question 5: Support wants one line per order that lists its products, separated by commas, in alphabetical order. How do you do it in PostgreSQL?

Say this first

Use string_agg with GROUP BY. It joins the values of a group into one string, and an ORDER BY inside the aggregate controls the order of the list.

Order 1001 has two item rows. A laptop and a keyboard. string_agg collects the product names of the group, sorts them because we asked it to, and glues them together with a comma. Two rows become one line.

order_items + products
order_idproduct
1001Laptop
1001Keyboard
1003Desk
1003Chair
GROUP BY order_id
string_agg(product, ', ' ORDER BY product)
one line per order
order_idproducts
1001Keyboard, Laptop
1003Chair, Desk

Here is the query on all the orders. The ORDER BY sits inside the parentheses of string_agg. Order 1001 shows Keyboard, then Laptop, in alphabetical order.

SQL - products per order
SELECT oi.order_id,
       string_agg(p.product, ', ' ORDER BY p.product) AS products,
       COUNT(*) AS items
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY oi.order_id
ORDER BY oi.order_id;
Output (PostgreSQL 16)12 rows
order_idproductsitems
1001Keyboard, Laptop2
1002Monitor1
1003Chair, Desk2
1004Keyboard1
1005Laptop1
1006Chair, Mouse2
1007Desk1
1008Mouse1
1009Chair1
1010Monitor1
1011Desk1
1012Keyboard1

Without that inner ORDER BY, the order of the list is not guaranteed, and it can change from one run to the next. The same pattern works with array_agg for an array, and jsonb_agg for JSON.

Interview tip

No inner ORDER BY = no guaranteed order. Also: array_agg, jsonb_agg.

Question 6: Finance wants sales by category and product, with a subtotal for each category and a grand total at the bottom, all from one query. How do you write it?

Say this first

Use GROUP BY ROLLUP. It runs the same aggregate at several levels: each product, each category, and the whole table. The subtotal rows show NULL in the columns that were rolled up.

ROLLUP of category and product means three levels of grouping. First, category and product: the detail rows. Then category alone: one subtotal per category. Then nothing at all: one grand total. Postgres stacks all three results into one answer.

GROUP BY ROLLUP (category, product)
(category, product)detail rows
categoryproductsales
FurnitureChair750.00
FurnitureDesk1200.00
(category)subtotal per category
categoryproductsales
FurnitureNULL1950.00
( )grand total
categoryproductsales
NULLNULL5200.00

Here is the real query. The detail rows come first. A row with a NULL product is the subtotal of its category. And the last row, with both columns NULL, is the grand total.

SQL - sales rollup
SELECT p.category, p.product,
       SUM(oi.qty * oi.unit_price) AS sales
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY ROLLUP (p.category, p.product)
ORDER BY p.category, p.product;
Output (PostgreSQL 16)10 rows
categoryproductsales
AccessoriesKeyboard150.00
AccessoriesMouse100.00
AccessoriesNULL250.00
ElectronicsLaptop2400.00
ElectronicsMonitor600.00
ElectronicsNULL3000.00
FurnitureChair750.00
FurnitureDesk1200.00
FurnitureNULL1950.00
NULLNULL5200.00

One caution. If the data itself can contain NULL, you cannot tell a real NULL from a subtotal. Use the GROUPING function for that. It returns 1 when the column was rolled up.

Interview tip

GROUPING(product) = 1 marks a subtotal row. Do not rely on NULL alone.

Question 7: The manager asks for the typical order amount. The average looks too high, because two big orders pull it up. What do you report, and how do you calculate it in PostgreSQL?

Say this first

Report the median, the middle value. Postgres has no MEDIAN function, so use percentile_cont(0.5) WITHIN GROUP (ORDER BY amount).

Sort the 12 order amounts from small to large. Here they are. The median is the middle of the line. With 12 values, that is halfway between the sixth and the seventh, so 300. The average is different. The two large orders on the right pull it up to 433.

orders.amount, sorted
50
50
75
250
275
300
300
400
400
650
1200
1250
Median = 300the middle value: half the orders are below it
Average = 433.33pulled up by 1200 and 1250

Here is the query. percentile_cont with 0.5 is the median. It is an ordered-set aggregate, so the ORDER BY goes inside WITHIN GROUP. The average is 433.33, but the median is only 300.

SQL - avg vs median
SELECT ROUND(AVG(amount), 2) AS average,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) AS median
FROM orders;
Output (PostgreSQL 16)1 row
averagemedian
433.33300

It works with GROUP BY as well. Here is the median per status. Notice the 287.5 for shipped orders. percentile_cont can return a value that is not in the table, because it interpolates between the two middle rows. If you need a real value from the data, use percentile_disc.

SQL - median by status
SELECT status, COUNT(*) AS orders,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) AS median
FROM orders
GROUP BY status
ORDER BY status;
Output (PostgreSQL 16)3 rows
statusordersmedian
Cancelled2225
Delivered8350
Shipped2287.5

So for skewed numbers like order amounts, salaries or response times, report the median, or a percentile, next to the average.

Interview tip

Skewed data: report the median or a percentile, not only the average.

Key points to remember

  • WHERE filters rows before grouping, HAVING filters groups after it
  • COUNT(*) counts rows, COUNT(column) skips NULLs, COUNT(DISTINCT) counts unique values
  • FILTER (WHERE ...) gives conditional aggregates in one pass
  • Every selected column must be grouped, aggregated, or depend on a grouped primary key
  • ROLLUP adds subtotals, and percentile_cont(0.5) gives the median

The full SQL script

Here is everything in one script: the sample data and every query from this post, in order.

Show the full SQL script (sample data and every query)
Full script - copy and run top to bottom
-- ========================================================================
-- PostgreSQL Interview Q&A #2 - GROUP BY, HAVING and FILTER
-- TechBrothersIT - run on PostgreSQL 16
-- ========================================================================

-- Sample data
CREATE TABLE departments (dept_id INT PRIMARY KEY, dept_name VARCHAR(20));
INSERT INTO departments VALUES (10, 'Sales'), (20, 'Engineering'), (30, 'HR'), (40, 'Finance');

CREATE TABLE employees (
  emp_id     INT PRIMARY KEY,
  name       VARCHAR(20),
  dept_id    INT,            -- NULL = not assigned yet
  manager_id INT,            -- NULL = top of the company
  salary     INT,
  hire_date  DATE
);
INSERT INTO employees VALUES
  (1, 'Aisha',  NULL, NULL, 150000, '2018-03-01'),
  (2, 'Omar',   20,   1,    120000, '2019-06-15'),
  (3, 'Lina',   20,   2,     90000, '2021-01-10'),
  (4, 'Ravi',   20,   2,     90000, '2022-04-05'),
  (5, 'Mei',    20,   2,     75000, '2024-09-01'),
  (6, 'Sara',   10,   1,     95000, '2020-02-20'),
  (7, 'Daniel', 10,   6,    105000, '2021-07-01'),
  (8, 'Fatima', 10,   6,     60000, '2023-11-12'),
  (9, 'John',   30,   1,     70000, '2019-09-30'),
  (10,'Zara',   NULL, 9,     55000, '2026-01-05');

CREATE TABLE customers (
  customer_id INT PRIMARY KEY,
  name        VARCHAR(20),
  city        VARCHAR(20),
  region      VARCHAR(10),    -- NULL = unknown
  email       VARCHAR(40)
);
INSERT INTO customers VALUES
  (1, 'Sara Khan',  'Toronto', 'East', 'sara@mail.com'),
  (2, 'Omar Ali',   'Dallas',  'West', 'omar@mail.com'),
  (3, 'Lina Park',  'Seattle', 'West', 'lina@mail.com'),
  (4, 'Mei Chen',   'Boston',  'East', 'mei@mail.com'),
  (5, 'Ravi Patel', 'Austin',  NULL,   'ravi@mail.com'),
  (6, 'Chen Wu',    'Denver',  'West', 'chen@mail.com'),
  (7, 'Sara K.',    'Toronto', 'East', 'sara@mail.com');

CREATE TABLE products (product_id INT PRIMARY KEY, product VARCHAR(20), category VARCHAR(15), price NUMERIC(10,2));
INSERT INTO products VALUES
  (1, 'Laptop',   'Electronics', 1200.00),
  (2, 'Monitor',  'Electronics',  300.00),
  (3, 'Keyboard', 'Accessories',   50.00),
  (4, 'Mouse',    'Accessories',   25.00),
  (5, 'Desk',     'Furniture',    400.00),
  (6, 'Chair',    'Furniture',    250.00);

CREATE TABLE orders (
  order_id    INT PRIMARY KEY,
  customer_id INT,
  order_date  DATE,
  status      VARCHAR(10),
  amount      NUMERIC(10,2)
);
INSERT INTO orders VALUES
  (1001, 1, '2026-01-05', 'Delivered', 1250.00),
  (1002, 2, '2026-01-18', 'Delivered',  300.00),
  (1003, 4, '2026-02-02', 'Delivered',  650.00),
  (1004, 1, '2026-02-14', 'Cancelled',   50.00),
  (1005, 3, '2026-02-20', 'Delivered', 1200.00),
  (1006, 4, '2026-03-03', 'Shipped',    275.00),
  (1007, 5, '2026-03-15', 'Delivered',  400.00),
  (1008, 2, '2026-04-01', 'Delivered',   75.00),
  (1009, 4, '2026-04-22', 'Delivered',  250.00),
  (1010, 1, '2026-05-09', 'Shipped',    300.00),
  (1011, 3, '2026-05-30', 'Cancelled',  400.00),
  (1012, 7, '2026-06-11', 'Delivered',   50.00);

CREATE TABLE order_items (order_id INT, product_id INT, qty INT, unit_price NUMERIC(10,2));
INSERT INTO order_items VALUES
  (1001, 1, 1, 1200.00), (1001, 3, 1, 50.00),
  (1002, 2, 1, 300.00),
  (1003, 5, 1, 400.00), (1003, 6, 1, 250.00),
  (1004, 3, 1, 50.00),
  (1005, 1, 1, 1200.00),
  (1006, 6, 1, 250.00), (1006, 4, 1, 25.00),
  (1007, 5, 1, 400.00),
  (1008, 4, 3, 25.00),
  (1009, 6, 1, 250.00),
  (1010, 2, 1, 300.00),
  (1011, 5, 1, 400.00),
  (1012, 3, 1, 50.00);

CREATE TABLE payments (payment_id INT PRIMARY KEY, order_id INT, paid_amount NUMERIC(10,2));
INSERT INTO payments VALUES
  (1, 1001, 1250.00), (2, 1002, 300.00),
  (3, 1003,  400.00), (4, 1003, 250.00),   -- order 1003 was paid in two parts
  (5, 1005, 1200.00), (6, 1007, 400.00),
  (7, 1008,   75.00), (8, 1009, 250.00), (9, 1012, 50.00);

-- Step 1 (question 1): where and having
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE status = 'Delivered'
GROUP BY customer_id
HAVING SUM(amount) > 500
ORDER BY total DESC;

-- Step 2 (question 1): sum in where
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE SUM(amount) > 500
GROUP BY customer_id;

-- Step 3 (question 2): three counts
SELECT COUNT(*)               AS all_rows,
       COUNT(region)          AS with_region,
       COUNT(DISTINCT region) AS regions,
       COUNT(DISTINCT email)  AS emails
FROM customers;

-- Step 4 (question 2): empty set
SELECT COUNT(*)                 AS orders,
       SUM(amount)              AS total,
       COALESCE(SUM(amount), 0) AS total_safe
FROM orders
WHERE status = 'Returned';

-- Step 5 (question 3): status counts
SELECT c.name,
  COUNT(*) FILTER (WHERE o.status = 'Delivered') AS delivered,
  COUNT(*) FILTER (WHERE o.status = 'Shipped')   AS shipped,
  COUNT(*) FILTER (WHERE o.status = 'Cancelled') AS cancelled
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name
ORDER BY c.customer_id;

-- Step 6 (question 4): group by error
SELECT city, name, COUNT(*) AS customers
FROM customers
GROUP BY city;

-- Step 7 (question 4): group by pk
SELECT c.customer_id, c.name, c.city,
       SUM(o.amount) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id
ORDER BY c.customer_id;

-- Step 8 (question 5): products per order
SELECT oi.order_id,
       string_agg(p.product, ', ' ORDER BY p.product) AS products,
       COUNT(*) AS items
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY oi.order_id
ORDER BY oi.order_id;

-- Step 9 (question 6): sales rollup
SELECT p.category, p.product,
       SUM(oi.qty * oi.unit_price) AS sales
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY ROLLUP (p.category, p.product)
ORDER BY p.category, p.product;

-- Step 10 (question 7): avg vs median
SELECT ROUND(AVG(amount), 2) AS average,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) AS median
FROM orders;

-- Step 11 (question 7): median by status
SELECT status, COUNT(*) AS orders,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) AS median
FROM orders
GROUP BY status
ORDER BY status;
Next in this series

Part 3: Window functions

The sample data on this page is invented for teaching. Every output table and query plan is the real result of the query above it, run on PostgreSQL 16 over that sample data.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.