← All posts

Column must appear in the GROUP BY clause: what it means

Adding the column to GROUP BY silences PostgreSQL and changes the question. On the same data, the fixed query drops a customer who spent 590.

Artur Kot 11 min read

Three customers, eight orders and one question: how much each customer spent and what they bought. The first draft of the query looks reasonable:

SELECT customer_id, product, sum(amount) AS total
FROM orders
GROUP BY customer_id;

PostgreSQL 17 answers with this:

ERROR:  column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT customer_id, product, sum(amount) AS total FROM order...
                            ^

If you’re learning aggregation, a column that must appear in the GROUP BY clause is probably the first error you’ll hit. The caret points at product, and the message offers two ways out. The first one, adding the column to GROUP BY, makes the error go away.

That’s exactly why it’s dangerous.

What PostgreSQL is objecting to

GROUP BY turns many rows into one row per group. Customer 1 has three orders, so the result gets one row for them with one total field. The sum is no problem: three amounts added together make one number.

The product column is a different story.

Those three orders are a keyboard, a mouse and a second keyboard. The database has to put one product name into one field, and it doesn’t know which. It could pick any of them, but then the answer would depend on the order the rows happen to sit on disk, and you couldn’t reproduce it. PostgreSQL refuses instead.

With \set VERBOSITY verbose in psql, the same error also shows its code and where in the server’s source it was raised:

ERROR:  42803: column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LOCATION:  check_ungrouped_columns_walker, parse_agg.c:1492

SQLSTATE 42803 is grouping_error. The server raises it while the query is being analysed, before the server reads a single row, so an empty table raises it just like a full one. I checked: a temporary table with no rows gives the same message.

ORDER BY follows the same rule. A query whose SELECT list holds only the customer and the sum, but which sorts by product, fails the same way, and this time the caret lands at the end of the line:

ERROR:  column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: ... AS total FROM orders GROUP BY customer_id ORDER BY product;
                                                               ^

Sorting groups by product has the same problem as showing the product. Each group has several, so there’s nothing to sort on.

WHERE doesn’t know this rule, because it runs earlier. It filters individual rows before any group exists, so it can use any column of the table. A query that sums only the mice each customer bought runs fine and returns two customers at 90 each. The other direction doesn’t work. A sum only exists after grouping, so a condition on a sum in WHERE fails with the same SQLSTATE:

ERROR:  42803: aggregate functions are not allowed in WHERE
LINE 1: SELECT customer_id, sum(amount) FROM orders WHERE sum(amount...
                                                          ^

Both messages describe one sequence. WHERE picks rows, GROUP BY folds them into groups, and only then do SELECT, HAVING and ORDER BY see the grouped result. Once that order is in your head, you’ll rarely see either error.

So the message isn’t saying “you forgot a keyword”. It’s saying: this column has several values per group, and you asked for one.

How SQLite answers the same query

The error is easy to read as PostgreSQL being pedantic. SQLite shows what happens without it. The same query on the same data in SQLite 3.50.4 goes through without a word:

(1, 'keyboard', 590)
(2, 'monitor', 1125)
(3, 'mouse', 425)

Looks like an answer. Now insert the same eight orders in reverse order and run the same query:

(1, 'keyboard', 590)
(2, 'cable', 1125)
(3, 'cable', 425)

Customer 2 bought one monitor and one cable. SQLite filled product from one of the group’s rows, and which one depended on the order it scanned the table in. Changing the insert order changed the report. The sums are right; the product names mean nothing.

The reflex fix, and seven rows instead of three

The shortest path to a query without an error is to do what the first half of the message suggests:

SELECT customer_id, product, sum(amount) AS total
FROM orders
GROUP BY customer_id, product;

The result:

 customer_id | product  |  total
-------------+----------+---------
           1 | keyboard |  500.00
           1 | mouse    |   90.00
           2 | cable    |   25.00
           2 | monitor  | 1100.00
           3 | cable    |   25.00
           3 | headset  |  310.00
           3 | mouse    |   90.00
(7 rows)

The error is gone, and so is the question you asked. The group isn’t the customer any more; it’s the pair of customer and product. The query now totals each customer’s spending per product. Three rows became seven.

On a short list you’d spot that. It’s worse when the result feeds something else, like a filter. Say you want customers who spent more than 500 in total. Grouped by customer:

SELECT customer_id, sum(amount) AS total
FROM orders
GROUP BY customer_id
HAVING sum(amount) > 500;
 customer_id |  total
-------------+---------
           1 |  590.00
           2 | 1125.00
(2 rows)

The same filter on the reflex-fixed query:

 customer_id | product |  total
-------------+---------+---------
           2 | monitor | 1100.00
(1 row)

Customer 1, Anna, spent 590 and dropped off the list. She never spent more than 500 on any single product, and HAVING now checks the total per customer and product pair, not per customer. No error, no warning. Just a list that looks plausible and is wrong.

Monthly reports break the same way. Give the orders an ordered_at date and ask for sales per month. Grouping by date_trunc('month', ordered_at) is right, but leave the raw date in SELECT and the familiar error comes back. Add ordered_at to GROUP BY and you get eight rows, one per order date, because every order here has a different date. The monthly report turns into a daily one. Drop the raw date and three months remain:

   month    | orders |  total
------------+--------+---------
 2026-09-01 |      3 |  590.00
 2026-10-01 |      3 | 1215.00
 2026-11-01 |      2 |  335.00
(3 rows)

The GROUP BY error stops the query before it returns anything. A wrong grouping runs and returns a wrong result that someone pastes into a spreadsheet.

Which value did you want?

The fix depends on what product was supposed to hold. There are four common answers, and each leads to a different query.

Every product in one field. Then the column needs an aggregate too, just like amount. string_agg joins the values into a string:

SELECT customer_id,
       string_agg(product, ', ' ORDER BY product) AS products,
       sum(amount) AS total
FROM orders
GROUP BY customer_id;
 customer_id |         products          |  total
-------------+---------------------------+---------
           1 | keyboard, keyboard, mouse |  590.00
           2 | cable, monitor            | 1125.00
           3 | cable, headset, mouse     |  425.00
(3 rows)

Three rows, matching the question.

Watch out for max(product), which also silences the error. For Anna it returns mouse, because “mouse” sorts last alphabetically, even though the most expensive thing she bought was a keyboard. Asking for the biggest purchase needs a different tool from an aggregate.

The product from the biggest order. That’s a question about one specific row per group, and PostgreSQL has DISTINCT ON for it. It keeps the first row of each group in the order you give:

SELECT DISTINCT ON (customer_id) customer_id, product, amount
FROM orders
ORDER BY customer_id, amount DESC;
 customer_id | product  | amount
-------------+----------+---------
           1 | keyboard |  250.00
           2 | monitor  | 1100.00
           3 | headset  |  310.00
(3 rows)

This time Anna gets the keyboard, because it was the most expensive. She bought two keyboards at 250, though, so two of her rows tie. DISTINCT ON picks one and doesn’t promise which. If the result has to be repeatable, add a tie-breaking column such as id to ORDER BY.

Data about the customer, not the order. This is the most common case in practice: you want the name and city from customers next to the total. Here PostgreSQL has a rule that’s easy to miss. If you group by a table’s primary key, you can select its other columns without listing them, because the key determines them:

SELECT c.id, c.name, c.city, sum(o.amount) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
 id |  name  |  city  |  total
----+--------+--------+---------
  1 | Anna   | Kraków |  590.00
  2 | Bartek | Gdańsk | 1125.00
  3 | Celina | Poznań |  425.00
(3 rows)

Group by the name instead of c.id and the error is back:

ERROR:  column "c.city" must appear in the GROUP BY clause or be used in an aggregate function

A name isn’t a key. Two Annas from different cities are a perfectly possible state for this table, and the database doesn’t guess.

The rule covers the primary key and nothing else. I added an email column with UNIQUE NOT NULL, which also identifies a customer, and grouping by it still fails with column "c.name" in the message. PostgreSQL only recognises functional dependency on a primary key. When you group by something else and need the customer’s columns, either list them in GROUP BY knowing they won’t split anything, or compute the totals in a subquery and join the customers to it:

SELECT c.name, c.city, t.total
FROM customers c
JOIN (SELECT customer_id, sum(amount) AS total
      FROM orders
      GROUP BY customer_id) t ON t.customer_id = c.id;

Same three rows, with name, city and total. The subquery does one job, counting, and the outer query attaches the description. In longer reports that split reads better than one GROUP BY with ten columns, half of them there only to keep the parser quiet.

Every order, with the customer’s total next to it. If you want to keep all the rows, GROUP BY is the wrong tool, because folding rows is its whole job. A window function computes the group total and folds nothing:

SELECT customer_id, product, amount,
       sum(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
 customer_id | product  | amount  | customer_total
-------------+----------+---------+----------------
           1 | keyboard |  250.00 |         590.00
           1 | mouse    |   90.00 |         590.00
           1 | keyboard |  250.00 |         590.00
           2 | monitor  | 1100.00 |        1125.00
           2 | cable    |   25.00 |        1125.00
           3 | mouse    |   90.00 |         425.00
           3 | headset  |  310.00 |         425.00
           3 | cable    |   25.00 |         425.00
(8 rows)

Eight rows, every order, each with the right customer total. product doesn’t need to appear in any clause, because no row was merged with anything and each keeps its own values.

One question before you add the column

Before you add a column because the message says it must appear in the GROUP BY clause, say in one sentence what one row of the result is. “One row is one customer” means the customer is in GROUP BY and every other column is either an aggregate or depends on the customer’s key. “One row is one order” means a window function, and “one row is a chosen order per customer” means DISTINCT ON. Adding the column is right only when it belongs to the answer to “what is one row”, and wrong when it’s there because leaving it out blocks the query.

Aggregation and window functions are two separate chapters in the DevJourney for SQL curriculum, “Aggregation” and “Window functions”. You write the queries for both on the PostgreSQL 17 built into the app, against a database that already has data in it.

Topics: Errors and messagesDebugging

The download has started

DevJourney.for.SQL_1.1.1_x64-setup.exe · 1.1.1

If your system warns you, and how to check the file

If Windows warns you

"Windows protected your PC" is not a virus detection. SmartScreen trusts the certificate an installer is signed with, and this one is still earning its reputation through a download count.

In the warning: More info → Run anyway.

Confirm it yourself

Check this download on VirusTotal ↗

Or in PowerShell, where you downloaded it:

Get-FileHash .\DevJourney.for.SQL_1.1.1_x64-setup.exe

Should print:

4ecca1fdad13a2b141b225e2a9a60e7eca5182a47da0e2662ee2c1754a77aeca