Real-world data is split across multiple tables. Joining the tables and then applying GROUP BY lets you aggregate children (orders) for each parent (customer). Use LEFT JOIN when parents with no children must remain.
-- Left-join orders onto customers, then aggregate SELECT c.name, COUNT(o.order_id), SUM(o.amount) FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name;
COUNT(*) counts that row, but COUNT(o.order_id) ignores NULL and correctly returns zero. Use COALESCE(SUM(...), 0) to turn a NULL total into zero.Join customers and orders, then calculate the number of orders and total amount for each customer. Keep customers with no orders as zero orders and a zero total. Return name, order_count, total_amount, ordered by total_amount descending and name ascending for ties.
| customer_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| order_id | customer_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 2 | 3000 |
| 104 | 2 | 1500 |
| 105 | 2 | 500 |
Carol (3) has no orders; the LEFT JOIN must retain her with values of zero.
Expected output (total_amount descending):
| name | order_count | total_amount |
|---|---|---|
| Bob | 3 | 5000 |
| Alice | 2 | 2000 |
| Carol | 0 | 0 |
Carol remains with zero orders and a zero total; an INNER JOIN would remove her.
SELECT c.name, COUNT(o.order_id) AS order_count, -- count only non-NULL order rows COALESCE(SUM(o.amount), 0) AS total_amount -- replace a NULL sum with 0 FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id -- preserve customers with no orders GROUP BY c.customer_id, c.name -- one group per customer ORDER BY total_amount DESC, c.name; -- highest total first; name breaks ties /* Logical order: JOIN → GROUP BY → COUNT/SUM → ORDER BY. */
LEGEND
① FROM customers LEFT JOIN orders
FROM customers LEFT JOIN ordersLeft-join orders onto customers. Carol has no match, so order_id and amount are NULL in her placeholder row.| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 1200 |
| 1 | Alice | 102 | 800 |
| 2 | Bob | 103 | 3000 |
| 2 | Bob | 104 | 1500 |
| 2 | Bob | 105 | 500 |
| 3 | Carol | NULL | NULL |
COUNT(*) counts that row as one, whereas COUNT(o.order_id) ignores NULL and reports zero. When counting children, count a column from the child table.SUM over a group with no child values returns NULL, not zero. COALESCE(SUM(o.amount), 0) converts that NULL so “no orders” appears as the intuitive total of zero.COUNT(o.order_id) excludes that row and correctly returns zero.To summarize daily records by month or week, truncate each date to the chosen granularity with DATE_TRUNC and GROUP BY that expression. The important point is that GROUP BY can use a computed expression, not just a stored column.
-- Map dates to their month start so rows in the same month share one key DATE_TRUNC('month', sale_date) -- 2024-01-05 → 2024-01-01 DATE_TRUNC('day', ts) -- remove the time portion
GROUP BY 1.From sales, calculate the number of sales and total amount for each month. Return month, sales_count, total_amount in ascending month order.
| sale_id | sale_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
DATE_TRUNC maps each date to the first day of its month.
Expected output (month ascending):
| month | sales_count | total_amount |
|---|---|---|
| 2024-01-01 | 2 | 2500 |
| 2024-02-01 | 3 | 3500 |
| 2024-03-01 | 1 | 3000 |
Six rows collapse into three groups: January 2, February 3, and March 1.
SELECT DATE_TRUNC('month', sale_date) AS month, -- month-start grouping key COUNT(*) AS sales_count, -- sales in the month SUM(amount) AS total_amount -- amount in the month FROM sales GROUP BY DATE_TRUNC('month', sale_date) -- same expression as SELECT ORDER BY month; -- chronological order /* Logical order: FROM → DATE_TRUNC → GROUP BY → aggregate → ORDER BY. */
LEGEND
① FROM sales (6 rows)
FROM salesRead six daily sales rows before mapping their dates to months.| sale_id | sale_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
DATE_TRUNC('month', sale_date) creates a reporting dimension that does not exist in the source data. This is what makes monthly and weekly aggregation possible.'month' to 'week', 'day', or 'quarter' to change the reporting granularity.GROUP BY sale_date creates separate groups whenever the dates differ, producing daily rather than monthly results. Group by the truncated expression instead.TO_CHAR(sale_date, 'MM') can mix the same month from different years and can sort chronologically incorrect as text. Group by the date-typed DATE_TRUNC result including the year.TO_CHAR(month, 'YYYY-MM'), but keep the date value for sorting and joins so chronological order remains correct.To summarize a continuous value by ranges such as price bands or age groups, create a category with CASE and GROUP BY that expression.
-- Assign each price to one of three bands CASE WHEN price < 500 THEN 'Low' WHEN price < 5000 THEN 'Mid' -- 500–4999 because the first branch already failed ELSE 'High' END
From products, calculate the product count and average price for each price band: Low below 500, Mid from 500 through 4999, and High from 5000. Return price_band, product_count, avg_price, rounding the average to an integer and sorting by avg_price ascending.
| product_id | product | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 300 |
| 3 | Bag | 1500 |
| 4 | Lamp | 2500 |
| 5 | Chair | 8000 |
| 6 | Desk | 12000 |
The localized products form two rows in each band: Pen/Notebook, Bag/Lamp, and Chair/Desk.
Expected output (avg_price ascending):
| price_band | product_count | avg_price |
|---|---|---|
| Low | 2 | 200 |
| Mid | 2 | 2000 |
| High | 2 | 10000 |
Low=(100+300)/2=200, Mid=(1500+2500)/2=2000, High=(8000+12000)/2=10000.
SELECT CASE WHEN price < 500 THEN 'Low' -- below 500 WHEN price < 5000 THEN 'Mid' -- 500 through 4999 ELSE 'High' -- 5000 or more END AS price_band, COUNT(*) AS product_count, -- products in each price band ROUND(AVG(price), 0) AS avg_price -- average price per band, rounded to an integer FROM products GROUP BY CASE WHEN price < 500 THEN 'Low' WHEN price < 5000 THEN 'Mid' ELSE 'High' END -- repeat the SELECT CASE expression; GROUP BY price_band also works in PostgreSQL ORDER BY avg_price; -- lowest average price first /* Logical order: 1. FROM products → read the rows 2. CASE → calculate price_band for every row 3. GROUP BY the CASE result → form one group per price band 4. COUNT(*) / ROUND(AVG(price), 0) → calculate count and average 5. ORDER BY avg_price → sort by average price */
LEGEND
① FROM products (6 rows)
FROM productsRead six localized products with continuous price values.| product_id | product | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 300 |
| 3 | Bag | 1500 |
| 4 | Lamp | 2500 |
| 5 | Chair | 8000 |
| 6 | Desk | 12000 |
price < 5000 branch means 500–4999 because values below 500 were already handled by the first branch. Boundary order determines the result.GROUP BY price_band or GROUP BY 1, which can avoid duplicating a long CASE expression.WHEN price < 5000 THEN 'Mid' comes first, the 100 and 300 values that belong in Low are absorbed into Mid and the Low group disappears. Put narrower, lower boundaries first.>= and > boundaries, or a missing ELSE, can leave rows unclassified or count them twice in separately written conditions. Design bands to be mutually exclusive and collectively exhaustive.width_bucket(); frequently reused business bands can become generated columns.“Has the same email address been registered more than once?” Duplicate detection is a classic use of grouping. GROUP BY the column to inspect, then keep only groups containing at least two rows with HAVING COUNT(*) > 1.
-- Count each email and keep counts of two or more: the duplicates SELECT email, COUNT(*) AS cnt FROM users GROUP BY email HAVING COUNT(*) > 1;
GROUP BY col_a, col_b.Return each email registered more than once and its count from users. Return email, cnt, ordered by cnt descending and email ascending.
| user_id | |
|---|---|
| 1 | a@x.com |
| 2 | b@x.com |
| 3 | a@x.com |
| 4 | c@x.com |
| 5 | b@x.com |
| 6 | a@x.com |
The localized addresses occur 3, 2, and 1 times; the single occurrence is not a duplicate.
Expected output (cnt descending):
| cnt | |
|---|---|
| a@x.com | 3 |
| b@x.com | 2 |
The address occurring once fails HAVING and is excluded.
SELECT email, COUNT(*) AS cnt -- registrations per email FROM users GROUP BY email -- group the value that should be unique HAVING COUNT(*) > 1 -- duplicate groups only ORDER BY cnt DESC, email; -- highest count first; email breaks ties /* Logical order: 1. FROM users → read the rows 2. GROUP BY email → form one group per email 3. COUNT(*) → count each group 4. HAVING COUNT(*) > 1 → keep duplicate groups only 5. ORDER BY cnt DESC, email → sort by duplicate count */
LEGEND
① FROM users (6 rows)
FROM usersRead six rows; two localized email values repeat.| user_id | |
|---|---|
| 1 | a@x.com |
| 2 | b@x.com |
| 3 | a@x.com |
| 4 | c@x.com |
| 5 | b@x.com |
| 6 | a@x.com |
HAVING COUNT(*) > 1. The returned count shows exactly how many rows share each value, making the result directly useful for data-quality checks.COUNT(*) does not exist until after grouping and aggregation. Raw-row predicates belong in WHERE; aggregate predicates belong in HAVING.GROUP BY name, birth_date HAVING COUNT(*) > 1. It simply combines multi-column grouping with HAVING.SELECT DISTINCT email collapses repeated values but does not reveal which values repeat or how often. Use GROUP BY plus HAVING when the goal is to detect and quantify duplicates.STRING_AGG(user_id::text, ', '). That gives a practical data-cleaning sequence: detect the duplicate, identify its rows, then fix them.The final aggregation pattern in this set aggregates text rather than numbers. STRING_AGG concatenates multiple values in a group into one delimited string, allowing a list of the group's members to fit in one row.
STRING_AGG(student, ', ' ORDER BY student) -- concatenate names alphabetically STRING_AGG(DISTINCT tag, ',') -- remove duplicates ARRAY_AGG(student) -- aggregate into an array instead of text
From enrollments, calculate each course's student count and an alphabetically ordered, comma-separated student list. Return course, student_count, students, ordered by student_count descending and course ascending for ties.
| id | course | student |
|---|---|---|
| 1 | Math | Bob |
| 2 | Math | Alice |
| 3 | Math | Carol |
| 4 | Science | Dave |
| 5 | Science | Alice |
| 6 | Art | Bob |
The localized courses contain 3, 2, and 1 students.
Expected output (student_count descending):
| course | student_count | students |
|---|---|---|
| Math | 3 | Alice, Bob, Carol |
| Science | 2 | Alice, Dave |
| Art | 1 | Bob |
Math input order Bob, Alice, Carol becomes Alice, Bob, Carol.
SELECT course, COUNT(*) AS student_count, -- students per course STRING_AGG(student, ', ' ORDER BY student) AS students -- alphabetical name list FROM enrollments GROUP BY course -- one group per course ORDER BY student_count DESC, course; -- largest course first; course breaks ties /* Logical order: 1. FROM enrollments → read the rows 2. GROUP BY course → form one group per course 3. COUNT / STRING_AGG → calculate the count and ordered name list 4. ORDER BY student_count DESC, course → sort by enrollment size */
LEGEND
① FROM enrollments (6 rows)
FROM enrollmentsRead six localized enrollments, one per student-course registration.| id | course | student |
|---|---|---|
| 1 | Math | Bob |
| 2 | Math | Alice |
| 3 | Math | Carol |
| 4 | Science | Dave |
| 5 | Science | Alice |
| 6 | Art | Bob |
STRING_AGG(student, ', ' ORDER BY student), the inner ORDER BY determines the name sequence. Without it the order is unspecified and may change between executions. The second argument, ', ', is the delimiter.STRING_AGG(DISTINCT student, ', ') to remove duplicates before concatenation, or ARRAY_AGG(student) when the caller needs an array rather than a string.STRING_AGG(student, ', ') does not guarantee a sequence. If sequence carries meaning, always put ORDER BY inside the aggregate.STRING_AGG(user_id::text, ', ') returns both each duplicate email and the IDs of its actual records. GROUP BY can now combine counts, sums, conditional aggregates, joins, periods, bins, and text aggregation to produce most practical summaries from detail data.