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
| Position | Clause |
|---|---|
| 1 | SELECT |
| 2 | FROM |
| 3 | JOIN |
| 4 | WHERE |
| 5 | GROUP BY |
| 6 | HAVING |
| 7 | ORDER BY |
The Grouping Rule
| Column in SELECT | Requirement |
|---|---|
| Aggregated | Wrap in COUNT, SUM, AVG, MIN, MAX |
| Not aggregated | Must appear in GROUP BY |
| Expression | Must match GROUP BY expression or be aggregated |
WHERE vs HAVING
| Clause | Stage | Aggregates Allowed |
|---|---|---|
| WHERE | Before grouping | No |
| HAVING | After grouping | Yes |
Grouping Extensions
| Extension | Produces |
|---|---|
| GROUPING SETS | Specified combinations |
| ROLLUP | Hierarchical subtotals |
| CUBE | All combinations |
Common Aggregate Functions with GROUP BY
| Function | Example |
|---|---|
| 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Column not in GROUP BY | Non-aggregated column in SELECT | Add to GROUP BY or aggregate |
| Aggregate in WHERE | WHERE runs before grouping | Use HAVING |
| COUNT(*) with LEFT JOIN overcounts | Outer join produces a row with NULLs | Use COUNT(col) from the outer table |
| GROUP BY expression mismatch | SELECT expression differs from GROUP BY | Repeat the same expression |
| Alias in GROUP BY fails | Non-standard feature | Repeat the expression for portability |
| Unexpected row count | Grouping by too many columns | Verify grouping columns define the intended groups |
| NULL as a group | NULL values form their own group | Filter 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
| Item | Value |
|---|---|
| GROUP BY purpose | Partition rows for per-group aggregation |
| Grouping rule | Non-aggregated columns must be in GROUP BY |
| Clause order | SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY |
| WHERE | Filters rows before grouping |
| HAVING | Filters groups after aggregation |
| Multiple columns | One group per distinct combination |
| Expressions | GROUP BY accepts expressions, not just columns |
| LEFT JOIN counting | Use COUNT(col) from outer table, not COUNT(*) |
| GROUPING SETS | Multiple grouping combinations in one query |
| ROLLUP | Hierarchical subtotals |
| CUBE | All 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!