The essentials

Quick reference

One focused task per row. Jump to the related section for complete, working examples.

UseSyntaxExamples
Count rowsSELECT COUNT(*) FROM orders;View examples
Count non-null valuesSELECT COUNT(shipped_at) FROM orders;View examples
Count distinct valuesSELECT COUNT(DISTINCT customer_id) FROM orders;View examples
Sum and average valuesSELECT SUM(amount), AVG(amount) FROM payments;View examples
Find range endpointsSELECT MIN(created_at), MAX(created_at) FROM events;View examples
Group equal valuesSELECT status, COUNT(*) FROM orders GROUP BY status;View examples
Group by several expressionsSELECT region, status, SUM(amount) FROM orders GROUP BY region, status;View examples
Filter detail rowsSELECT team, SUM(score) FROM results WHERE season = 2026 GROUP BY team;View examples
Filter summary groupsSELECT team, SUM(score) FROM results GROUP BY team HAVING SUM(score) >= 100;View examples
Filter one aggregateCOUNT(*) FILTER (WHERE status = 'paid') AS paid_countView examples
Sum selected valuesSUM(amount) FILTER (WHERE status = 'paid') AS paid_totalView examples
Join text in orderSTRING_AGG(name, ', ' ORDER BY name) AS namesView examples
Collect values in orderARRAY_AGG(id ORDER BY created_at, id) AS idsView examples
Require every conditionBOOL_AND(approved) AS all_approvedView examples
Choose several summariesGROUP BY GROUPING SETS ((region, product), (region), ())View examples
Build hierarchical subtotalsGROUP BY ROLLUP (region, product)View examples
Build all subtotal combinationsGROUP BY CUBE (region, channel)View examples
Identify subtotal columnsGROUPING(region, product) AS grouping_maskView examples
Default an empty totalCOALESCE(SUM(amount), 0) AS totalView examples

Aggregation turns sets of rows into summary values. Filter detail rows before grouping, select only grouped expressions or aggregates, order aggregates whose result depends on order, and use grouping indicators when subtotal rows can otherwise be confused with real null values.

Step by step

Detailed examples

01

Choose the aggregate that matches the question

COUNT(*) counts rows, while COUNT(expression) ignores null results and COUNT(DISTINCT expression) also removes duplicates. Most aggregates ignore null inputs; SUM, AVG, MIN, and MAX return null rather than zero for no input rows. Numeric return types vary with the input type, so check precision requirements for financial and statistical work.

Compare row, value, and distinct counts
WITH orders (customer_id, shipped_at, amount) AS (
  VALUES (1, DATE '2026-01-01', 20), (1, NULL, 30), (2, DATE '2026-01-02', 50)
)
SELECT COUNT(*) AS rows,
       COUNT(shipped_at) AS shipped,
       COUNT(DISTINCT customer_id) AS customers,
       SUM(amount) AS total,
       AVG(amount) AS average
FROM orders;
Output
rows | shipped | customers | total | average
-----+---------+-----------+-------+---------
3    | 2       | 2         | 100   | 33.3333
Back to quick reference ↑
02

Return one row per grouping key

GROUP BY combines rows with equal grouped expressions, then aggregates operate within each group. Every selected expression must be grouped, aggregated, or functionally dependent in a way PostgreSQL recognizes. Grouping does not guarantee output order, so add a final ORDER BY when presentation must be deterministic.

Totals by region and status
WITH orders (region, status, amount) AS (
  VALUES ('East', 'paid', 40), ('East', 'paid', 20),
         ('East', 'open', 15), ('West', 'paid', 30)
)
SELECT region, status, COUNT(*) AS orders, SUM(amount) AS total
FROM orders
GROUP BY region, status
ORDER BY region, status;
Output
region | status | orders | total
-------+--------+--------+------
East   | open   | 1      | 15
East   | paid   | 2      | 60
West   | paid   | 1      | 30
Back to quick reference ↑
03

Use WHERE for rows and HAVING for groups

WHERE runs before grouping and is the efficient place for predicates on detail rows. HAVING runs after aggregation and can reference aggregate expressions to reject whole groups. Moving a condition between them changes the data being summarized, so express the business question before optimizing the query.

Recent teams with meaningful totals
WITH results (season, team, score) AS (
  VALUES (2026, 'A', 60), (2026, 'A', 50), (2026, 'B', 70), (2025, 'B', 80)
)
SELECT team, SUM(score) AS total
FROM results
WHERE season = 2026
GROUP BY team
HAVING SUM(score) >= 100
ORDER BY team;
Output
team | total
-----+------
A    | 110
Back to quick reference ↑
04

Calculate several conditional summaries in one grouping pass

FILTER attaches a WHERE-like predicate to one aggregate. This is clearer than repeating CASE expressions and allows paid, open, failed, or other measures to share the same grouped rows. A filtered SUM returns null when nothing qualifies, so apply COALESCE only when zero is the correct domain value.

Paid and open metrics side by side
WITH orders (region, status, amount) AS (
  VALUES ('East', 'paid', 40), ('East', 'open', 15), ('East', 'paid', 20)
)
SELECT region,
       COUNT(*) AS all_orders,
       COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
       SUM(amount) FILTER (WHERE status = 'paid') AS paid_total,
       COUNT(*) FILTER (WHERE status = 'open') AS open_orders
FROM orders
GROUP BY region;
Output
region | all_orders | paid_orders | paid_total | open_orders
-------+------------+-------------+------------+------------
East   | 3          | 2           | 60         | 1
Back to quick reference ↑
05

Order inputs inside order-sensitive aggregates

STRING_AGG, ARRAY_AGG, JSON aggregates, and similar collectors can produce meaningfully different results for different input orders. Put ORDER BY inside the aggregate call so it controls that aggregate's input rather than only sorting final rows. BOOL_AND and BOOL_OR summarize predicates but ignore nulls, which should be handled explicitly when unknown is significant.

Deterministic names and identifiers
WITH members (team, id, name, joined_at, approved) AS (
  VALUES ('Docs', 2, 'Grace', DATE '2026-02-01', true),
         ('Docs', 1, 'Ada', DATE '2026-01-01', true)
)
SELECT team,
       STRING_AGG(name, ', ' ORDER BY name) AS names,
       ARRAY_AGG(id ORDER BY joined_at, id) AS ids,
       BOOL_AND(approved) AS all_approved
FROM members
GROUP BY team;
Output
team | names      | ids   | all_approved
-----+------------+-------+-------------
Docs | Ada, Grace | {1,2} | true
Back to quick reference ↑
06

Produce explicit subtotal levels

GROUPING SETS lists exactly the summaries required. ROLLUP expands prefixes for hierarchical totals, while CUBE expands every subset and can grow output quickly. Subtotal rows use null placeholders for omitted grouping expressions; GROUPING returns a mask that distinguishes those placeholders from real null group values.

Product, regional, and grand totals
WITH sales (region, product, amount) AS (
  VALUES ('East', 'A', 10), ('East', 'B', 20), ('West', 'A', 15)
)
SELECT region, product, SUM(amount) AS total,
       GROUPING(region, product) AS grouping_mask
FROM sales
GROUP BY ROLLUP (region, product)
ORDER BY GROUPING(region, product), region, product;
Output
region | product | total | grouping_mask
-------+---------+-------+--------------
East   | A       | 10    | 0
East   | B       | 20    | 0
West   | A       | 15    | 0
East   | NULL    | 30    | 1
West   | NULL    | 15    | 1
NULL   | NULL    | 45    | 3
Back to quick reference ↑
07

Define empty-set and null semantics intentionally

Except for COUNT, built-in aggregates commonly return null when no rows are aggregated. COALESCE that result only when the domain defines an empty total as zero or an empty collection. On outer joins, COUNT(*) counts the preserved row even without a match; count a non-null column from the optional side when the goal is related-row count.

Zero related rows after a left join
WITH teams (id) AS (VALUES (1), (2)),
scores (team_id, points) AS (VALUES (1, 10), (1, 20))
SELECT t.id,
       COUNT(s.team_id) AS score_rows,
       COALESCE(SUM(s.points), 0) AS points
FROM teams AS t
LEFT JOIN scores AS s ON s.team_id = t.id
GROUP BY t.id
ORDER BY t.id;
Output
id | score_rows | points
---+------------+-------
1  | 2          | 30
2  | 0          | 0
Back to quick reference ↑

Sources and further reading

References

Authoritative documentation used to verify and expand this cheat sheet.

  1. PostgreSQL Global Development GroupPostgreSQL: Aggregate Functionspostgresql.org
  2. PostgreSQL Global Development GroupPostgreSQL: Aggregate Expressionspostgresql.org
  3. PostgreSQL Global Development GroupPostgreSQL: GROUP BY and HAVINGpostgresql.org
  4. PostgreSQL Global Development GroupPostgreSQL: GROUPING SETS, CUBE, and ROLLUPpostgresql.org

Help us improve

Found a typo or missing example?

Tell us what would make this cheat sheet clearer, more complete, or more useful.

Share feedback