Aggregation: measuring the business

COUNT, SUM, AVG: rows become numbers

25 min
What you'll learn
  • write queries with COUNT, SUM, AVG, MIN, and MAX that collapse a set of rows into a single summary
  • combine WHERE with to total only the rows you care about — paid orders, for instance
  • tell COUNT(*), COUNT(col), and COUNT(DISTINCT col) apart and explain why they return three different numbers on the same data
  • explain why SELECT status, COUNT(*) without GROUP BY fails with an error

Looking ahead. Some examples in this chapter stitch two tables together with JOIN — that's the subject of Chapter 4, where we'll cover it properly. For now, just read a line like orders o JOIN order_items oi ON oi.order_id = o.id as "glued the rows of two tables together by a key." What matters here are the themselves, not the JOIN.

Chapter 3 — "The Store's Pulse"

"Analyst" clearance settles onto your pass as a warm amber stripe. K.'s encrypted block never did open, but you know its heading by heart: "Raw rows are noise. Meaning appears once you can add them up." QUERY dims the spare holopanels and leaves one — empty, with a single dot pulsing at its center.

QUERY: You've been reading the archive row by row. Enough. Today we listen to its heart.

A cadet at a holopanel: a stream of capsule-rows compresses into a single pulsing dot — one figure
An aggregate compresses hundreds of rows into a single heartbeat — a number you can say out loud.

Up to this point, you have mostly been retrieving individual rows.

For example:

SELECT id, user_id, status, total_amount
FROM orders
WHERE status = 'paid';

A query like that lists the paid orders one row at a time:

iduser_idstatustotal_amount
1017paid1200
1029paid3500
1037paid800
10412paid2100

This is useful when you want to examine the individual rows.

But a business question often sounds quite different:

How many paid orders were there?
What is the total revenue?
What is the average order value?
What were the smallest and the largest orders?
How many distinct customers placed an order?

Questions like these do not need a long list of rows. They need a summary instead.

That is where SQL's functions come in.

An aggregate function takes a set of rows and returns a single summary value.

For example:

SELECT COUNT(*) AS orders_count
FROM orders;

This query does not show each order separately. It returns a single number: the number of rows in the orders table.

Aggregate functions: many rows → one value

Aggregates turn many rows into useful summary values.

Not this:

order 101
order 102
order 103
order 104

But this:

4 orders in total

Not this:

1200
3500
800
2100

But this:

total amount 7600
average order 1900
smallest order 800
largest order 3500

Here are the five main functions:

FunctionWhat it computesExample question
COUNT(*)the number of rowsHow many orders?
SUM(x)the sum of the valuesWhat is the revenue?
AVG(x)the average valueWhat is the average order value?
MIN(x)the smallest valueWhat is the smallest order amount?
MAX(x)the largest valueWhat is the largest order amount?

For example:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders;

If the query has no GROUP BY, each aggregate computes one summary value over the entire input set.

So the result is a single row:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
4760019008003500
9905990129034902790SUM14 550one number
An aggregate function compresses a stream of rows into a single result — one figure for the whole set.

COUNT: how many rows

COUNT answers the question "How many?"

The most common form is:

COUNT(*)

It counts rows.

For example:

SELECT COUNT(*) AS orders_count
FROM orders;

Result:

orders_count
128

The star in COUNT(*) does not mean "select every column". Here, it means:

Count the rows themselves.

COUNT(*) does not inspect the values in any column. All it cares about is whether a row exists.

Use COUNT(*) for questions like these:

How many orders are there in total?
How many products are there in total?
How many users are there in total?
How many rows are left after the filter?

For example, here is how to count the paid orders:

SELECT COUNT(*) AS paid_orders_count
FROM orders
WHERE status = 'paid';

First, WHERE keeps only the paid orders. Then COUNT(*) counts the remaining rows.

SUM, AVG, MIN and MAX

SUM adds values up.

SELECT SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

That gives you the total revenue from paid orders.

AVG computes the average value.

SELECT AVG(total_amount) AS avg_order_amount
FROM orders
WHERE status = 'paid';

That gives you the average order value for paid orders.

MIN finds the smallest value.

SELECT MIN(total_amount) AS min_order_amount
FROM orders
WHERE status = 'paid';

MAX finds the largest value.

SELECT MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

You can use all of these functions together:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

This gives you a complete summary of the paid orders in a single result row.

WHERE filters the rows first, aggregates summarize them second

It helps to understand the logical order here.

In this query:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

the filter is logically applied first:

WHERE status = 'paid'

It keeps only the paid orders.

Only then do the compute their results over the remaining rows:

COUNT(*)
SUM(total_amount)

So the query does not aggregate all orders and then filter for paid ones. It first filters out the rows it does not need, then computes the aggregates.

This lets you choose exactly which rows to summarize.

Revenue from paid orders only:

WHERE status = 'paid'

The number of cancelled orders:

WHERE status = 'cancelled'

The average order value during a particular period:

WHERE created_at >= '2024-01-01'
  AND created_at <  '2024-02-01'

First, the filter determines which rows remain. Then each aggregate computes a summary value.

QUERY: Before you take the data's pulse, decide which rows you want to summarize. All orders, paid orders, and cancelled orders tell different stories.

Listen to the store's heart: a single row with all its vital signs — how many paid orders, how many distinct buyers stand behind them, the revenue, and the smallest and largest order. ROUND(...,2) rounds the average to two decimal places.

COUNT(*) and COUNT(DISTINCT col) answer different questions. COUNT(*) counts rows, while COUNT(DISTINCT user_id) counts how many distinct buyers stand behind them. If there are more orders than buyers, the archive isn't broken: someone came back and bought again.

COUNT(*), COUNT(col), COUNT(DISTINCT col)

COUNT comes in several forms, and they answer different questions.

Let's work through a small example.

Suppose you have a visits table:

idpromo_code
1SALE
2SALE
3
4VIP
5NULL

The query:

SELECT
    COUNT(*) AS rows_count,
    COUNT(promo_code) AS with_promo_code,
    COUNT(DISTINCT promo_code) AS different_promo_codes
FROM visits;

returns:

rows_countwith_promo_codedifferent_promo_codes
532

Why?

COUNT(*) counts every row.

The table has 5 rows, so the result is 5.

COUNT(promo_code) counts the rows where promo_code has a non-NULL value.

It does not count NULL values. Three rows have a non-NULL promo code:

SALE
SALE
VIP

So the result is 3.

COUNT(DISTINCT promo_code) counts the distinct non-NULL values.

Among the non-NULL values:

SALE
SALE
VIP

only two are distinct:

SALE
VIP

So the result is 2.

Putting it all together:

COUNT(*)                 → how many rows
COUNT(promo_code)        → how many rows have a promo code
COUNT(DISTINCT promo_code) → how many distinct promo codes

These ask three different questions of the data, so their answers can differ.

Aggregates and NULL

The general rule is:

Aggregate functions usually ignore NULL.

For example, suppose you have these values:

total_amount
1000
2000

SUM(total_amount) adds up only the non-NULL values:

1000 + 2000 = 3000

AVG(total_amount) also uses only the non-NULL values:

(1000 + 2000) / 2 = 1500

It does not calculate this:

(1000 + 2000 + NULL) / 3

MIN and MAX likewise consider only non-NULL values when finding the smallest and largest.

The one important exception is COUNT(*).

COUNT(*) counts rows rather than the value of a particular column, so NULL values in those rows do not affect the count.

Compare:

SELECT
    COUNT(*) AS rows_count,
    COUNT(total_amount) AS filled_amounts,
    SUM(total_amount) AS total_sum,
    AVG(total_amount) AS avg_amount
FROM orders;

COUNT(*) answers the question:

how many rows

COUNT(total_amount) answers the question:

how many rows have total_amount filled in

These are not the same thing.

When WHERE leaves no rows at all

Sometimes a filter finds no rows whatsoever.

For example:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'status_that_does_not_exist';

If there are no such orders, the result still contains one row because an query without GROUP BY returns one summary for its input set.

Its values will be:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
0NULLNULLNULL

COUNT(*) returns 0, because there are no rows.

And SUM, AVG, MIN and MAX return NULL, because there are no values to sum, average, or compare.

If your report needs zero revenue instead of NULL, you can use COALESCE:

SELECT
    COUNT(*) AS orders_count,
    COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE status = 'status_that_does_not_exist';

This does not change the data in the table. It simply shows 0 in the query result instead of returning NULL for the total.

COUNT(*) and COUNT(1)

You may sometimes see this in queries written by others:

COUNT(1)

For example:

SELECT COUNT(1)
FROM orders;

For a regular row count in PostgreSQL, this is equivalent to:

SELECT COUNT(*)
FROM orders;

Why?

Because 1 is an expression that is never NULL. It has the same non- value for every row, so COUNT(1) counts every row.

For readability, though, it is better to write:

COUNT(*)

That way it is obvious at a glance that you are counting rows.

Do not rely on the myth that COUNT(1) is faster than COUNT(*) in PostgreSQL. For now, remember something simpler:

If you want to count rows, write COUNT(*).

Why an ordinary column next to an aggregate is an error

Look at this query:

SELECT status, COUNT(*)
FROM orders;

At first glance, it may look as though this should show each status with its order count.

But without GROUP BY, this query is invalid.

Why?

COUNT(*) without GROUP BY collapses all the input rows into a single result row.

For example:

count
128

But status is an ordinary column. Different orders in the table have different statuses:

idstatus
1paid
2pending
3paid
4cancelled

If the whole set of rows has collapsed into one result row, which status is SQL supposed to show next to the overall count?

paid?
pending?
cancelled?

SQL does not guess. It asks you to make the logic explicit.

If you want the overall order count, drop status:

SELECT COUNT(*) AS orders_count
FROM orders;

If you want the number of orders per status, add GROUP BY:

SELECT status, COUNT(*) AS orders_count
FROM orders
GROUP BY status;

Then the result is not one overall row but a separate row for each status:

statusorders_count
paid80
pending25
cancelled18
refunded5

We will go through GROUP BY in detail later on. For now, what matters is the reason for the error:

An produces one value for the entire set, while an ordinary column may have several possible values. SQL will not choose one for you.

The main rule for without GROUP BY

If the query has no GROUP BY, each aggregate function computes one summary value over all the rows left after WHERE.

SELECT COUNT(*), SUM(total_amount)
FROM orders
WHERE status = 'paid';

A query like this returns a single row.

You cannot select an ordinary column alongside an aggregate when there is no grouping:

SELECT status, COUNT(*)
FROM orders;

A query like this is invalid, because status can differ from row to row.

Interview question

Interview question: A table has a hundred rows, and some values in the manager_id column are NULL. What do COUNT(*), COUNT(manager_id), and COUNT(DISTINCT manager_id) return, and why are the numbers different?

Strong answer: COUNT(*) returns 100 — it counts rows and ignores NULL. COUNT(manager_id) returns fewer: only rows where manager_id is filled in, because ignore NULL. COUNT(DISTINCT manager_id) is smaller still or equal: the number of distinct non-empty values. These are three different questions to the data: "how many rows," "how many are filled in," "how many distinct."

Interview question: Why is SELECT status, COUNT(*) FROM orders without GROUP BY an error?

Strong answer: The aggregate collapses the result set into a single row, but status stays multivalued — it differs across rows, and SQL won't guess which value to show. Either drop status, or add GROUP BY status, and the count is computed separately for each status.

Check yourself
What does the query SELECT AVG(price) FROM products; return when no GROUP BY is specified?
Check yourself
What does COUNT(*) count?
Check yourself
How is COUNT(user_id) different from COUNT(DISTINCT user_id)?
Check yourself
Why is the query SELECT status, COUNT(*) FROM orders; invalid without GROUP BY?

QUERY: Raw rows show individual events. Aggregates let you take the data's pulse: how many there are, what they add up to, and their average, minimum, and maximum values.

Practice: solve the tasks
Solved 0 of 3 · any 2 is enough to pass