Skip to content
/ Chapter 10 · Subqueries and EXISTS
Lesson 10.7 · garden_shop
Lesson 10.7

SQL Subqueries in SELECT and HAVING

A scalar subquery can go anywhere a single value can: as a column beside each group, or on the right side of a HAVING comparison.

In Lesson 10.1 · Scalar subqueries, the subquery sat in WHERE, comparing each product with the average price. A scalar subquery returns one value, so it can go anywhere a single value can. Two places come up constantly in reports: the SELECT list, to show a total beside each row, and HAVING, to filter groups against it.

A total beside every group

What share of all units sold does each category account for? The grouped query gives each category's units. The total across every category comes from a subquery in the SELECT list:

Each category's share of units sold
select
  c.category_name,
  sum(oi.quantity) as units,
  round(100.0 * sum(oi.quantity) / (
    select sum(quantity)
    from order_items
  ), 1) as pct_of_units
from order_items as oi
join products as p on p.product_id = oi.product_id
join categories as c on c.category_id = p.category_id
group by c.category_name
order by units desc, c.category_name

The subquery has no GROUP BY and doesn't refer to the outer query, so it returns the same number, 87, on every row. Houseplants sold 23 of those units, 26.4%. A subquery like this, one that doesn't depend on the outer row, runs once for the whole query, not once per group.

The 100.0 matters. Units are whole numbers, and in PostgreSQL and SQL Server an integer divided by an integer drops the fraction, so 100 * 23 / 87 comes out as 26. DuckDB's / always keeps the fraction, but writing 100.0 keeps the query correct everywhere.

Filtering groups against the total

Which categories average more than the store as a whole? The comparison is between an aggregate, each category's avg(price), and one number, the store-wide average. Comparisons on aggregates belong in HAVING (Lesson 4.4), and the subquery supplies the number:

Categories above the store average
select c.category_name, round(avg(p.price), 2) as avg_price
from products as p
join categories as c on c.category_id = p.category_id
group by c.category_name
having avg(p.price) > (
  select avg(price)
  from products
)
order by avg_price desc

Three of the eight categories clear the store average of 14.36. Put the same condition in WHERE instead and DuckDB stops with Binder Error: WHERE clause cannot contain aggregates!, because WHERE filters rows before they're grouped, so there's no category average yet to compare. The subquery itself is fine in either clause. It's the avg(p.price) on the left that needs HAVING.

When the subquery returns nothing, or too much

The two rules from Lesson 10.1 still apply. A subquery that matches no rows returns NULL, so a share calculated against it is NULL on every row, and a HAVING comparison against it keeps no groups. No error, just an empty or blank result. A subquery that returns more than one row stops the query with More than one row returned by a subquery. An aggregate with no GROUP BY, like the two above, always returns exactly one row, which is why it's the usual choice.

Writing the subquery once

The exercise below needs the store average three times: as a column, inside a difference, and in HAVING. Repeating a short subquery is normal, and every major database accepts it. When it grows, two later lessons offer a way to write it once: a common table expression (Lesson 11.5) names it at the top of the query, and avg(price) over (), a window function (Lesson 11.4), puts the overall average on every row with no subquery at all.

Schema · Garden Shop Tables · products, categories 2 tables
Table · products

One row per product, with price, cost, and inventory levels.

9 columns · 24 rows
product_id int product_name text category_id int supplier_id int price decimal cost decimal quantity_on_hand int reorder_level int discontinued bool
Table · categories

Lookup table of product categories.

2 columns · 8 rows
category_id int category_name text
Your task

The starter lists every category's average price. Add the store-wide average as store_avg and the gap as above_by (category average minus store average), both rounded to 2 places, and keep only the categories that average more than the store as a whole. Return category_name, avg_price, store_avg, and above_by, highest average first.

·

Run your query to inspect the result.

Expected answer
  • Columns: category_name, avg_price, store_avg, above_by.
  • Three categories: Houseplants, Watering, and Outdoor Decor. store_avg is 14.36 on every row.
  • The other five categories average below 14.36, so HAVING drops them.