| |

SQL 30 🛢️ Grouping Data with GROUP BY

GROUP BY partitions the rows of a result set into groups and applies aggregate functions within each group. It is the mechanism that turns a detail table into a summary table, answering questions like “how many orders per customer,” “what is the average salary per department,” or “what is the total revenue per month.” Without GROUP BY, an aggregate function returns a single value for the entire result set; with GROUP BY, it returns one value per group.

The clause interacts with the rest of the query in specific ways. Every column in the SELECT list must either appear in the GROUP BY clause or be wrapped in an aggregate function — this is the “grouping rule” that most databases enforce. WHERE filters rows before grouping; HAVING filters groups after aggregation. Understanding these interactions, and the difference between grouping by a column, an expression, and a derived value, is essential for writing correct summary queries.

Key point: GROUP BY partitions rows by the values of the grouping columns. Every non-aggregated column in SELECT must be in GROUP BY. WHERE filters rows before grouping; HAVING filters groups after. The grouping columns form the unique key of the result.


Why GROUP BY exists

The per-category summary problem. A single total is rarely enough. Managers want totals per department, per region, per product, per month. GROUP BY provides the partitioning that makes per-category aggregation possible. It is the SQL mechanism for “for each X, compute Y.”

The grouping rule problem. A SELECT list that mixes aggregated and non-aggregated columns creates ambiguity: which value of the non-aggregated column should be returned for the group? Standard SQL resolves this by requiring every non-aggregated column to be a grouping column, so that each group has exactly one value for each such column. Databases that historically allowed the ambiguity (notably MySQL before 5.7) produced arbitrary values, which led to subtle bugs.

The filtering problem. Some conditions apply to individual rows (salary greater than 50000) and some apply to groups (average salary greater than 70000). WHERE handles the first; HAVING handles the second. The distinction matters because WHERE cannot reference aggregates, and HAVING runs after aggregation. Combining them in the right order is part of writing correct grouped queries.

The functional dependency problem. Some databases recognize functional dependencies: if a column is functionally determined by the grouping column (for example, a primary key and the other columns in the same table), the database may allow it in the SELECT list without grouping. PostgreSQL supports this optimization, which reduces the need to list every column in GROUP BY when grouping by a primary key.

The performance problem. GROUP BY requires either sorting the rows or building a hash table keyed by the grouping columns. Both are more expensive than a simple scan. The choice of grouping columns, the presence of indexes on those columns, and the size of the groups all affect performance. Understanding the execution model helps in designing queries and indexes.


a. Basic GROUP BY syntax

The clause appears after FROM and WHERE, before HAVING and ORDER BY.

SELECT department_id, COUNT(*) AS employee_count
FROM employees
GROUP BY department_id;

The result has one row per distinct value of department_id, with the count of employees in that department. Departments with no employees do not appear, because there are no rows to form a group. Including them requires an outer join from a departments table.

Grouping by multiple columns creates one group per distinct combination:

SELECT department_id, job_title, COUNT(*) AS count
FROM employees
GROUP BY department_id, job_title;

The result has one row for each (department, job title) pair that appears in the data. Combinations that do not appear are not in the result.

b. The grouping rule

Every column in the SELECT list must either be an aggregate or appear in the GROUP BY clause. This is the rule enforced by standard SQL and by most modern databases.

-- Valid: first_name is not in SELECT
SELECT department_id, COUNT(*) FROM employees GROUP BY department_id;

-- Invalid in standard SQL: first_name is neither grouped nor aggregated
SELECT department_id, first_name, COUNT(*)
FROM employees
GROUP BY department_id;

The error message varies by database but typically says something like “column first_name must appear in the GROUP BY clause or be used in an aggregate function.”

The rule exists because a group can contain multiple rows with different values for first_name, and SQL has no way to decide which one to return. To include a non-aggregated column, add it to GROUP BY:

SELECT department_id, job_title, COUNT(*)
FROM employees
GROUP BY department_id, job_title;

Now each group is uniquely identified by the combination of department and job title, and every row in a group has the same value for both.

c. Grouping by expressions

GROUP BY accepts expressions, not just column names. This allows grouping by derived values such as the year of a date, the length of a string, or the result of a CASE expression.

-- Orders per year
SELECT EXTRACT(YEAR FROM order_date) AS order_year,
       COUNT(*) AS order_count
FROM orders
GROUP BY EXTRACT(YEAR FROM order_date)
ORDER BY order_year;

The expression in SELECT must match the expression in GROUP BY for the query to be valid in most databases. Some databases allow grouping by the alias defined in SELECT, but this is non-standard and not portable.

-- Non-standard: grouping by alias
SELECT EXTRACT(YEAR FROM order_date) AS order_year, COUNT(*)
FROM orders
GROUP BY order_year;

PostgreSQL and MySQL allow this; SQL Server and Oracle do not. For portability, repeat the expression.

A CASE expression in GROUP BY creates custom buckets:

SELECT CASE
           WHEN salary < 50000 THEN 'low'
           WHEN salary < 100000 THEN 'medium'
           ELSE 'high'
       END AS salary_band,
       COUNT(*) AS employee_count
FROM employees
GROUP BY CASE
             WHEN salary < 50000 THEN 'low'
             WHEN salary < 100000 THEN 'medium'
             ELSE 'high'
         END;

This groups employees into salary bands rather than by exact salary.

d. HAVING: filtering groups

HAVING filters groups after aggregation. It is the counterpart to WHERE, which filters rows before grouping.

-- Departments with more than 5 employees
SELECT department_id, COUNT(*) AS employee_count
FROM employees
GROUP BY department_id
HAVING COUNT(*) > 5;

The difference from WHERE is fundamental. WHERE cannot reference aggregates because aggregates do not exist until after grouping.

-- Error: aggregate in WHERE
SELECT department_id, COUNT(*)
FROM employees
WHERE COUNT(*) > 5
GROUP BY department_id;

The correct form puts the aggregate condition in HAVING.

Both clauses can appear in the same query, filtering at different stages:

-- Active employees only, then departments with more than 5 of them
SELECT department_id, COUNT(*) AS active_count, AVG(salary) AS avg_salary
FROM employees
WHERE active = TRUE
GROUP BY department_id
HAVING COUNT(*) > 5 AND AVG(salary) > 60000;

WHERE filters individual inactive employees; HAVING filters the resulting groups.

e. GROUP BY with joins

Grouping commonly follows a join. Aggregates are computed over the joined rows.

SELECT d.department_name,
       COUNT(e.employee_id) AS employee_count,
       AVG(e.salary) AS avg_salary
FROM departments d
LEFT JOIN employees e ON d.department_id = e.department_id
GROUP BY d.department_name;

The LEFT JOIN ensures that departments with no employees appear in the result with a count of zero. Note COUNT(e.employee_id) rather than COUNT(*): with a LEFT JOIN, a department with no employees produces one row with all employee columns NULL, and COUNT(*) would count that row as 1. COUNT(e.employee_id) counts non-NULL values, which correctly returns 0.

This is one of the most common subtle errors in grouped queries with outer joins. The rule: when counting rows from the outer-joined table, count a column from that table, not *.

f. Grouping sets, ROLLUP, and CUBE

Standard GROUP BY produces one level of grouping. SQL provides extensions for multiple levels in a single query.

GROUPING SETS specifies multiple grouping combinations:

SELECT department_id, job_title, COUNT(*) AS count
FROM employees
GROUP BY GROUPING SETS (
    (department_id, job_title),
    (department_id),
    ()
);

This produces the detail rows by (department, job title), the subtotal rows by department, and the grand total, all in one result set.

ROLLUP produces hierarchical subtotals:

SELECT EXTRACT(YEAR FROM order_date) AS yr,
       EXTRACT(MONTH FROM order_date) AS mo,
       SUM(total) AS revenue
FROM orders
GROUP BY ROLLUP (
    EXTRACT(YEAR FROM order_date),
    EXTRACT(MONTH FROM order_date)
);

This produces monthly revenue, yearly subtotals, and a grand total. The rows where a grouping column is NULL in the result represent the subtotal levels.

CUBE produces all combinations:

SELECT department_id, job_title, COUNT(*)
FROM employees
GROUP BY CUBE (department_id, job_title);

This produces rows for (department, job title), (department), (job title), and the grand total.

These extensions are supported by PostgreSQL, SQL Server, Oracle, and modern MySQL. They are useful for reports that need subtotals without multiple queries.

g. Grouping by multiple columns and interaction with ORDER BY

Grouping by multiple columns produces one row per distinct combination. The result is unordered unless ORDER BY is specified.

SELECT department_id, job_title, COUNT(*) AS count
FROM employees
GROUP BY department_id, job_title
ORDER BY department_id, count DESC;

Sorting by an aggregate is allowed because ORDER BY is evaluated after grouping. Sorting by an alias for the aggregate is also allowed in most databases.

The result of a grouped query is essentially a new table whose columns are the grouping columns and the aggregates. This result can be wrapped in a subquery or CTE and further filtered or joined.


Complete Example Session

-- ============================================
-- PART 1: CREATE SAMPLE TABLES
-- ============================================
CREATE TABLE departments (
    department_id   INTEGER PRIMARY KEY,
    department_name VARCHAR(50)
);

CREATE TABLE employees (
    employee_id   INTEGER PRIMARY KEY,
    first_name    VARCHAR(50),
    department_id INTEGER,
    job_title     VARCHAR(50),
    salary        NUMERIC(10, 2),
    hire_date     DATE,
    active        BOOLEAN
);

INSERT INTO departments VALUES
(1, 'Engineering'), (2, 'Sales'), (3, 'Marketing'), (4, 'Support');

INSERT INTO employees VALUES
(101, 'Alice', 1, 'Engineer',   95000, '2020-03-15', TRUE),
(102, 'Bob',   1, 'Engineer',   78000, '2021-06-01', TRUE),
(103, 'Carol', 2, 'Sales Rep',  82000, '2019-11-20', TRUE),
(104, 'David', 2, 'Sales Rep',  65000, '2022-01-10', TRUE),
(105, 'Eve',   3, 'Marketer',   72000, '2020-08-05', TRUE),
(106, 'Frank', 3, 'Marketer',   68000, '2021-09-15', FALSE),
(107, 'Grace', 1, 'Manager',    88000, '2022-04-01', TRUE);
-- ============================================
-- PART 2: BASIC GROUP BY
-- ============================================
SELECT department_id, COUNT(*) AS employee_count
FROM employees
GROUP BY department_id
ORDER BY department_id;
-- ============================================
-- PART 3: GROUP BY MULTIPLE COLUMNS
-- ============================================
SELECT department_id, job_title, COUNT(*) AS count
FROM employees
GROUP BY department_id, job_title
ORDER BY department_id, job_title;
-- ============================================
-- PART 4: GROUPING RULE ERROR
-- ============================================
-- first_name is not grouped or aggregated.

-- Error:
-- SELECT department_id, first_name, COUNT(*)
-- FROM employees
-- GROUP BY department_id;

-- Correct: add first_name to GROUP BY (one row per employee)
SELECT department_id, first_name, COUNT(*) AS count
FROM employees
GROUP BY department_id, first_name;
-- ============================================
-- PART 5: GROUP BY EXPRESSION
-- ============================================
-- Group by year of hire.

SELECT EXTRACT(YEAR FROM hire_date) AS hire_year,
       COUNT(*) AS hires
FROM employees
GROUP BY EXTRACT(YEAR FROM hire_date)
ORDER BY hire_year;
-- ============================================
-- PART 6: GROUP BY CASE EXPRESSION
-- ============================================
-- Bucket salaries into bands.

SELECT CASE
           WHEN salary < 70000 THEN 'low'
           WHEN salary < 90000 THEN 'medium'
           ELSE 'high'
       END AS salary_band,
       COUNT(*) AS count
FROM employees
GROUP BY CASE
             WHEN salary < 70000 THEN 'low'
             WHEN salary < 90000 THEN 'medium'
             ELSE 'high'
         END
ORDER BY salary_band;
-- ============================================
-- PART 7: HAVING FILTERS GROUPS
-- ============================================
SELECT department_id, COUNT(*) AS count, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING COUNT(*) > 1 AND AVG(salary) > 75000;
-- ============================================
-- PART 8: WHERE AND HAVING TOGETHER
-- ============================================
-- WHERE filters rows; HAVING filters groups.

SELECT department_id, COUNT(*) AS active_count
FROM employees
WHERE active = TRUE
GROUP BY department_id
HAVING COUNT(*) >= 2;
-- ============================================
-- PART 9: GROUP BY WITH LEFT JOIN
-- ============================================
-- Include departments with no employees.

SELECT d.department_name,
       COUNT(e.employee_id) AS employee_count
FROM departments d
LEFT JOIN employees e ON d.department_id = e.department_id
GROUP BY d.department_name
ORDER BY d.department_name;
-- ============================================
-- PART 10: ROLLUP FOR SUBTOTALS
-- ============================================
-- Monthly, yearly, and grand totals in one query.

SELECT EXTRACT(YEAR FROM hire_date)  AS yr,
       EXTRACT(MONTH FROM hire_date) AS mo,
       COUNT(*) AS hires
FROM employees
GROUP BY ROLLUP (
    EXTRACT(YEAR FROM hire_date),
    EXTRACT(MONTH FROM hire_date)
)
ORDER BY yr, mo;

These ten parts cover the complete range of GROUP BY usage: basic grouping, multi-column grouping, the grouping rule and its violation, grouping by expressions, grouping by CASE, HAVING for group filters, WHERE and HAVING together, LEFT JOIN with correct counting, and ROLLUP for subtotals. Each pattern addresses a different reporting need.


Quick Reference

GROUP BY Clause Order

PositionClause
1SELECT
2FROM
3JOIN
4WHERE
5GROUP BY
6HAVING
7ORDER BY

The Grouping Rule

Column in SELECTRequirement
AggregatedWrap in COUNT, SUM, AVG, MIN, MAX
Not aggregatedMust appear in GROUP BY
ExpressionMust match GROUP BY expression or be aggregated

WHERE vs HAVING

ClauseStageAggregates Allowed
WHEREBefore groupingNo
HAVINGAfter groupingYes

Grouping Extensions

ExtensionProduces
GROUPING SETSSpecified combinations
ROLLUPHierarchical subtotals
CUBEAll combinations

Common Aggregate Functions with GROUP BY

FunctionExample
COUNT(*)Row count per group
COUNT(col)Non-NULL count per group
SUM(col)Total per group
AVG(col)Mean per group
MIN(col)Smallest per group
MAX(col)Largest per group

Best Practices

✅ Do This:

SELECT department_id, COUNT(*) FROM employees GROUP BY department_id;
SELECT department_id, job_title, AVG(salary)
FROM employees GROUP BY department_id, job_title;
SELECT d.name, COUNT(e.id) FROM departments d
LEFT JOIN employees e ON d.id = e.department_id
GROUP BY d.name;
SELECT department_id, AVG(salary) FROM employees
GROUP BY department_id HAVING AVG(salary) > 70000;

❌ Don’t Do This:

SELECT department_id, first_name, COUNT(*)
FROM employees GROUP BY department_id;                 -- ❌ first_name not grouped
SELECT department_id, COUNT(*) FROM employees
WHERE COUNT(*) > 5 GROUP BY department_id;             -- ❌ aggregate in WHERE
SELECT d.name, COUNT(*) FROM departments d
LEFT JOIN employees e ON d.id = e.department_id
GROUP BY d.name;                                       -- ❌ COUNT(*) counts outer row

Common Pitfalls

PitfallWhy It HappensFix
Column not in GROUP BYNon-aggregated column in SELECTAdd to GROUP BY or aggregate
Aggregate in WHEREWHERE runs before groupingUse HAVING
COUNT(*) with LEFT JOIN overcountsOuter join produces a row with NULLsUse COUNT(col) from the outer table
GROUP BY expression mismatchSELECT expression differs from GROUP BYRepeat the same expression
Alias in GROUP BY failsNon-standard featureRepeat the expression for portability
Unexpected row countGrouping by too many columnsVerify grouping columns define the intended groups
NULL as a groupNULL values form their own groupFilter with IS NULL if needed

Real-World Examples

1. Count per Category

SELECT category_id, COUNT(*) AS products FROM products GROUP BY category_id;

2. Total Revenue per Customer

SELECT customer_id, SUM(total) AS revenue FROM orders GROUP BY customer_id;

3. Average Rating per Product

SELECT product_id, AVG(rating) AS avg_rating FROM reviews GROUP BY product_id;

4. Orders per Month

SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS orders
FROM orders GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;

5. Top Customers by Revenue

SELECT customer_id, SUM(total) AS revenue
FROM orders GROUP BY customer_id HAVING SUM(total) > 10000 ORDER BY revenue DESC;

6. Departments with No Employees

SELECT d.name, COUNT(e.id) AS count
FROM departments d LEFT JOIN employees e ON d.id = e.department_id
GROUP BY d.name HAVING COUNT(e.id) = 0;

7. Salary Bands

SELECT CASE WHEN salary < 50000 THEN 'low' ELSE 'high' END AS band, COUNT(*)
FROM employees GROUP BY CASE WHEN salary < 50000 THEN 'low' ELSE 'high' END;

8. Yearly and Monthly Subtotal

SELECT EXTRACT(YEAR FROM order_date) AS yr,
       EXTRACT(MONTH FROM order_date) AS mo,
       SUM(total) AS revenue
FROM orders
GROUP BY ROLLUP (EXTRACT(YEAR FROM order_date), EXTRACT(MONTH FROM order_date));

9. Grouping by Join Result

SELECT c.country, COUNT(o.id) AS orders
FROM customers c JOIN orders o ON c.id = o.customer_id
GROUP BY c.country;

10. Distinct Counts per Group

SELECT department_id, COUNT(DISTINCT job_title) AS distinct_titles
FROM employees GROUP BY department_id;

Visual

GROUP BY Partitions Rows

┌──────────────────────────────────────────────────────────────┐
│  GROUP BY PARTITIONS ROWS INTO GROUPS                        │
│                                                              │
│  Before grouping:                                            │
│  ┌────┬────────┬────────┐                                    │
│  │ id │ dept   │ salary │                                    │
│  ├────┼────────┼────────┤                                    │
│  │101 │ 1      │ 95000  │                                    │
│  │102 │ 1      │ 78000  │                                    │
│  │103 │ 2      │ 82000  │                                    │
│  │104 │ 2      │ 65000  │                                    │
│  │105 │ 3      │ 72000  │                                    │
│  └────┴────────┴────────┘                                    │
│                                                              │
│  GROUP BY dept:                                              │
│  ┌─────────────────────┐  ┌─────────────────────┐           │
│  │ dept = 1            │  │ dept = 2            │           │
│  │ rows: 101, 102      │  │ rows: 103, 104      │           │
│  │ COUNT(*) = 2        │  │ COUNT(*) = 2        │           │
│  │ SUM = 173000        │  │ SUM = 147000        │           │
│  └─────────────────────┘  └─────────────────────┘           │
│  ┌─────────────────────┐                                     │
│  │ dept = 3            │                                     │
│  │ rows: 105           │                                     │
│  │ COUNT(*) = 1        │                                     │
│  │ SUM = 72000         │                                     │
│  └─────────────────────┘                                     │
│                                                              │
│  Result: one row per group.                                  │
└──────────────────────────────────────────────────────────────┘

Query Clause Execution Order

┌──────────────────────────────────────────────────────────────┐
│  HOW SQL PROCESSES A GROUPED QUERY                           │
│                                                              │
│  1. FROM / JOIN      ──▶ Assemble rows                       │
│  2. WHERE            ──▶ Filter individual rows              │
│  3. GROUP BY         ──▶ Partition remaining rows            │
│  4. Aggregates       ──▶ Compute per group                   │
│  5. HAVING           ──▶ Filter groups                       │
│  6. SELECT           ──▶ Produce output columns              │
│  7. ORDER BY         ──▶ Sort output                         │
│                                                              │
│  WHERE cannot see aggregates (step 2 before step 4).         │
│  HAVING can see aggregates (step 5 after step 4).            │
│  Column aliases defined in SELECT are available to ORDER BY  │
│  but not to WHERE, GROUP BY, or HAVING in standard SQL.      │
└──────────────────────────────────────────────────────────────┘

COUNT(*) vs COUNT(col) with LEFT JOIN

┌──────────────────────────────────────────────────────────────┐
│  OUTER JOIN + COUNT: A COMMON MISTAKE                        │
│                                                              │
│  departments:  Engineering, Sales, Marketing, Support        │
│  employees:    Engineering (2), Sales (2), Marketing (1)     │
│                                                              │
│  COUNT(*) with LEFT JOIN:                                    │
│  ┌──────────────────┬──────────┐                             │
│  │ Engineering      │ 2        │                             │
│  │ Sales            │ 2        │                             │
│  │ Marketing        │ 1        │                             │
│  │ Support          │ 1  ← WRONG (should be 0)              │
│  └──────────────────┴──────────┘                             │
│                                                              │
│  COUNT(e.employee_id) with LEFT JOIN:                        │
│  ┌──────────────────┬──────────┐                             │
│  │ Engineering      │ 2        │                             │
│  │ Sales            │ 2        │                             │
│  │ Marketing        │ 1        │                             │
│  │ Support          │ 0  ← correct                        │
│  └──────────────────┴──────────┘                             │
│                                                              │
│  With LEFT JOIN, COUNT(*) counts the NULL row produced for   │
│  departments with no employees. COUNT(col) from the outer    │
│  table counts only non-NULL values, which is correct.        │
└──────────────────────────────────────────────────────────────┘

ROLLUP Produces Subtotals

┌──────────────────────────────────────────────────────────────┐
│  ROLLUP HIERARCHY                                            │
│                                                              │
│  GROUP BY ROLLUP (year, month)                               │
│                                                              │
│  ┌──────┬──────┬──────────┐                                  │
│  │ year │ month│ revenue  │                                  │
│  ├──────┼──────┼──────────┤                                  │
│  │ 2025 │ 01   │ 50000    │  ← monthly detail                │
│  │ 2025 │ 02   │ 60000    │                                  │
│  │ 2025 │ NULL │ 110000   │  ← yearly subtotal               │
│  │ 2026 │ 01   │ 55000    │                                  │
│  │ 2026 │ NULL │ 55000    │  ← yearly subtotal               │
│  │ NULL │ NULL │ 165000   │  ← grand total                   │
│  └──────┴──────┴──────────┘                                  │
│                                                              │
│  NULL in a grouping column indicates a subtotal level.       │
│  Use GROUPING() to distinguish NULL subtotals from actual    │
│  NULL values in the data.                                    │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
GROUP BY purposePartition rows for per-group aggregation
Grouping ruleNon-aggregated columns must be in GROUP BY
Clause orderSELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY
WHEREFilters rows before grouping
HAVINGFilters groups after aggregation
Multiple columnsOne group per distinct combination
ExpressionsGROUP BY accepts expressions, not just columns
LEFT JOIN countingUse COUNT(col) from outer table, not COUNT(*)
GROUPING SETSMultiple grouping combinations in one query
ROLLUPHierarchical subtotals
CUBEAll combinations

Key takeaways:

  • GROUP BY partitions rows into groups. Each group has one value for the grouping columns, and aggregates are computed per group.
  • The grouping rule requires non-aggregated columns to be in GROUP BY. A column in SELECT that is neither aggregated nor grouped produces an error in standard SQL.
  • WHERE filters rows; HAVING filters groups. WHERE runs before grouping and cannot reference aggregates. HAVING runs after grouping and can.
  • Grouping by multiple columns produces one row per combination. The grouping columns together form the unique key of the result.
  • GROUP BY accepts expressions. Group by year, by month, by CASE expression, or by any deterministic expression.
  • COUNT(*) and COUNT(col) differ with LEFT JOIN. A LEFT JOIN produces a NULL row for unmatched rows; COUNT(*) counts it, COUNT(col) does not. Use COUNT(col) from the outer table for correct counts.
  • ROLLUP, CUBE, and GROUPING SETS produce subtotals. They extend GROUP BY to multiple levels in a single query, useful for reports.
  • The result of GROUP BY is a new table. It can be wrapped in a subquery or CTE and further processed.

Remember: GROUP BY is the clause that turns detail rows into summary rows. It partitions the data by the values of one or more columns or expressions, and aggregate functions compute one value per group. The grouping rule requires every non-aggregated column in the SELECT list to appear in GROUP BY, which ensures that each group has a single, unambiguous value for each output column. WHERE filters rows before grouping; HAVING filters groups after. When grouping follows a LEFT JOIN, counting a column from the outer table rather than using COUNT(*) avoids the common error of counting NULL rows as 1. The extensions ROLLUP, CUBE, and GROUPING SETS produce subtotals at multiple levels in one query, which is useful for hierarchical reports. Mastering GROUP BY is mastering the transition from storing data to summarizing it.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!