SQL · Guides · October 30, 2026 · 9 min read

SQL aggregate functions: COUNT, SUM, AVG, MIN, MAX with examples

Many small boarding passes funnelling into a single large number on an airport display

An aggregate function takes many rows and returns one value. The five you will use every day are COUNT (how many), SUM (total), AVG (average), MIN and MAX (smallest and largest). Without GROUP BY they collapse the whole table into one row; with GROUP BY they return one row per group. Two rules explain most surprises: all of them except COUNT(*) ignore NULL, and COUNT(*), COUNT(column) and COUNT(DISTINCT column) count three different things.

All examples run on the airline database from the Nomto SQL course, in PostgreSQL syntax on DuckDB (the engine of the Nomto SQL room), and every result table is the real output. Fares are in US dollars.

The five aggregate functions in one query

ticket_flights has one row per passenger per flight segment, with the fare in amount:

SELECT COUNT(*)              AS segments,
       SUM(amount)           AS revenue,
       ROUND(AVG(amount), 2) AS avg_fare,
       MIN(amount)           AS min_fare,
       MAX(amount)           AS max_fare
FROM ticket_flights;
segments revenue avg_fare min_fare max_fare
1045726 207669809.00 198.59 30.00 2033.00
function returns works on
COUNT(*) number of rows any table
COUNT(col) number of non-NULL values any type
SUM(col) total numbers
AVG(col) arithmetic mean numbers
MIN(col), MAX(col) smallest, largest numbers, text, dates

COUNT(*) vs COUNT(column) vs COUNT(DISTINCT column)

This is the difference that matters most in practice:

SELECT COUNT(*)                        AS flights,
       COUNT(actual_departure)         AS departed,
       COUNT(DISTINCT arrival_airport) AS destinations,
       COUNT(DISTINCT aircraft_code)   AS models_used
FROM flights;
flights departed destinations models_used
33121 16773 104 8

COUNT(1) gives the same result as COUNT(*) and is not faster in any modern database. Pick one and stay consistent.

COUNT(DISTINCT ...) is how you answer “how many unique X”. The tickets table has one row per passenger ticket, and several tickets can share one booking:

SELECT COUNT(*)                      AS tickets,
       COUNT(contact_data->>'email') AS with_email,
       COUNT(DISTINCT book_ref)      AS bookings
FROM tickets;
tickets with_email bookings
366733 199083 262788

To count distinct combinations of two columns, PostgreSQL and DuckDB do not accept COUNT(DISTINCT a, b) (MySQL does). Count the rows of a SELECT DISTINCT instead:

SELECT COUNT(*) AS routes
FROM (SELECT DISTINCT departure_airport, arrival_airport FROM flights) r;
routes
618

SUM and AVG

SUM adds up, AVG divides that sum by the number of non-NULL values. Both only accept numbers. Per fare class, sorted by average fare:

SELECT fare_conditions,
       COUNT(*)              AS segments,
       ROUND(SUM(amount))    AS revenue,
       ROUND(AVG(amount), 2) AS avg_fare,
       MIN(amount)           AS min_fare,
       MAX(amount)           AS max_fare
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY avg_fare DESC;
fare_conditions segments revenue avg_fare min_fare max_fare
Business 107642 55051796 511.43 91.00 2033.00
Comfort 17291 5661169 327.41 199.00 476.00
Economy 920793 146956844 159.6 30.00 745.00

The average of averages trap

What is the average fare? Averaging the three class averages gives a very different answer from averaging all segments:

SELECT ROUND(AVG(avg_fare), 2) AS avg_of_averages,
       (SELECT ROUND(AVG(amount), 2) FROM ticket_flights) AS true_average
FROM (
  SELECT fare_conditions, AVG(amount) AS avg_fare
  FROM ticket_flights
  GROUP BY fare_conditions
) t;
avg_of_averages true_average
332.81 198.59

The average of averages gives 17,291 Comfort segments the same weight as 920,793 Economy ones. Always average the raw rows, or compute SUM(amount) / SUM(segments) from grouped totals.

Integer division

In PostgreSQL, SQL Server and SQLite, dividing one integer by another drops the remainder: COUNT(*) / COUNT(*)-style ratios silently become 0 or 1. DuckDB’s / returns a fraction (7 / 2 is 3.5; 7 // 2 is 3), and MySQL’s / does too. Multiplying by 100.0 first, as in the examples below, gives the right answer everywhere.

MIN and MAX

MIN and MAX work on any type that can be sorted: numbers, dates and text.

SELECT MIN(book_date)    AS first_booking,
       MAX(book_date)    AS last_booking,
       MIN(total_amount) AS smallest,
       MAX(total_amount) AS largest
FROM bookings;
first_booking last_booking smallest largest
2026-06-17 14:05:00 2026-08-11 18:00:00 34.00 12045.00

On text, MIN(city) returns the alphabetically first value (Abakan) and MAX(city) the last (Yuzhno-Sakhalinsk). MAX returns the value, not the row: to see which booking had the largest amount, use ORDER BY total_amount DESC LIMIT 1, or a window function when you need it per group (see window functions).

Aggregate functions and NULL

The NULL guide shows each of these on real data, including how much an average shifts when unknown values are turned into zeros.

Aggregates with GROUP BY and HAVING

GROUP BY splits rows into groups, and each aggregate is computed once per group. HAVING then filters groups by an aggregate:

SELECT departure_airport, COUNT(*) AS departures
FROM flights
GROUP BY departure_airport
HAVING COUNT(*) >= 1500
ORDER BY departures DESC;
departure_airport departures
DME 3217
SVO 2981
LED 1900
VKO 1719

Two rules follow from the order in which SQL runs a query:

The details, with the common mistakes, are in GROUP BY vs HAVING.

Conditional aggregates: FILTER and CASE

Often you want several counts with different conditions in one row. The FILTER clause applies a condition to one aggregate only:

SELECT COUNT(*) FILTER (WHERE status = 'Cancelled') AS cancelled,
       COUNT(*) FILTER (WHERE status = 'Delayed')   AS delayed,
       SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled_case,
       ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'Cancelled') / COUNT(*), 2) AS cancelled_pct
FROM flights;
cancelled delayed cancelled_case cancelled_pct
414 41 414 1.25

FILTER works in PostgreSQL (9.4+), DuckDB and SQLite (3.30+). MySQL and SQL Server do not support it; there, use SUM(CASE WHEN ... THEN 1 ELSE 0 END) or COUNT(CASE WHEN ... THEN 1 END), which return the same number, as the cancelled_case column shows.

The same pattern gives shares of a total in one pass:

SELECT ROUND(SUM(amount)) AS revenue,
       ROUND(SUM(amount) FILTER (WHERE fare_conditions = 'Business')) AS business_revenue,
       ROUND(100.0 * SUM(amount) FILTER (WHERE fare_conditions = 'Business') / SUM(amount), 1) AS business_pct
FROM ticket_flights;
revenue business_revenue business_pct
207669809 55051796 26.5

Business class is about 10% of segments but 26.5% of revenue. More conditional logic is in the CASE WHEN guide.

Wrong totals after a JOIN

Aggregates count whatever rows they get. A join that matches one row to several repeats it, and SUM adds it several times. Booking totals summed after joining to tickets:

SELECT SUM(b.total_amount) AS wrong_revenue,
       (SELECT SUM(total_amount) FROM bookings) AS right_revenue
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref;
wrong_revenue right_revenue
343206192.00 207669809.00

A booking with three passengers has three tickets, so its total was added three times. The query runs without errors and the number looks plausible. Aggregate each table at its own level first, then join the results, or use COUNT(DISTINCT ...) when you only need counts. The joins guide explains this fan-out in detail.

Nesting aggregates

You cannot put one aggregate directly inside another: SELECT MAX(COUNT(*)) FROM flights GROUP BY departure_airport fails with “aggregate function calls cannot be nested”. Aggregate in a subquery or CTE, then aggregate the result:

SELECT AVG(n) AS avg_tickets_per_booking, MAX(n) AS max_tickets
FROM (SELECT book_ref, COUNT(*) AS n FROM tickets GROUP BY book_ref) t;
avg_tickets_per_booking max_tickets
1.3955469808362635 5

On average a booking has 1.4 tickets, and the largest has five. See the CTE guide for writing multi-step aggregations readably.

Other useful aggregate functions

SELECT split_part(model, ' ', 1) AS maker,
       COUNT(*)   AS models,
       MAX(range) AS max_range,
       STRING_AGG(model, ', ' ORDER BY range DESC) AS line_up
FROM aircrafts
GROUP BY maker
ORDER BY max_range DESC;
maker models max_range line_up
Boeing 3 11100 Boeing 777-300, Boeing 767-300, Boeing 737-300
Airbus 3 6700 Airbus A319-100, Airbus A320-200, Airbus A321-200
Sukhoi 1 3000 Sukhoi Superjet-100
Bombardier 1 2700 Bombardier CRJ-200
Cessna 1 1200 Cessna 208 Caravan

Every aggregate can also be used as a window function with OVER (...), which keeps the individual rows instead of collapsing them: that is how running totals and shares of a group are computed.

FAQ

What are the aggregate functions in SQL?

The standard five are COUNT, SUM, AVG, MIN and MAX. Most databases add more, such as STRING_AGG, ARRAY_AGG, STDDEV, VARIANCE and percentile functions. They all turn a set of rows into a single value, per group if there is a GROUP BY.

What is the difference between COUNT(*) and COUNT(column)?

COUNT(*) counts rows. COUNT(column) counts rows where the column is not NULL. In the flights table they give 33,121 and 16,773 for actual_departure, because flights that have not left yet have no departure time.

Do aggregate functions ignore NULL?

Yes, all of them except COUNT(*). AVG divides by the number of non-NULL values, and SUM of a column that is entirely NULL (or of zero rows) returns NULL, not 0.

Can I use an aggregate function in WHERE?

No. WHERE filters rows before they are grouped, so aggregates do not exist yet. Use HAVING to filter on an aggregate, or compute it in a subquery and filter in the outer query.

Can I use aggregate functions without GROUP BY?

Yes. Without GROUP BY the whole table (after WHERE) is one group, and you get a single row. You cannot mix such aggregates with plain columns in the same SELECT.

How do I count distinct values in SQL?

COUNT(DISTINCT column). For combinations of several columns, count the rows of SELECT DISTINCT a, b in a subquery, since COUNT(DISTINCT a, b) works only in MySQL.

Also in Español, Português (Brasil), Tiếng Việt, 简体中文