The short version
- Aggregates collapse rows.
COUNT,SUM,AVG,MINandMAXturn many rows into one. - Only
COUNT(*)counts every row. Every other aggregate skips NULL. WHEREfilters rows,HAVINGfilters groups. One runs before grouping, the other after.- Group every selected column you do not aggregate. MySQL rejects the rest with error 1055.
- Window functions keep every row.
OVERadds a computed column instead of collapsing. - The engines differ at the edges.
GROUP_CONCATversusstring_agg, andCUBEandFILTERin PostgreSQL only.
Collapsing rows: the aggregate functions
An aggregate function reads many rows and returns one value. The five that matter are COUNT, SUM, AVG, MIN and MAX. Without a GROUP BY they treat the whole result as one group, so the query returns one row. MIN and MAX also work on dates and strings, which is how you find a customer's first or latest order.
NULL is the detail returning people forget. COUNT(*) counts rows. COUNT(column) counts rows where that column is not NULL. The others skip NULLs, so an average is the average of the values that exist. DISTINCT inside the parentheses counts each distinct value once. An aggregate over zero rows returns NULL, except COUNT, which returns 0, so wrap sums in COALESCE(SUM(...), 0) when a report must show a zero.
SELECT COUNT(*) AS all_orders,
COUNT(customer_id) AS with_customer,
COUNT(DISTINCT customer_id) AS customers,
SUM(total) AS revenue,
ROUND(AVG(total), 2) AS avg_order,
MIN(total) AS smallest,
MAX(total) AS largest
FROM orders;
| id | customer_id | order_date | status | total |
|---|---|---|---|---|
| 10 | 1 | 2026-01-05 | paid | 65.00 |
| 11 | 1 | 2026-02-11 | paid | 180.00 |
| 12 | 2 | 2026-02-20 | pending | 40.00 |
| 13 | NULL | 2026-03-01 | paid | 20.00 |
| 14 | 3 | 2026-03-15 | cancelled | 45.00 |
| all_orders | with_customer | customers | revenue | avg_order | smallest | largest |
|---|---|---|---|---|---|---|
| 5 | 4 | 3 | 350.00 | 70.00 | 20.00 | 180.00 |
Remember
COUNT(*) counts rows. Every other aggregate, including COUNT(column), ignores NULL, so look for NULLs before you look for a bug.
GROUP BY and HAVING
GROUP BY splits the rows into groups that share a value, runs the aggregates once per group, and returns one row per group. NULL is a group of its own. WHERE runs before grouping, so it sees single rows and cannot mention an aggregate. HAVING runs after, so it can say SUM(total) > 100. When a condition fits either place, put it in WHERE: fewer rows reach the grouping.
SELECT customer_id, SUM(total) AS paid_total
FROM orders
WHERE status = 'paid' -- rows, before grouping
GROUP BY customer_id
HAVING SUM(total) > 100; -- groups, after aggregation
| customer_id | paid_total |
|---|---|
| 1 | 245.00 |
The guest order was paid, so it passed the WHERE and formed its own group of 20.00, which HAVING then removed.
The rule every returning person forgets
Every column in the SELECT list must appear in GROUP BY or sit inside an aggregate. MySQL 8.4, 9.7 and 26.x enforce it through ONLY_FULL_GROUP_BY in the default sql_mode and reject the query with error 1055. PostgreSQL has always rejected it. Both allow one shortcut, called functional dependence: group by a table's primary key and you may select its other columns, because one key value means one row.
-- Rejected: order_date is neither grouped nor aggregated
SELECT customer_id, order_date, SUM(total) FROM orders GROUP BY customer_id;
-- Accepted: order_date is aggregated
SELECT customer_id, MAX(order_date) AS last_order, SUM(total) AS spent
FROM orders GROUP BY customer_id;
-- Accepted: c.name depends on the primary key c.id
SELECT c.id, c.name, COUNT(o.id) AS orders
FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id;
Careful
When error 1055 appears after an upgrade, the tempting fix is to remove ONLY_FULL_GROUP_BY from sql_mode. Resist it: the query then returns an arbitrary row's value and nobody notices until a report is wrong.
For you as a DBA
You will read reports in the slow query log more often than you write them (chapter 16). A HAVING with no aggregate in it is a WHERE in disguise: the engine grouped rows it then threw away.
Window functions: keep every row
A window function computes something over a set of rows related to the current row and writes the answer onto that row. Nothing collapses: five rows in, five rows out, one column added. The phrase to listen for is "per row, compared with the others". MySQL has had them since 8.0, PostgreSQL for over a decade, and the syntax is the same.
The OVER clause has three optional parts. PARTITION BY says which rows belong together, and it is GROUP BY without the collapsing. ORDER BY sets the order inside each partition. The frame says which neighbors the function may see, for example ROWS BETWEEN 2 PRECEDING AND CURRENT ROW. Adding an ORDER BY changes the default frame to the start of the partition through the current row, which is why a SUM that showed the partition total starts showing a running total.
ROW_NUMBER is the workhorse. Partition by customer, order by date, and each customer's orders are numbered from their first.
SELECT customer_id, id, order_date, total,
ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY order_date) AS n
FROM orders;
| customer_id | id | order_date | total | n |
|---|---|---|---|---|
| NULL | 13 | 2026-03-01 | 20.00 | 1 |
| 1 | 10 | 2026-01-05 | 65.00 | 1 |
| 1 | 11 | 2026-02-11 | 180.00 | 2 |
| 2 | 12 | 2026-02-20 | 40.00 | 1 |
| 3 | 14 | 2026-03-15 | 45.00 | 1 |
The ones worth recognizing:
| Function | What it gives you |
|---|---|
ROW_NUMBER() | 1, 2, 3 within the partition. Never ties. |
RANK() | Ties share a number, then it skips: 1, 2, 2, 4. |
DENSE_RANK() | Ties share a number and nothing is skipped: 1, 2, 2, 3. |
NTILE(n) | Deals the ordered rows into n buckets of nearly equal size, for halves and quartiles. |
LAG(col) | The value from the previous row in the window, for "compared with last month". |
LEAD(col) | The value from the next row in the window. |
SUM(col) OVER (...) | Any aggregate as a window: running total with an ORDER BY, partition total without one. |
A running total, on something other than orders
The sample database ships with a small tracks table, because a list with a natural order shows a running total better than invoices do. Here is Jeff Buckley's Grace, with the elapsed time of the album as each song ends.
SELECT track_no, title, seconds,
SUM(seconds) OVER (ORDER BY track_no) AS elapsed
FROM tracks
WHERE artist = 'Jeff Buckley' AND album = 'Grace'
ORDER BY track_no;
| track_no | title | seconds | elapsed |
|---|---|---|---|
| 1 | Mojo Pin | 342 | 342 |
| 2 | Grace | 322 | 664 |
| 3 | Last Goodbye | 275 | 939 |
| 4 | Lilac Wine | 272 | 1211 |
| 5 | So Real | 283 | 1494 |
| 6 | Hallelujah | 413 | 1907 |
| 7 | Lover, You Should've Come Over | 403 | 2310 |
| 8 | Corpus Christi Carol | 176 | 2486 |
| 9 | Eternal Life | 292 | 2778 |
| 10 | Dream Brother | 326 | 3104 |
Every row survives and each one carries the total so far. The last value, 3104 seconds, is the length of the album. Drop the ORDER BY inside OVER and every row would show 3104 instead, because the frame becomes the whole partition.
MySQL versus PostgreSQL
These are the lines to translate when a query moves between engines (chapter 17). One trap is silent: MySQL GROUP_CONCAT truncates at group_concat_max_len, 1024 bytes by default, without an error.
| Capability | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|
| Non-aggregated columns must be grouped | Yes ONLY_FULL_GROUP_BY in the default sql_mode | Yes always |
| String from a group | Yes GROUP_CONCAT | Yes string_agg |
| JSON from a group | Yes JSON_ARRAYAGG, JSON_OBJECTAGG | Yes json_agg, jsonb_agg, json_object_agg |
| Subtotals and GROUPING() | Yes WITH ROLLUP or ROLLUP() | Yes ROLLUP() |
| CUBE, GROUPING SETS | No (UNION ALL of grouped queries) | Yes |
| FILTER (WHERE …) on an aggregate | No (CASE inside the aggregate) | Yes |
| Window functions, PARTITION BY, named windows | Yes since 8.0 | Yes |
| Frame units | Partial ROWS and RANGE | Yes ROWS, RANGE, GROUPS, plus EXCLUDE |
| Materialized view for a cached report | No (summary table plus a scheduled event) | Yes |
Report patterns you will be asked for
Most requests are one of these shapes. Each names an intermediate result with a CTE, the WITH clause (chapter 13).
- Top-N per group. Number the rows with
ROW_NUMBERinside each partition, then keeprn = 1;LIMITapplies to the whole result and cannot do this. - Running total.
SUM(total) OVER (ORDER BY order_date). - Percent of total. Divide the row's value by
SUM(value) OVER (), or by a partitioned sum for the share within a group. - Month over month. Group into months in a CTE, then
LAGover the aggregate. - Deduplicate. Number the rows within each set of duplicates, delete those above 1, then add the unique constraint (chapter 3).
Ask the AI
Name the engine, the version, the tables and the shape you want, and let it write the window clause: "MySQL 8.4, table orders(id, customer_id, order_date, status, total): for each customer, their most recent paid order, the date of the paid order before it, and the days between. Use a window function and a CTE."
Performance, and where reports should live
A GROUP BY over a big table reads every row unless the WHERE narrows it first, and an index whose column order matches the GROUP BY or the window's ORDER BY lets the engine skip the sort. Chapter 10 (Indexes and query performance) shows those steps in a plan. When the same heavy report runs all day, store the result: PostgreSQL has materialized views, and MySQL needs a summary table filled by a scheduled event (chapter 12).
Go deeper
- MySQL 8.4: Window Functions
Frame rules, named windows, function list.
- MySQL 8.4: MySQL Handling of GROUP BY
ONLY_FULL_GROUP_BY, functional dependence, ANY_VALUE().
- MySQL 8.4: GROUP BY Modifiers
WITH ROLLUP and GROUPING().
- PostgreSQL: Window Functions tutorial
The gentlest official introduction.
- PostgreSQL: Aggregate Functions
string_agg, json_agg and FILTER.