Two-column GROUP BY, and HAVING
New file for this lesson. Ninety support tickets from three months of a fictional help desk, with a channel, a priority, a category, two timing columns and a satisfaction score that is sometimes missing. It is a better shape than the sales file for what this lesson is about, because the interesting questions are all about crossing one category with another.
| ticket_id | created_date | channel | priority | category | first_response_min | resolution_min | status | satisfaction |
|---|---|---|---|---|---|---|---|---|
| T-1001 | 2025-03-01 | Phone | Normal | Bug | 102 | 1845 | Open | |
| T-1002 | 2025-03-02 | Phone | Urgent | How-to | 17 | 2304 | Pending | |
| T-1003 | 2025-03-03 | Normal | Billing | 178 | 732 | Resolved | 2 | |
| T-1004 | 2025-03-04 | Phone | High | Feature request | 65 | 1052 | Closed | 2 |
| T-1005 | 2025-03-05 | Chat | Low | How-to | 192 | 1258 | Resolved | 3 |
Note the empty cells in the last column. Twenty of the ninety tickets have no satisfaction score, because nobody rates a ticket that is still open. Those empty cells become NULL when the file is read, and they are going to matter before the end of this lesson.
Grouping by a pair
Add a second column to the GROUP BY and the buckets get finer. Three channels crossed with four priorities gives at most twelve buckets, and the query returns exactly the combinations that actually occur in the file rather than all twelve regardless. If nobody ever raised an urgent ticket by chat, that row simply is not in the output.
That absence is worth pausing on, because it is the difference between a SQL result and a spreadsheet pivot. A pivot draws the full grid and leaves blanks. SQL returns rows that exist. When a dashboard needs the empty combinations shown as zero, you have to manufacture them, and the usual tool is a cross join against the list of categories.
The columns in a GROUP BY have no privileged order. Grouping by channel and priority produces the same set of rows as grouping by priority and channel; only the column order in the output changes, and only if you also swap them in the select list. Order the select list the way the reader will scan it.
HAVING filters groups
You cannot put an aggregate in a WHERE clause.
WHERE COUNT(*) >= 10 is an
error, and the reason is timing rather than fussiness: WHERE runs while the engine is still
looking at individual rows, before any bucket exists, so there is nothing to count yet.
HAVING is the same idea applied
after the grouping. It sits directly after GROUP BY and its condition is evaluated once per
bucket:
SELECT channel, priority, COUNT(*) AS tickets FROM csv GROUP BY channel, priority HAVING COUNT(*) >= 10 ORDER BY tickets DESC
Three combinations clear that bar on this file. HAVING is what you reach for whenever the question contains the words "at least", "more than" or "only the ones with", applied to a group rather than a row.
WHERE and HAVING in one query
They are not alternatives, and a query can sensibly use both:
SELECT category, COUNT(*) AS tickets FROM csv WHERE status = 'Resolved' GROUP BY category HAVING COUNT(*) > 8
Read it top to bottom and it is the order of operations: keep resolved tickets, bucket them by category, keep buckets with more than eight. Putting a row-level condition in HAVING would usually still give the right answer, but it makes the engine build buckets it is about to throw away, and on a large table that is real time. Filter early.
Nulls, one more time
The satisfaction column is where lesson 6 pays off.
COUNT(*) counts tickets;
COUNT(satisfaction) counts the
ones that were rated. Put both in one grouped query and the two columns side by side tell you
your response rate per status without a single explicit null check. On this file every Resolved
and Closed ticket is rated and no Open or Pending one is, which is exactly the pattern you would
hope to see and exactly the sort of thing worth verifying rather than assuming.
AVG(satisfaction) over the same
groups divides by the rated tickets only, which is almost certainly what you want. If somebody
asks why the average satisfaction is not the total divided by the ticket count, that is the
answer.
Reading a grouped result critically
One of the exercises below asks for average first response time by priority, and the answer is a clean ladder: urgent tickets are answered in minutes, low priority ones in hours. That is what a healthy support process looks like in data, and it is worth noticing that you can see the process working from four numbers.
It is also worth noticing what those four numbers hide. An average of eleven minutes could be every ticket at eleven minutes, or half at one minute and half at twenty-one. Aggregates are lossy by design, and the discipline is to remember what you gave up when you collapsed a hundred rows into four. A median and a maximum alongside the average cost nothing extra and tell you a much fuller story.
Exercises
1. Response time by priority
Return one row per priority with the average first response in minutes, rounded to two decimals, headed avg_first_response.
Hint
Single-column grouping, one rounded average. The ladder in the answer is the point.
2. The busy combinations
Return every channel and priority pair that has at least ten tickets, with the count headed tickets.
Hint
Two grouping columns, and the "at least" goes in HAVING, not WHERE.
3. The common categories
Return every category with more than fifteen tickets, with the count headed tickets. Strictly more than fifteen.
Hint
One grouping column this time, and the strict operator in the HAVING.
4. Rated and unrated
Return one row per status with the ticket count headed tickets and the number of tickets carrying a satisfaction score headed scored.
Hint
Two COUNTs that differ only in what is inside the brackets.
What to look up next
Look up FILTER (WHERE ...), the standard SQL clause that
DuckDB and Postgres both support. It lets you write
COUNT(*) FILTER (WHERE priority = 'Urgent')
beside a plain count in the same grouped query, which is far clearer than the CASE trick most
people learn first.