Hook: GROUP BY is the SQL way to turn a messy pile of rows into tidy stacks — interviewers love it because it shows whether you understand how SQL thinks about groups, not just records.
Question: What does GROUP BY do in SQL?
Answer: GROUP BY collects rows that share the same values in one or more columns, then lets aggregate functions like COUNT, SUM, AVG, MIN, and MAX run once per group. It is how you ask questions like 'sales per day' or 'orders per customer' instead of looking at every row separately.
Interview-Ready Answer: I use GROUP BY when I need one result row per category instead of one row per record. It first groups rows by the chosen columns, then aggregates each group, so a query like 'total sales by region' becomes one row for each region. A key detail is that WHERE filters rows before grouping, while HAVING filters groups after aggregation.
GROUP BY changes the shape of your result. Instead of returning every row, SQL builds buckets of rows that share the same key values, then calculates one output row per bucket. If you group 10,000 sales rows by region, you might get only 5 final rows back.
FROM and joins them if needed.WHERE removes rows that should never be considered. This happens before groups exist.GROUP BY forms groups using the grouping key, which can be one column, multiple columns, or an expression like COALESCE(region, 'Unknown').SUM(amount) adds values inside each bucket, and COUNT(*) counts rows in that bucket.HAVING filters whole groups after aggregation. This is where you keep only groups whose total is big enough.SELECT returns the grouped columns and aggregate results, and ORDER BY can sort the final summary.Important mental model: grouping is not sorting. The engine may use hashing or sorting internally, but the SQL result is not guaranteed to come out in grouped order unless you add ORDER BY.
| Feature | What it does | Result shape |
|---|---|---|
GROUP BY | Makes buckets | One row per group |
DISTINCT | Removes duplicate rows | Unique rows only |
WHERE | Filters raw rows | Fewer input rows |
HAVING | Filters grouped rows | Fewer groups |
DISTINCT can look similar to GROUP BY when you only select the grouping columns, but it cannot compute summaries. If you need totals, averages, or counts, you need GROUP BY.
Most SQL engines use one of two main strategies. Hash aggregation builds an in-memory hash table keyed by the grouping columns; on average it is close to O(n) time, with memory around O(g) where g is the number of groups. Sort aggregation sorts rows by the grouping key first; that is typically O(n log n), but it can be efficient if the data is already ordered or if an index helps.
Realistically, grouping 1,000,000 rows into 12 months is cheap. Grouping 1,000,000 rows into 900,000 unique user IDs is much heavier because the engine has almost as many groups as rows. If memory is too small, the engine may spill to disk, which slows the query a lot.
NULL in the same grouping column belong to one group together.COUNT(*) vs COUNT(column): COUNT(*) counts rows, while COUNT(column) ignores NULL values.DATE(order_ts) or COALESCE(...) groups by the computed value, not the raw column.Real-World Example: Imagine a checkout service in an e-commerce app that powers a finance dashboard. Every hour, it runs a report for revenue by region and payment status using GROUP BY, and the business team checks whether sales are trending up or down.
One week, an engineer moved a filter from HAVING to WHERE because it looked cleaner. The query now removed rows before the sums were built, so regions with lots of small orders disappeared from the dashboard. The symptom was a sudden drop in reported revenue, logs showing fewer input rows than expected, and finance asking why the daily total no longer matched the payment gateway.
What went wrong: the team filtered the raw data instead of the grouped totals. The fix was to keep row-level filters in WHERE, move total-based rules to HAVING, and add an ORDER BY so the report was stable and easy to read.
-- GROUP BY demo: summarize rows into one result per category.
-- This script is written to run as-is in SQLite and most SQL engines with standard syntax.
CREATE TABLE sales (
order_id INTEGER PRIMARY KEY,
region TEXT,
amount INTEGER,
status TEXT
);
INSERT INTO sales (order_id, region, amount, status) VALUES
(1, 'East', 120, 'paid'),
(2, 'East', 80, 'paid'),
(3, 'West', 200, 'paid'),
(4, 'West', NULL, 'refunded'),
(5, NULL, 50, 'paid'),
(6, 'North', 150, 'paid'),
(7, 'North', 150, 'paid');
-- One row per region.
-- COUNT(*) counts all rows in the group.
-- COUNT(amount) ignores NULL amounts, which is a common gotcha.
SELECT
COALESCE(region, 'Unknown') AS region_label,
COUNT(*) AS orders,
COUNT(amount) AS priced_orders,
SUM(amount) AS revenue
FROM sales
GROUP BY COALESCE(region, 'Unknown')
ORDER BY region_label;
-- HAVING filters groups after the sum is computed.
-- If you needed the same filter in WHERE, it would be wrong because WHERE runs before grouping.
SELECT
COALESCE(region, 'Unknown') AS region_label,
SUM(amount) AS revenue
FROM sales
GROUP BY COALESCE(region, 'Unknown')
HAVING SUM(amount) >= 200
ORDER BY revenue DESC;
-- Edge case note:
-- The row with region = NULL becomes its own group, labeled 'Unknown' here by COALESCE.
-- The row with amount = NULL is still counted by COUNT(*), but not by COUNT(amount).
Follow-up & Tricky Questions:
GROUP BY different from DISTINCT? DISTINCT removes duplicate rows, while GROUP BY creates buckets so aggregates can be calculated. If you only need unique values, DISTINCT is simpler; if you need totals or counts, use GROUP BY.HAVING used for? HAVING filters after grouping, so it is the right place for conditions like SUM(amount) > 1000 or COUNT(*) > 5. It is the post-aggregation cousin of WHERE.GROUP BY? In standard SQL, no, unless it is wrapped in an aggregate. Many modern databases enforce this strictly; older MySQL modes could return an arbitrary value, which is dangerous and non-portable.NULL values? NULLs in the grouping key are grouped together as one bucket. Inside aggregates, COUNT(column) ignores NULL, while COUNT(*) still counts the row.GROUP BY sort the output? No. It groups rows, but output order is undefined unless you add ORDER BY. Some engines may appear to return sorted results, but you should never rely on that.SUM(amount) OVER (PARTITION BY region). That keeps each original row and adds a per-group summary beside it.WHERE be used with aggregates? No, because WHERE runs before aggregation. A condition based on a group total belongs in HAVING.GROUP BY work on expressions? Yes. You can group by computed values like dates, buckets, or COALESCE results, which is common in reporting queries.COUNT(column) count NULL values? No. It counts only non-NULL values, so it can be smaller than COUNT(*) even inside the same group.WHERE SUM(amount) > 100? No. Aggregate results do not exist yet in WHERE; the correct clause is HAVING.Common Mistakes:
WHERE for group totals. Correction: use HAVING when the condition depends on an aggregate result.GROUP BY sorts results. Correction: add ORDER BY if you care about the display order.NULL behavior. Correction: remember that grouping puts NULLs together, and COUNT(column) skips them.Memory Hook: Think of GROUP BY as a shelf sorter: first pile identical items together, then count or sum each pile. One pile, one answer.
Cheat Sheet:
GROUP BY = one row per group.WHERE filters before grouping.HAVING filters after grouping.COUNT(*) counts rows; COUNT(col) ignores NULL.NULLs in the key form one group together.ORDER BY if you need stable output order.Practice Tasks:
orders table by status and count how many orders are in each status.HAVING to keep only customers with more than 3 orders.NULL regions show up as 'Unknown' using COALESCE.