Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite) Module 4 – Aggregates and GROUP BY

SQL aggregates: COUNT, SUM, AVG, MIN and MAX

How COUNT, SUM, AVG, MIN and MAX summarise rows in SQL, what they return for NULLs and for no rows at all, and how COUNT(DISTINCT) counts unique values.

  • Beginner
  • 20 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot

What you will learn

  • Summarise a table with COUNT, SUM, AVG, MIN and MAX
  • Explain how aggregates treat NULLs and inputs with no rows
  • Count distinct values with COUNT(DISTINCT …)
  • Avoid integer division when you compute an average yourself

Before you start

On this page

Most questions about data are questions about many rows at once: how many orders came in, how much they were worth, what the average rating was, which order was the biggest. An aggregate function answers them. It reads a whole set of rows and gives back a single value.

The five basic aggregates, count, sum, avg, min and max, exist in every engine this track covers. This lesson uses them on a small table of grocery orders. The examples run in SQLite 3.49.1, the engine of this page’s Run button (sql.js 1.14.2), and the notes say where PostgreSQL and MySQL behave differently.

Five aggregates, one row

Each example creates its own small table first, so you can see every row it works on. The amounts are stored as whole paise, the hundredth part of a rupee, so that adding them up never loses a fraction.

Five aggregates over the whole table SQL · summary.sql
-- Six orders of a small grocery shop. Amounts are whole paise (100 paise = 1 rupee),
-- so totals stay exact; rating is 1 to 5, or NULL when the customer did not rate.
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL,
  rating       INTEGER
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000, 5),
  (2, 'Vikram', 'Kochi',  12000, NULL),
  (3, 'Asha',   'Pune',   30000, 4),
  (4, 'Meera',  'Jaipur',  8000, 2),
  (5, 'Tenzin', 'Pune',   99000, NULL),
  (6, 'Vikram', 'Kochi',  15000, 4);

-- Five aggregates over the whole table: six rows in, one row out.
SELECT count(*)                            AS orders,
       sum(amount_paise) / 100.0           AS total_rupees,
       round(avg(amount_paise) / 100.0, 2) AS average_rupees,
       min(amount_paise) / 100.0           AS smallest_rupees,
       max(amount_paise) / 100.0           AS largest_rupees
FROM orders;

Output

┌────────┬──────────────┬────────────────┬─────────────────┬────────────────┐
│ orders │ total_rupees │ average_rupees │ smallest_rupees │ largest_rupees │
├────────┼──────────────┼────────────────┼─────────────────┼────────────────┤
│ 6      │ 2090.0       │ 348.33         │ 80.0            │ 990.0          │
└────────┴──────────────┴────────────────┴─────────────────┴────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < summary.sql

Six rows went in and one row came out. Without a GROUP BY clause (the next lesson), the whole table counts as a single group, so each aggregate in the SELECT list produces one value and the query returns exactly one row (a HAVING clause, two lessons on, can remove it).

Two details in this query are worth copying:

  • sum(amount_paise) / 100.0 divides by 100.0, not 100. With a decimal point on one side the division keeps its fraction; you will see below what happens without it.
  • round(…, 2) rounds the average to two decimal places for display. Round only the final value you show, never a number you are still going to calculate with.

NULLs are skipped

Two of the six orders were not rated, so rating is NULL in two rows. Aggregates that take a column ignore the NULLs in it:

What each aggregate counts SQL · nulls.sql
-- The same six orders; two of them have no rating (NULL).
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL,
  rating       INTEGER
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000, 5),
  (2, 'Vikram', 'Kochi',  12000, NULL),
  (3, 'Asha',   'Pune',   30000, 4),
  (4, 'Meera',  'Jaipur',  8000, 2),
  (5, 'Tenzin', 'Pune',   99000, NULL),
  (6, 'Vikram', 'Kochi',  15000, 4);

SELECT count(*)                 AS orders,
       count(rating)            AS rated,
       count(DISTINCT customer) AS customers,
       count(DISTINCT city)     AS cities,
       avg(rating)              AS avg_rating,
       avg(coalesce(rating, 0)) AS avg_if_null_were_0
FROM orders;

Output

┌────────┬───────┬───────────┬────────┬────────────┬────────────────────┐
│ orders │ rated │ customers │ cities │ avg_rating │ avg_if_null_were_0 │
├────────┼───────┼───────────┼────────┼────────────┼────────────────────┤
│ 6      │ 4     │ 4         │ 3      │ 3.75       │ 2.5                │
└────────┴───────┴───────────┴────────┴────────────┴────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < nulls.sql

A rating column of 5, NULL, 4, 2, NULL, 4: count(*) is 6, count(rating) is 4, sum(rating) is 15 and avg(rating) is 3.75.The rating columnof the six orders5 · NULL · 4 · 2 · NULL · 4What each aggregate readscount(*) = 6every rowcount(rating) = 4not the two NULLssum(rating) = 155 + 4 + 2 + 4avg(rating) = 3.7515 ÷ 4, not 15 ÷ 6

What the aggregates make of a column with NULLs

Text description of the diagram

The diagram starts from the rating column of the lesson's six orders: 5, NULL, 4, 2, NULL and 4. An arrow leads to four results.

  • count(*) is 6: it counts every row, whatever the row holds.
  • count(rating) is 4: it counts only the rows where rating is not NULL.
  • sum(rating) is 15: 5 + 4 + 2 + 4, the two NULLs are skipped.
  • avg(rating) is 3.75: the sum 15 divided by the 4 rated rows, not by all 6 rows.
  • count(*) counts rows: all 6, whatever their columns hold.
  • count(rating) counts the rows where rating is not NULL: 4.
  • count(DISTINCT customer) counts different non-NULL values. Asha and Vikram ordered twice, so there are 4 customers in 6 orders, and the orders come from 3 cities.
  • avg(rating) averages the four ratings that exist: (5 + 4 + 2 + 4) / 4 = 3.75. The two unrated orders are left out, not counted as zero.

If your report really should treat a missing rating as 0, say so with coalesce(rating, 0), which replaces each NULL before the average sees it. That answers a different question, and gives a different number: 2.5. Decide which question you are asking before you choose.

When there are no rows

A WHERE clause can leave nothing for the aggregates to read. Here no order comes from Delhi:

Aggregates over zero rows SQL · no_rows.sql
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL,
  rating       INTEGER
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000, 5),
  (2, 'Vikram', 'Kochi',  12000, NULL),
  (3, 'Meera',  'Jaipur',  8000, 2);

-- No order comes from Delhi, so every aggregate below sees zero rows.
-- The query still returns exactly one row.
SELECT count(*)                       AS orders,
       sum(amount_paise)              AS total,
       sum(amount_paise) IS NULL      AS total_is_null,
       coalesce(sum(amount_paise), 0) AS total_or_0,
       total(amount_paise)            AS sqlite_total,
       max(amount_paise)              AS largest
FROM orders
WHERE city = 'Delhi';

Output

┌────────┬───────┬───────────────┬────────────┬──────────────┬─────────┐
│ orders │ total │ total_is_null │ total_or_0 │ sqlite_total │ largest │
├────────┼───────┼───────────────┼────────────┼──────────────┼─────────┤
│ 0      │       │ 1             │ 0          │ 0.0          │         │
└────────┴───────┴───────────────┴────────────┴──────────────┴─────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < no_rows.sql

Note

In these output tables an empty cell is a NULL. The column total_is_null shows it: sum(amount_paise) IS NULL is 1 (true).

The query still returns one row, but most of its values are NULL:

  • count(*) is 0. Counting nothing gives zero.
  • sum, avg, min and max are NULL: there is no total, average, smallest or largest value of nothing. This is what the SQL standard asks for, and PostgreSQL, MySQL and SQLite all do it.
  • For a report that should say 0, wrap the sum: coalesce(sum(amount_paise), 0).

SQLite also has total(), which returns 0.0 when there are no rows. It always returns a floating-point number, and PostgreSQL and MySQL do not have it, so coalesce(sum(…), 0) is the portable choice.

A common mistake: an average by hand

avg() exists, but people often compute an average themselves, as a sum divided by a count. With integer columns that goes wrong quietly:

Integer division cuts off the fraction SQL · integer_average.sql
CREATE TABLE ratings (rating INTEGER);
INSERT INTO ratings VALUES (5), (4), (2), (4);

-- 15 / 4 with two integers is integer division: the fraction is cut off.
SELECT sum(rating) / count(rating) AS by_hand,
       avg(rating)                 AS with_avg
FROM ratings;

Output

┌─────────┬──────────┐
│ by_hand │ with_avg │
├─────────┼──────────┤
│ 3       │ 3.75     │
└─────────┴──────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < integer_average.sql

sum(rating) is 15 and count(rating) is 4, and both are integers. In SQLite and in PostgreSQL, dividing an integer by an integer gives an integer, truncated toward zero, so the result is 3 instead of 3.75. MySQL’s / operator returns a decimal instead (3.7500), which is one more reason not to depend on it. Use avg(), or make one side a decimal before dividing: sum(rating) * 1.0 / count(rating).

MIN and MAX work on text and dates too

min and max are not only for numbers. They compare values the way ORDER BY would sort them, so on text they pick the first and last value in sort order:

MIN and MAX on text SQL · min_max_text.sql
-- MIN and MAX compare text the way ORDER BY sorts it, character by character.
CREATE TABLE deliveries (
  id         INTEGER PRIMARY KEY,
  rider      TEXT,
  iso_date   TEXT,   -- year-month-day: sorts in date order
  dd_mm_yyyy TEXT    -- day-month-year: sorts by the day first
);
INSERT INTO deliveries VALUES
  (1, 'Lalitha', '2026-01-31', '31-01-2026'),
  (2, 'Farhan',  '2026-03-05', '05-03-2026'),
  (3, 'Joseph',  '2025-12-24', '24-12-2025');

SELECT min(rider)      AS first_rider,
       max(iso_date)   AS latest_iso,
       max(dd_mm_yyyy) AS latest_dd_mm_yyyy
FROM deliveries;

Output

┌─────────────┬────────────┬───────────────────┐
│ first_rider │ latest_iso │ latest_dd_mm_yyyy │
├─────────────┼────────────┼───────────────────┤
│ Farhan      │ 2026-03-05 │ 31-01-2026        │
└─────────────┴────────────┴───────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < min_max_text.sql

min(rider) is Farhan, the first name alphabetically. The latest delivery is right in iso_date, because a year-month-day date sorts like the date it stands for. In dd_mm_yyyy the same dates sort by their day first, so max picks 31-01-2026 although 05-03-2026 came later. Store dates in ISO form (2026-03-05) in SQLite, or in a real date type in PostgreSQL and MySQL, and min and max will mean earliest and latest.

Text comparison follows the column’s collation, so letter case and accents can change the answer; a later lesson on collations covers that.

Aggregates do not belong in WHERE

To list the orders above the average amount, the obvious query fails:

An aggregate inside WHERE SQL · aggregate_in_where.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount_paise INTEGER);
INSERT INTO orders VALUES
  (1, 'Asha', 45000), (2, 'Vikram', 12000), (3, 'Asha', 30000),
  (4, 'Meera', 8000), (5, 'Tenzin', 99000), (6, 'Vikram', 15000);

-- Wrong: WHERE runs row by row, before any average exists.
SELECT customer, amount_paise
FROM orders
WHERE amount_paise > avg(amount_paise);

Output (exit status 1)

Printed as an error (standard error)

Parse error near line 7: misuse of aggregate function avg()

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < aggregate_in_where.sql

WHERE looks at one row at a time and decides which rows reach the aggregates, so it is evaluated before any average exists. SQLite rejects the query with “misuse of aggregate function”, and PostgreSQL and MySQL reject it too. One fix is a subquery: a query in brackets that computes the average first, so WHERE can compare every row with that one number.

Compare each row with the average SQL · above_average.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount_paise INTEGER);
INSERT INTO orders VALUES
  (1, 'Asha', 45000), (2, 'Vikram', 12000), (3, 'Asha', 30000),
  (4, 'Meera', 8000), (5, 'Tenzin', 99000), (6, 'Vikram', 15000);

-- The subquery computes the average once (34833.33 paise); WHERE then
-- compares each row with that single number.
SELECT customer, amount_paise
FROM orders
WHERE amount_paise > (SELECT avg(amount_paise) FROM orders)
ORDER BY amount_paise DESC;

Output

┌──────────┬──────────────┐
│ customer │ amount_paise │
├──────────┼──────────────┤
│ Tenzin   │ 99000        │
│ Asha     │ 45000        │
└──────────┴──────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < above_average.sql

When the condition is about the aggregate of each group, such as “customers with more than two orders”, the tool is HAVING, two lessons on.

How the engines differ

All three engines agree on what the aggregates mean. They differ in the types of the results and in what dividing two integers gives:

The basic aggregates in PostgreSQL, MySQL and SQLite
Criterion PostgreSQL 18MySQL 8.4SQLite 3.49
count(…) returns bigintBIGINTan integer
sum of an integer column bigintDECIMALan integer
avg of an integer column numericDECIMALa float
sum, avg, min, max of no rows NULLNULLNULL
15 / 4 33.75003
total(x) NoNoYes: 0.0 for no rows
SQLite Online (SQL Playground) Try these aggregates on a CSV file of your own: import it as a table, and SQLite runs in your browser, as in this lesson.

Key takeaways

  • An aggregate turns many rows into one value. Without GROUP BY (and HAVING), a query with aggregates returns exactly one row.
  • count(*) counts rows; count(column) counts non-NULL values; count(DISTINCT column) counts different non-NULL values.
  • sum, avg, min and max skip NULLs, and return NULL when there are no rows; count returns 0. Use coalesce(sum(x), 0) when a report needs a zero.
  • An integer divided by an integer is truncated in SQLite and PostgreSQL: use avg(), or divide by a decimal such as 100.0.
  • min and max follow sort order, so they work on text, and on dates stored in ISO form.
  • Aggregates cannot appear in WHERE; compare with a subquery, or filter groups with HAVING.

Exercise

Exercise · Easy · SQL

Summarise one month of paid orders

The table orders has one row per order, with these columns:

  • id: the order's number;
  • customer_id: who placed it;
  • placed_at: when, as text such as 2026-03-02 09:30:00;
  • status: paid, cancelled or refunded;
  • amount_paise: the amount in paise (100 paise make a rupee).

Write one query that summarises the paid orders placed in the month 2026-03, that is from 2026-03-01 00:00:00 up to, but not including, 2026-04-01 00:00:00. It returns one row with three columns:

  • orders: how many such orders there are;
  • paying_customers: how many different customers placed them;
  • avg_paid_rupees: their average amount in rupees, rounded to 2 decimal places.

When no order matches, the row is 0, 0 and NULL: that is what count and avg return over no rows, so the query needs no special case. The sample tests run your query on three small tables and compare its result with the expected row.

Starter code · paid_orders.sql

-- The paid orders placed in 2026-03: how many there are, how many different
-- customers placed them, and their average amount in rupees (2 decimal places).
SELECT count(*) AS orders,
       count(*) AS paying_customers,
       avg(amount_paise) AS avg_paid_rupees
FROM orders;
The sample tests · tests.yaml
# Sample tests: each one builds a small orders table, runs your query on it and compares the one row it returns.
tests:
  - name: summarises the paid orders of that month only, and keeps the fraction of the average
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, placed_at TEXT, status TEXT, amount_paise INTEGER);
      INSERT INTO orders VALUES
        (1, 11, '2026-02-27 10:15:00', 'paid',      40000),
        (2, 11, '2026-03-02 09:30:00', 'paid',      25000),
        (3, 12, '2026-03-05 18:45:00', 'cancelled', 90000),
        (4, 13, '2026-03-11 12:00:00', 'paid',      32000),
        (5, 11, '2026-03-20 20:10:00', 'paid',      18000),
        (6, 14, '2026-03-28 08:05:00', 'paid',      47503),
        (7, 15, '2026-04-01 07:00:00', 'paid',      60000);
    expected:
      columns: [orders, paying_customers, avg_paid_rupees]
      rows:
        - [4, 3, 306.26]
  - name: returns 0, 0 and NULL when no paid order falls in the month
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, placed_at TEXT, status TEXT, amount_paise INTEGER);
      INSERT INTO orders VALUES
        (1, 21, '2026-03-09 11:00:00', 'cancelled', 15000),
        (2, 22, '2026-02-14 16:20:00', 'paid',      30000),
        (3, 23, '2026-04-03 09:00:00', 'paid',      12000);
    expected:
      columns: [orders, paying_customers, avg_paid_rupees]
      rows:
        - [0, 0, null]
  - name: includes the month's last second but not the next month's first
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, placed_at TEXT, status TEXT, amount_paise INTEGER);
      INSERT INTO orders VALUES
        (1, 31, '2026-03-31 23:59:59', 'paid',     10050),
        (2, 31, '2026-03-01 00:00:00', 'paid',      9950),
        (3, 32, '2026-04-01 00:00:00', 'paid',     50000),
        (4, 33, '2026-03-15 13:30:00', 'refunded', 70000);
    expected:
      columns: [orders, paying_customers, avg_paid_rupees]
      rows:
        - [2, 1, 100.0]
A hint

Filter first, then summarise: put the status and the date range in WHERE, and the three aggregates in the SELECT list. count(DISTINCT customer_id) counts each customer once, and dates in this form compare correctly as text, so placed_at >= '2026-03-01' works. Divide by 100.0 rather than 100 before you round.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 This script runs in SQLite. What are the three counts it returns, in order?

    Read the code, then choose one answer.

    CREATE TABLE visits (city TEXT);
    INSERT INTO visits VALUES ('Pune'), ('Kochi'), (NULL), ('Pune');
    SELECT count(*), count(city), count(DISTINCT city) FROM visits;
    Show the answer to question 1

    Answer: 4, 3 and 2

    count(*) counts all four rows. count(city) skips the NULL and counts 3. count(DISTINCT city) counts the different non-NULL values, Pune and Kochi, so 2.

  2. Question 2 of 6 A query computes sum(amount), but its WHERE clause matches no rows. What does the sum return in PostgreSQL, MySQL and SQLite?

    Choose one answer.

    Show the answer to question 2

    Answer: NULL

    Over no rows, sum, avg, min and max return NULL and only count returns 0. The query still returns one row, because an aggregate query without GROUP BY or HAVING always does. Write coalesce(sum(amount), 0) when a report needs a zero.

  3. Question 3 of 6 The ratings are 5, 4, 2 and 4. What does this query print?

    What does this program print? Choose one answer.

    CREATE TABLE ratings (rating INTEGER);
    INSERT INTO ratings VALUES (5), (4), (2), (4);
    
    -- 15 / 4 with two integers is integer division: the fraction is cut off.
    SELECT sum(rating) / count(rating) AS by_hand,
           avg(rating)                 AS with_avg
    FROM ratings;
    Show the answer to question 3

    Answer: it prints

    ┌─────────┬──────────┐
    │ by_hand │ with_avg │
    ├─────────┼──────────┤
    │ 3       │ 3.75     │
    └─────────┴──────────┘

    sum(rating) is 15 and count(rating) is 4. Both are integers, so SQLite divides them as integers and cuts the fraction off: 3, not 3.75 and not 4 (nothing is rounded). In SQLite avg() returns a floating-point number even when every input is an integer, so it gives 3.75.

  4. Question 4 of 6 The column rating contains some NULLs. Which of these leave the NULL rows out of their result?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • avg(rating)
    • count(rating)
    • max(rating)

    Aggregates that take a column skip its NULLs, so count(rating), avg(rating) and max(rating) only see the rated rows. count(*) counts rows, NULLs or not. coalesce(rating, 0) turns every NULL into 0 before avg sees it, so those rows are averaged in as zeros.

  5. Question 5 of 6 A rating column holds 5, NULL, 3, NULL and 4. What does avg(rating) return?

    Type a number.

    Show the answer to question 5

    Answer: 4

    avg skips the two NULLs and averages the three ratings that exist: (5 + 3 + 4) / 3 = 4. SQLite shows it as 4.0, a floating-point number.

  6. Question 6 of 6 Why is SELECT * FROM orders WHERE amount_paise > avg(amount_paise) rejected?

    Choose one answer.

    Show the answer to question 6

    Answer: WHERE decides which rows reach the aggregates, so it is evaluated before any average exists

    WHERE works row by row and chooses the rows the aggregates will read, so an aggregate cannot be part of it. All three engines reject the query. Compute the average in a subquery, (SELECT avg(amount_paise) FROM orders), and compare each row with that, or use HAVING for conditions on the aggregates of groups.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.