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

GROUP BY in SQL: one row per group

How GROUP BY splits rows into groups and returns one row for each, which columns a grouped query may select, and how PostgreSQL, MySQL and SQLite enforce it.

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

What you will learn

  • Group rows by one or more columns or expressions
  • Apply the rule that selected columns must be grouped or aggregated
  • Recognise how PostgreSQL, MySQL and SQLite enforce that rule

Before you start

On this page

The previous lesson summarised a whole table into one row. Usually you want one summary per something: orders per city, revenue per month, students per course. GROUP BY does that. It puts the rows into groups that share the same values, runs the aggregates once for each group, and returns one row per group.

Every query below runs in the browser’s SQLite, version 3.49.1 through sql.js 1.14.2. The last part of the lesson shows how PostgreSQL and MySQL treat the same queries.

One row per city

Orders and revenue per city SQL · per_city.sql
-- Seven orders; the last one was collected from the shop, so it has no city (NULL).
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT,
  amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
  (1, 'Asha',     'Pune',   45000),
  (2, 'Vikram',   'Kochi',  12000),
  (3, 'Asha',     'Pune',   30000),
  (4, 'Meera',    'Jaipur',  8000),
  (5, 'Tenzin',   'Pune',   99000),
  (6, 'Vikram',   'Kochi',  15000),
  (7, 'Gurpreet', NULL,     20000);

-- One output row per city: the rows of each city are counted and added up.
SELECT city,
       count(*)                  AS orders,
       sum(amount_paise) / 100.0 AS revenue_rupees
FROM orders
GROUP BY city
ORDER BY orders DESC, city;

Output

┌────────┬────────┬────────────────┐
│  city  │ orders │ revenue_rupees │
├────────┼────────┼────────────────┤
│ Pune   │ 3      │ 1740.0         │
│ Kochi  │ 2      │ 270.0          │
│        │ 1      │ 200.0          │
│ Jaipur │ 1      │ 80.0           │
└────────┴────────┴────────────────┘

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

Seven orders are sorted into four groups by city, Pune, Kochi, NULL and Jaipur, and each group becomes one row with its count and total.orders: 7 rows, in rupeesPune 450 · Kochi 120Pune 300 · Jaipur 80Pune 990 · Kochi 150no city 200GROUP BY cityOne row per groupPune: 3 orders, 1740.0Kochi: 2 orders, 270.0NULL: 1 order, 200.0Jaipur: 1 order, 80.0Pune450 + 300+ 990Kochi120 + 150NULL(no city)200Jaipur80grouped by citycount(*) and sum()once per group

GROUP BY city turns seven rows into four

Text description of the diagram

The diagram shows the lesson's seven orders, from top to bottom, with amounts in rupees.

  1. The table has seven rows: Pune 450, Kochi 120, Pune 300, Jaipur 80, Pune 990, Kochi 150, and one order with no city for 200.
  2. GROUP BY city puts them into four groups: Pune (450, 300 and 990), Kochi (120 and 150), NULL for the order without a city (200), and Jaipur (80).
  3. count(*) and sum() run once for each group, so the result has one row per group: Pune with 3 orders and 1740.0, Kochi with 2 orders and 270.0, NULL with 1 order and 200.0, and Jaipur with 1 order and 80.0.

Seven rows went in and four came out, one for each different value of city:

  • The three Pune orders became one row whose count(*) is 3 and whose sum is their three amounts added up.
  • The order with no city did not vanish. All rows whose city is NULL form one group of their own, shown with an empty cell. Grouping treats NULLs as equal to each other, unlike = in a WHERE clause.
  • ORDER BY sorts the groups. Without it the groups come back in whatever order the engine finds convenient, which can change from one version or one plan to the next, so always sort a grouped result you show to someone.

Several columns and expressions

GROUP BY takes a list. Each different combination of the listed values is a group, so grouping by month and category gives one row for every month-and-category pair that occurs in the data:

Revenue per month and category SQL · month_category.sql
-- One row per product on an order. placed_on is an ISO date stored as text.
CREATE TABLE order_lines (
  order_id    INTEGER,
  placed_on   TEXT,
  category    TEXT,
  product     TEXT,
  qty         INTEGER,
  price_paise INTEGER
);
INSERT INTO order_lines VALUES
  (101, '2026-01-04', 'Staples', 'Basmati rice 5 kg', 1, 64000),
  (101, '2026-01-04', 'Dairy',   'Paneer 200 g',      2,  9000),
  (102, '2026-01-19', 'Snacks',  'Banana chips',      4,  6000),
  (103, '2026-01-27', 'Staples', 'Toor dal 1 kg',     2, 16500),
  (104, '2026-02-02', 'Dairy',   'Curd 500 g',        2,  4500),
  (104, '2026-02-02', 'Staples', 'Atta 5 kg',         1, 28000),
  (105, '2026-02-14', 'Snacks',  'Bhujia 400 g',      1, 11000),
  (105, '2026-02-14', 'Dairy',   'Paneer 200 g',      1,  9000);

-- Group by an expression (the month) and a column: one row per month and category.
SELECT substr(placed_on, 1, 7)         AS month,
       category,
       sum(qty * price_paise) / 100.0  AS revenue_rupees
FROM order_lines
GROUP BY month, category
ORDER BY month, revenue_rupees DESC, category;

Output

┌─────────┬──────────┬────────────────┐
│  month  │ category │ revenue_rupees │
├─────────┼──────────┼────────────────┤
│ 2026-01 │ Staples  │ 970.0          │
│ 2026-01 │ Snacks   │ 240.0          │
│ 2026-01 │ Dairy    │ 180.0          │
│ 2026-02 │ Staples  │ 280.0          │
│ 2026-02 │ Dairy    │ 180.0          │
│ 2026-02 │ Snacks   │ 110.0          │
└─────────┴──────────┴────────────────┘

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

The month is not a column of the table: substr(placed_on, 1, 7) cuts 2026-01 out of 2026-01-04. You can group by any expression, and refer to it in three ways:

  • by repeating the expression, GROUP BY substr(placed_on, 1, 7), category;
  • by the alias given in the SELECT list, GROUP BY month, category, as this query does;
  • by position, GROUP BY 1, 2 for the first and second output columns.

All three engines accept all three forms. Grouping by an output alias goes beyond the SQL standard, which groups by input columns only, and MySQL’s manual calls the positions deprecated, so prefer names. With a real date column instead of text, PostgreSQL and MySQL have their own functions for “the month of this date”; the dates lesson shows them.

The rule: grouped or aggregated

Once rows are grouped, each output row stands for a whole group. A column you select must therefore have one value per group. That holds for the columns you grouped by and for aggregates, which turn the group’s values into one. Anything else is a problem:

A column that is neither grouped nor aggregated SQL · bare_column.sql
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT,
  amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000),
  (2, 'Vikram', 'Kochi',  12000),
  (3, 'Asha',   'Pune',   30000),
  (4, 'Meera',  'Jaipur',  8000),
  (5, 'Tenzin', 'Pune',   99000),
  (6, 'Vikram', 'Kochi',  15000);

-- customer is neither grouped nor aggregated: Pune has two different customers,
-- so which one should its row show? SQLite answers anyway.
SELECT city, customer, count(*) AS orders
FROM orders
GROUP BY city
ORDER BY city;

Output

┌────────┬──────────┬────────┐
│  city  │ customer │ orders │
├────────┼──────────┼────────┤
│ Jaipur │ Meera    │ 1      │
│ Kochi  │ Vikram   │ 2      │
│ Pune   │ Asha     │ 3      │
└────────┴──────────┴────────┘

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

Pune has orders from Asha and from Tenzin, yet its row shows Asha. SQLite took the value from one of the group’s rows, and nothing in the query says which one: on other data, another version or another plan it could be Tenzin. SQLite calls such a column a bare column and documents it as its own extension. PostgreSQL and MySQL refuse the query instead:

  • PostgreSQL rejects any selected column that is not grouped, not inside an aggregate, and not functionally dependent on the grouped columns.
  • MySQL 8.4 rejects it with error 1055, because its default SQL mode includes ONLY_FULL_GROUP_BY.

The error is the more helpful answer. Decide what you meant: if you wanted each customer, group by them too; if you wanted one example customer per city and any of them will do, say so with an aggregate such as min(customer).

The SQLite exception for MIN and MAX

SQLite gives bare columns one useful, documented meaning. When the query has exactly one min() or max(), the bare columns come from the row that holds that minimum or maximum:

The row that holds the maximum SQL · bare_column_max.sql
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT,
  amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000),
  (2, 'Vikram', 'Kochi',  12000),
  (3, 'Asha',   'Pune',   30000),
  (4, 'Meera',  'Jaipur',  8000),
  (5, 'Tenzin', 'Pune',   99000),
  (6, 'Vikram', 'Kochi',  15000);

-- SQLite only: with a single max(), the bare columns come from the row that has the maximum.
SELECT city, max(amount_paise) AS largest_paise, customer, id
FROM orders
GROUP BY city
ORDER BY city;

Output

┌────────┬───────────────┬──────────┬────┐
│  city  │ largest_paise │ customer │ id │
├────────┼───────────────┼──────────┼────┤
│ Jaipur │ 8000          │ Meera    │ 4  │
│ Kochi  │ 15000         │ Vikram   │ 6  │
│ Pune   │ 99000         │ Tenzin   │ 5  │
└────────┴───────────────┴──────────┴────┘

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

For Pune the largest order is 99000 paise, and customer and id come from that row: Tenzin, order 5. This works only in SQLite, and if two rows tie for the maximum it picks one of them arbitrarily. In PostgreSQL and MySQL you get “the row with the largest value per group” with a window function or a join, which later modules cover.

Functional dependence and ANY_VALUE

Both PostgreSQL and MySQL accept an ungrouped column when it is functionally dependent on the grouped ones, which in practice means you grouped by a table’s primary key: one key value means one row, so every other column of that table has a single value per group. (PostgreSQL recognises only the primary key; MySQL also accepts a UNIQUE column declared NOT NULL.) This matters once you join tables: grouping by customers.id lets you select customers.name as well.

When you know a column has the same value in every row of each group but the engine cannot prove it, PostgreSQL (from version 16) and MySQL offer any_value(column): it returns a value from the group and tells the engine you accept any of them. SQLite 3.49 has no any_value(); its bare columns already behave that way.

A common mistake: grouping by too much

When an engine complains that customer is not grouped, adding it to GROUP BY makes the error go away. It also changes the question:

Grouping by city and customer SQL · too_many_columns.sql
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  city         TEXT,
  amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
  (1, 'Asha',   'Pune',   45000),
  (2, 'Vikram', 'Kochi',  12000),
  (3, 'Asha',   'Pune',   30000),
  (4, 'Meera',  'Jaipur',  8000),
  (5, 'Tenzin', 'Pune',   99000),
  (6, 'Vikram', 'Kochi',  15000);

-- Meant: orders per city. Adding customer to GROUP BY "to make the error go away"
-- changes the question: now each group is one customer in one city.
SELECT city, customer, count(*) AS orders
FROM orders
GROUP BY city, customer
ORDER BY city, customer;

Output

┌────────┬──────────┬────────┐
│  city  │ customer │ orders │
├────────┼──────────┼────────┤
│ Jaipur │ Meera    │ 1      │
│ Kochi  │ Vikram   │ 2      │
│ Pune   │ Asha     │ 2      │
│ Pune   │ Tenzin   │ 1      │
└────────┴──────────┴────────┘

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

Now each group is one customer in one city, so Pune is split into two rows and no row shows Pune’s total of three orders. Every column in GROUP BY makes the groups smaller. List exactly the columns that define “one row per …” in your question, and aggregate the rest.

How the engines differ

GROUP BY in PostgreSQL, MySQL and SQLite
Criterion PostgreSQL 18MySQL 8.4SQLite 3.49
A selected column that is not grouped or aggregated ErrorError 1055Accepted: a value from one row of the group
Ungrouped column of a grouped primary key AcceptedAcceptedAccepted
GROUP BY an output alias or a position AcceptedAccepted (positions are deprecated)Accepted
any_value(column) From version 16YesNo
Rows whose grouped value is NULL One groupOne groupOne group
SQLite Online (SQL Playground) Group the rows of your own CSV file: import it as a table, and SQLite runs in your browser, like these examples.

Key takeaways

  • GROUP BY turns the rows that share the same grouped values into one output row, and the aggregates are computed per group.
  • You can group by several columns and by expressions; each different combination of values is a group, and NULLs form one group.
  • Every selected column must be grouped, aggregated or functionally dependent on a grouped primary key. PostgreSQL and MySQL enforce this; SQLite returns a value from an arbitrary row of the group instead.
  • In SQLite only, a single min() or max() makes the bare columns come from the row that holds it.
  • Sort grouped results with ORDER BY, and do not add columns to GROUP BY just to silence an error: it changes what each row means.

Exercise

Exercise · Easy · SQL

Revenue per category and month

The table order_lines has one row per product on an order:

  • order_id: the order;
  • placed_on: the day it was placed, as text such as 2026-01-03;
  • category: the product's category, such as Dairy;
  • qty: how many were bought;
  • price_paise: the price of one, in paise (100 paise make a rupee).

Write a query that returns one row for every month of the year 2026 and every category that sold something in that month, with three columns:

- month: the month as text, such as 2026-01; - category; - revenue_rupees: the month's revenue for that category in rupees, that is the sum of qty times price_paise, divided by 100.0.

Leave out every line that was not placed in 2026. Sort the rows by month, oldest first, then by revenue, highest first; when two categories of a month have the same revenue, put them in alphabetical order. The order of the rows is checked.

Starter code · revenue.sql

-- Revenue per month and category for 2026: months oldest first,
-- then the highest revenue first, ties in alphabetical order.
SELECT placed_on AS month,
       category,
       price_paise / 100.0 AS revenue_rupees
FROM order_lines;
The sample tests · tests.yaml
# Sample tests: each one builds a small order_lines table, runs your query on it and compares the rows it returns,
# in order.
tests:
  - name: one row per month and category of the year, in the order asked for
    ordered: true
    setup: |
      CREATE TABLE order_lines (order_id INTEGER, placed_on TEXT, category TEXT, product TEXT, qty INTEGER, price_paise INTEGER);
      INSERT INTO order_lines VALUES
        (1, '2025-12-30', 'Staples', 'Atta 5 kg',         1, 28000),
        (2, '2026-01-03', 'Staples', 'Toor dal 1 kg',     2, 16500),
        (2, '2026-01-03', 'Dairy',   'Ghee 500 ml',       1, 39000),
        (3, '2026-01-21', 'Snacks',  'Murukku 200 g',     3,  4000),
        (3, '2026-01-21', 'Staples', 'Basmati rice 5 kg', 1, 64000),
        (4, '2026-02-09', 'Dairy',   'Paneer 200 g',      2,  9000),
        (4, '2026-02-09', 'Snacks',  'Banana chips',      3,  6000),
        (5, '2026-02-25', 'Staples', 'Sugar 1 kg',        2,  4800);
    expected:
      columns: [month, category, revenue_rupees]
      rows:
        - ['2026-01', 'Staples', 970.0]
        - ['2026-01', 'Dairy', 390.0]
        - ['2026-01', 'Snacks', 120.0]
        - ['2026-02', 'Dairy', 180.0]
        - ['2026-02', 'Snacks', 180.0]
        - ['2026-02', 'Staples', 96.0]
  - name: multiplies the quantity by the price and keeps the paise
    ordered: true
    setup: |
      CREATE TABLE order_lines (order_id INTEGER, placed_on TEXT, category TEXT, product TEXT, qty INTEGER, price_paise INTEGER);
      INSERT INTO order_lines VALUES
        (1, '2026-03-05', 'Fruit', 'Mango 1 kg',     3, 15000),
        (2, '2026-03-18', 'Fruit', 'Banana 1 dozen', 2,  6025);
    expected:
      columns: [month, category, revenue_rupees]
      rows:
        - ['2026-03', 'Fruit', 570.5]
  - name: keeps the last day of the year and nothing from the years around it
    ordered: true
    setup: |
      CREATE TABLE order_lines (order_id INTEGER, placed_on TEXT, category TEXT, product TEXT, qty INTEGER, price_paise INTEGER);
      INSERT INTO order_lines VALUES
        (1, '2025-12-31', 'Dairy', 'Butter 100 g', 1, 5600),
        (2, '2026-12-31', 'Dairy', 'Butter 100 g', 2, 5600),
        (3, '2027-01-01', 'Dairy', 'Butter 100 g', 4, 5600);
    expected:
      columns: [month, category, revenue_rupees]
      rows:
        - ['2026-12', 'Dairy', 112.0]
A hint

substr(placed_on, 1, 7) cuts the month out of a date such as 2026-01-03. Group by that month and by the category, and let sum() add up qty * price_paise for each group. ORDER BY takes a list, and each item can have its own ASC or DESC.

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

5 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 5 A table has 7 orders: 3 from Pune, 2 from Kochi, 1 from Jaipur and 1 whose city is NULL. How many rows does SELECT city, count(*) FROM orders GROUP BY city return?

    Type a number.

    Show the answer to question 1

    Answer: 4

    One row per different value of city: Pune, Kochi and Jaipur, plus one group for all the rows whose city is NULL. Grouping treats NULLs as equal, so the NULL order is neither dropped nor given a group per row.

  2. Question 2 of 5 Pune has orders from two different customers. Which engines refuse SELECT city, customer, count(*) FROM orders GROUP BY city?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • PostgreSQL 18
    • MySQL 8.4 with its default SQL mode

    customer is neither grouped nor aggregated, so a group can hold several values of it. PostgreSQL rejects the query, and so does MySQL, whose default SQL mode includes ONLY_FULL_GROUP_BY (error 1055). SQLite runs it and shows the customer of one of the group's rows; which row is not defined.

  3. Question 3 of 5 In SQLite, what does the customer column show in SELECT city, max(amount_paise), customer FROM orders GROUP BY city?

    Choose one answer.

    Show the answer to question 3

    Answer: For each city, the customer of the order with the largest amount (one of them if two orders tie)

    With exactly one min() or max() in the query, SQLite takes the bare columns from the row that holds the minimum or maximum. This is an SQLite extension: PostgreSQL and MySQL reject the query, and they need a window function or a join for the same result.

  4. Question 4 of 5 A query with GROUP BY city fails in PostgreSQL because it also selects customer. You add customer to the GROUP BY list. What changes?

    Choose one answer.

    Show the answer to question 4

    Answer: It runs, but each row is now one customer in one city, so a city with two customers gets two rows

    Every column in GROUP BY makes the groups finer. Grouping by city and customer answers "how many orders did each customer place in each city", which is a different question from "how many orders per city". Group by what defines one row of your answer, and aggregate the rest.

  5. Question 5 of 5 All three engines accept these ways of grouping by the first output column. Which one does the MySQL manual call deprecated?

    Choose one answer.

    Show the answer to question 5

    Answer: GROUP BY 1, a column position

    MySQL's manual says column positions are deprecated because the syntax was removed from the SQL standard. They also break silently when someone reorders the SELECT list, so grouping by names or expressions is safer in every engine.

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.