First decide what one result row means
A sales report starts with a question: do you need one row per invoice, per customer, or per billing country? This is the output grain. GROUP BY collects eligible input rows with matching keys and produces one summary row per group.
Our source contains 412 invoices belonging to 59 customers. A customer summary has 59 rows; each row describes all invoices for one customer. A country summary groups different customers together. Choose the grain before adding totals.
Prerequisite: recognize SELECT, FROM and WHERE. No earlier lesson is required. If your report combines tables, read JOINs vs Subqueries and check row multiplicity before aggregating. You can practice with checked exercises after the examples.
The Chinook sample database
These examples use the complete Chinook music-store database available in the DataTug demo: 59 customers and 412 invoices, with every original table and value retained. All 59 customers have invoices. Queries that focus on one customer or qualifying invoices show that condition explicitly in the SQL.
The relationship is Customer.CustomerId → Invoice.CustomerId: one customer can have many invoices; each invoice belongs to one customer. InvoiceId is the invoice’s unique key. InvoiceDate is ISO-sortable text; Total is a numeric amount in the dataset’s units. This source uses dates from 2021–2025; other Chinook releases differ. Invoice IDs are not necessarily chronological.
Results are shown as a preview for clarity. Tables with more than 10 records initially show the first 10. Choose Show all to reveal every matching record, or Run to execute the exact displayed SQL in the DataTug editor against the same Chinook database. No query is saved to the project. The preview does not add a WHERE or LIMIT clause. Synthetic examples define their own rows with VALUES in the displayed query.
Download the complete Chinook SQLite database and open it with SQLite 3.25.0 or later, then run any query below. No account or network database is required. Query downloads contain every displayed example and exercise solution. Frozen result tables are checked against this database; each code header states its minimum SQLite version.
Chinook upstream and schema · Chinook licence · Source attribution. SQLite uses binary floating-point for many decimal operations; examples round money for display. Production finance needs an explicit exact-decimal or integer-minor-unit policy.
COUNT, SUM and AVG summarize the input
One summary for all invoices
What are the invoice count, revenue and average invoice amount?
SELECT COUNT(*) AS InvoiceCount, ROUND(SUM(Total), 2) AS Revenue,
ROUND(AVG(Total), 2) AS AverageInvoice
FROM Invoice;| InvoiceCount | Revenue | AverageInvoice |
|---|---|---|
| 412 | 2328.6 | 5.65 |
COUNT(*) counts invoices. SUM(Total) adds their amounts. AVG(Total) calculates the average invoice amount. Without GROUP BY, this aggregate query produces one summary for the entire eligible input. ROUND controls the displayed precision; it does not change which rows enter the calculation.
GROUP BY output
Find total spending per customer.
SELECT CustomerId, ROUND(SUM(Total), 2) AS CustomerTotal
FROM Invoice GROUP BY CustomerId ORDER BY CustomerId;Show all 59 recordsShow first 10 records
| CustomerId | CustomerTotal |
|---|---|
| 1 | 39.62 |
| 2 | 37.62 |
| 3 | 39.62 |
| 4 | 39.62 |
| 5 | 40.62 |
| 6 | 49.62 |
| 7 | 42.62 |
| 8 | 37.62 |
| 9 | 37.62 |
| 10 | 37.62 |
| 11 | 37.62 |
| 12 | 37.62 |
| 13 | 37.62 |
| 14 | 37.62 |
| 15 | 38.62 |
| 16 | 37.62 |
| 17 | 39.62 |
| 18 | 37.62 |
| 19 | 38.62 |
| 20 | 39.62 |
| 21 | 37.62 |
| 22 | 39.62 |
| 23 | 37.62 |
| 24 | 43.62 |
| 25 | 42.62 |
| 26 | 47.62 |
| 27 | 37.62 |
| 28 | 43.62 |
| 29 | 37.62 |
| 30 | 37.62 |
| 31 | 37.62 |
| 32 | 37.62 |
| 33 | 37.62 |
| 34 | 39.62 |
| 35 | 37.62 |
| 36 | 37.62 |
| 37 | 43.62 |
| 38 | 37.62 |
| 39 | 38.62 |
| 40 | 38.62 |
| 41 | 37.62 |
| 42 | 39.62 |
| 43 | 40.62 |
| 44 | 41.62 |
| 45 | 45.62 |
| 46 | 45.62 |
| 47 | 37.62 |
| 48 | 40.62 |
| 49 | 37.62 |
| 50 | 37.62 |
| 51 | 38.62 |
| 52 | 37.62 |
| 53 | 37.62 |
| 54 | 37.62 |
| 55 | 37.62 |
| 56 | 37.62 |
| 57 | 46.62 |
| 58 | 38.62 |
| 59 | 36.64 |
412 invoice rows become 59 customer summary rows. Every customer appears because all 59 have invoices.
Adding GROUP BY CustomerId creates a separate sum per customer. Customer 1 totals 39.62 across seven invoices; their summary is one row. GROUP BY does not promise a display order, so every multirow example specifies ORDER BY.
AVG(Total) weights each non-NULL invoice equally. Averaging customer averages instead would weight customers equally, which can produce a different result when invoice counts differ. Define the denominator your question needs.
Grouping keys define which rows belong together
One summary per billing country
How many invoices and how much revenue belong to each billing country?
SELECT BillingCountry, COUNT(*) AS InvoiceCount,
ROUND(SUM(Total), 2) AS Revenue, ROUND(AVG(Total), 2) AS AverageInvoice
FROM Invoice
GROUP BY BillingCountry
ORDER BY BillingCountry;Show all 24 recordsShow first 10 records
| BillingCountry | InvoiceCount | Revenue | AverageInvoice |
|---|---|---|---|
| Argentina | 7 | 37.62 | 5.37 |
| Australia | 7 | 37.62 | 5.37 |
| Austria | 7 | 42.62 | 6.09 |
| Belgium | 7 | 37.62 | 5.37 |
| Brazil | 35 | 190.1 | 5.43 |
| Canada | 56 | 303.96 | 5.43 |
| Chile | 7 | 46.62 | 6.66 |
| Czech Republic | 14 | 90.24 | 6.45 |
| Denmark | 7 | 37.62 | 5.37 |
| Finland | 7 | 41.62 | 5.95 |
| France | 35 | 195.1 | 5.57 |
| Germany | 28 | 156.48 | 5.59 |
| Hungary | 7 | 45.62 | 6.52 |
| India | 13 | 75.26 | 5.79 |
| Ireland | 7 | 45.62 | 6.52 |
| Italy | 7 | 37.62 | 5.37 |
| Netherlands | 7 | 40.62 | 5.8 |
| Norway | 7 | 39.62 | 5.66 |
| Poland | 7 | 37.62 | 5.37 |
| Portugal | 14 | 77.24 | 5.52 |
| Spain | 7 | 37.62 | 5.37 |
| Sweden | 7 | 38.62 | 5.52 |
| USA | 91 | 523.06 | 5.75 |
| United Kingdom | 21 | 112.86 | 5.37 |
BillingCountry answers where invoices were billed, rather than the customer's current profile country. Use the field that matches your business question. Select each grouping key or an aggregate; an ungrouped invoice ID beside a country total cannot identify all contributing invoices.
One summary per country and city
How does a second grouping key change the output grain?
SELECT BillingCountry, BillingCity, COUNT(*) AS InvoiceCount,
ROUND(SUM(Total), 2) AS Revenue
FROM Invoice
GROUP BY BillingCountry, BillingCity
ORDER BY BillingCountry, BillingCity;Show all 53 recordsShow first 10 records
| BillingCountry | BillingCity | InvoiceCount | Revenue |
|---|---|---|---|
| Argentina | Buenos Aires | 7 | 37.62 |
| Australia | Sidney | 7 | 37.62 |
| Austria | Vienne | 7 | 42.62 |
| Belgium | Brussels | 7 | 37.62 |
| Brazil | Brasília | 7 | 37.62 |
| Brazil | Rio de Janeiro | 7 | 37.62 |
| Brazil | São José dos Campos | 7 | 39.62 |
| Brazil | São Paulo | 14 | 75.24 |
| Canada | Edmonton | 7 | 37.62 |
| Canada | Halifax | 7 | 37.62 |
| Canada | Montréal | 7 | 39.62 |
| Canada | Ottawa | 7 | 37.62 |
| Canada | Toronto | 7 | 37.62 |
| Canada | Vancouver | 7 | 38.62 |
| Canada | Winnipeg | 7 | 37.62 |
| Canada | Yellowknife | 7 | 37.62 |
| Chile | Santiago | 7 | 46.62 |
| Czech Republic | Prague | 14 | 90.24 |
| Denmark | Copenhagen | 7 | 37.62 |
| Finland | Helsinki | 7 | 41.62 |
| France | Bordeaux | 7 | 39.62 |
| France | Dijon | 7 | 40.62 |
| France | Lyon | 7 | 37.62 |
| France | Paris | 14 | 77.24 |
| Germany | Berlin | 14 | 75.24 |
| Germany | Frankfurt | 7 | 43.62 |
| Germany | Stuttgart | 7 | 37.62 |
| Hungary | Budapest | 7 | 45.62 |
| India | Bangalore | 6 | 36.64 |
| India | Delhi | 7 | 38.62 |
| Ireland | Dublin | 7 | 45.62 |
| Italy | Rome | 7 | 37.62 |
| Netherlands | Amsterdam | 7 | 40.62 |
| Norway | Oslo | 7 | 39.62 |
| Poland | Warsaw | 7 | 37.62 |
| Portugal | Lisbon | 7 | 39.62 |
| Portugal | Porto | 7 | 37.62 |
| Spain | Madrid | 7 | 37.62 |
| Sweden | Stockholm | 7 | 38.62 |
| USA | Boston | 7 | 37.62 |
| USA | Chicago | 7 | 43.62 |
| USA | Cupertino | 7 | 38.62 |
| USA | Fort Worth | 7 | 47.62 |
| USA | Madison | 7 | 42.62 |
| USA | Mountain View | 14 | 77.24 |
| USA | New York | 7 | 37.62 |
| USA | Orlando | 7 | 39.62 |
| USA | Redmond | 7 | 39.62 |
| USA | Reno | 7 | 37.62 |
| USA | Salt Lake City | 7 | 43.62 |
| USA | Tucson | 7 | 37.62 |
| United Kingdom | Edinburgh | 7 | 37.62 |
| United Kingdom | London | 14 | 75.24 |
Two keys define one group for each distinct country/city pair. Including the country avoids accidentally combining cities with the same name in different countries. Adding a key makes the grain more detailed; removing one combines groups. For real customer reporting, use a stable CustomerId rather than assuming names are unique.
Grouping depends on the engine's equality and collation rules. Different spellings and letter case can form different groups. Normalize deliberately when the report needs that behavior; do not silently merge business identities.
HAVING keeps groups that meet a condition
Keep customers whose total exceeds 45
Which customers spent more than 45 across all invoices?
SELECT CustomerId, COUNT(*) AS InvoiceCount, ROUND(SUM(Total), 2) AS CustomerTotal
FROM Invoice
GROUP BY CustomerId
HAVING SUM(Total) > 45
ORDER BY CustomerId;| CustomerId | InvoiceCount | CustomerTotal |
|---|---|---|
| 6 | 7 | 49.62 |
| 26 | 7 | 47.62 |
| 45 | 7 | 45.62 |
| 46 | 7 | 45.62 |
| 57 | 7 | 46.62 |
Each customer gets a total, then HAVING keeps only totals greater than 45. The result has five customers. This condition belongs on the aggregate at this SELECT level: WHERE cannot evaluate that same-level SUM before its group exists.
For a fuller comparison—including cases where WHERE and HAVING return identical rows and what that means for cost—continue to WHERE vs HAVING. Repeating SUM(Total) in HAVING also avoids relying on engine-specific alias support.
WHERE chooses the population; HAVING chooses the summaries
Combine WHERE and HAVING
Which customers spent more than 10 during 2025?
SELECT CustomerId, COUNT(*) AS InvoiceCount, ROUND(SUM(Total), 2) AS CustomerTotal
FROM Invoice
WHERE InvoiceDate >= '2025-01-01' AND InvoiceDate < '2026-01-01'
GROUP BY CustomerId
HAVING SUM(Total) > 10
ORDER BY CustomerId;Show all 18 recordsShow first 10 records
| CustomerId | InvoiceCount | CustomerTotal |
|---|---|---|
| 6 | 2 | 27.84 |
| 10 | 2 | 15.84 |
| 12 | 3 | 11.88 |
| 14 | 2 | 15.84 |
| 18 | 2 | 22.77 |
| 27 | 2 | 15.84 |
| 29 | 3 | 11.88 |
| 31 | 2 | 15.84 |
| 33 | 3 | 11.88 |
| 35 | 3 | 24.75 |
| 39 | 2 | 22.77 |
| 44 | 2 | 15.84 |
| 48 | 2 | 15.84 |
| 50 | 3 | 11.88 |
| 52 | 2 | 15.84 |
| 54 | 3 | 11.88 |
| 56 | 3 | 24.75 |
| 58 | 3 | 11.89 |
WHERE first limits input to invoices in 2025. GROUP BY forms customer summaries from that input. HAVING keeps the 18 customers with more than 10 in eligible spending. Their totals mean 2025 spending, not all-time spending.
The half-open date interval includes the start of 2025 and excludes the start of 2026. Comparing the unrounded SUM with the threshold preserves the stated condition; rounding is only for display.
Indexes and selective row predicates can reduce work before grouping. GROUP BY may still require sorting or temporary state. Inspect your engine's plan and measure representative data before claiming that a particular spelling is faster.
COUNT, NULLs and missing groups
Count rows versus known values
How many customers have a company value?
SELECT COUNT(*) AS CustomerCount, COUNT(Company) AS KnownCompanies,
COUNT(*) - COUNT(Company) AS MissingCompanies
FROM Customer;| CustomerCount | KnownCompanies | MissingCompanies |
|---|---|---|
| 59 | 10 | 49 |
COUNT(*) counts all 59 customer rows. COUNT(Company) counts only non-NULL company values. An empty string is still a non-NULL value; decide whether your data model treats it as missing.
SUM and AVG ignore NULL inputs. If every value in a group is NULL, SUM and AVG return NULL, while COUNT(*) still counts its rows. A comparison against NULL is unknown, so it does not pass HAVING. SQLite groups NULL keys together; NULL grouping is not the same as the expression NULL = NULL being true.
An aggregate query without GROUP BY still returns one row on empty input: COUNT(*) is zero and SUM/AVG are NULL. A grouped query with no eligible rows returns no groups. It does not invent customers or countries with zero invoices.
Intermediate solution
Count invoices over 20 for every customer, including zero.
SELECT c.CustomerId, COUNT(i.InvoiceId) AS InvoiceCount
FROM Customer AS c
LEFT JOIN Invoice AS i ON i.CustomerId = c.CustomerId AND i.Total > 20
GROUP BY c.CustomerId
ORDER BY c.CustomerId;Show all 59 recordsShow first 10 records
| CustomerId | InvoiceCount |
|---|---|
| 1 | 0 |
| 2 | 0 |
| 3 | 0 |
| 4 | 0 |
| 5 | 0 |
| 6 | 1 |
| 7 | 0 |
| 8 | 0 |
| 9 | 0 |
| 10 | 0 |
| 11 | 0 |
| 12 | 0 |
| 13 | 0 |
| 14 | 0 |
| 15 | 0 |
| 16 | 0 |
| 17 | 0 |
| 18 | 0 |
| 19 | 0 |
| 20 | 0 |
| 21 | 0 |
| 22 | 0 |
| 23 | 0 |
| 24 | 0 |
| 25 | 0 |
| 26 | 1 |
| 27 | 0 |
| 28 | 0 |
| 29 | 0 |
| 30 | 0 |
| 31 | 0 |
| 32 | 0 |
| 33 | 0 |
| 34 | 0 |
| 35 | 0 |
| 36 | 0 |
| 37 | 0 |
| 38 | 0 |
| 39 | 0 |
| 40 | 0 |
| 41 | 0 |
| 42 | 0 |
| 43 | 0 |
| 44 | 0 |
| 45 | 1 |
| 46 | 1 |
| 47 | 0 |
| 48 | 0 |
| 49 | 0 |
| 50 | 0 |
| 51 | 0 |
| 52 | 0 |
| 53 | 0 |
| 54 | 0 |
| 55 | 0 |
| 56 | 0 |
| 57 | 0 |
| 58 | 0 |
| 59 | 0 |
Only four customers have a qualifying invoice. COUNT(i.InvoiceId) correctly returns zero for each null-extended row; COUNT(*) would incorrectly return one for the other 55 customers.
This existing lesson query starts with Customer and LEFT JOINs invoices above 20 to retain customers with no matching invoice. COUNT(i.InvoiceId) counts matches; COUNT(*) would also count the preserved customer row and give at least one. The invoice condition stays in ON so unmatched customers survive. See JOIN exercises for that distinction.
Joining invoices to invoice lines can repeat invoice amounts. Summing the repeated invoice Total would overcount revenue. Check the grain after every join, and aggregate at the grain required by your question.
Practice: choose keys, population and threshold
Beginner · country summaries
Return billing countries with at least 20 invoices, showing count and total revenue.
Hint
Group by BillingCountry, count invoices, and test COUNT(*) in HAVING.
Solution and checked result
Exercise solution: countries with at least 20 invoices
Return billing countries with at least 20 invoices, showing count and total revenue.
SELECT BillingCountry, COUNT(*) AS InvoiceCount, ROUND(SUM(Total), 2) AS Revenue
FROM Invoice
GROUP BY BillingCountry
HAVING COUNT(*) >= 20
ORDER BY BillingCountry;| BillingCountry | InvoiceCount | Revenue |
|---|---|---|
| Brazil | 35 | 190.1 |
| Canada | 56 | 303.96 |
| France | 35 | 195.1 |
| Germany | 28 | 156.48 |
| USA | 91 | 523.06 |
| United Kingdom | 21 | 112.86 |
Intermediate · two keys and a date range
Return country/city groups with more than 15 revenue from invoices dated in 2025.
Hint
Use WHERE for the 2025 date range, GROUP BY both location keys, and HAVING for SUM(Total).
Solution and checked result
Exercise solution: city groups in 2025
Return country/city groups with more than 15 revenue from invoices dated in 2025.
SELECT BillingCountry, BillingCity, ROUND(SUM(Total), 2) AS Revenue
FROM Invoice
WHERE InvoiceDate >= '2025-01-01' AND InvoiceDate < '2026-01-01'
GROUP BY BillingCountry, BillingCity
HAVING SUM(Total) > 15
ORDER BY BillingCountry, BillingCity;Show all 13 recordsShow first 10 records
| BillingCountry | BillingCity | Revenue |
|---|---|---|
| Argentina | Buenos Aires | 24.75 |
| Brazil | São Paulo | 16.83 |
| Canada | Edmonton | 15.84 |
| Canada | Halifax | 15.84 |
| Czech Republic | Prague | 36.75 |
| Finland | Helsinki | 15.84 |
| France | Paris | 22.77 |
| Netherlands | Amsterdam | 15.84 |
| Portugal | Porto | 24.75 |
| USA | Mountain View | 16.83 |
| USA | New York | 22.77 |
| USA | Tucson | 15.84 |
| United Kingdom | London | 16.83 |
References and next steps
- SQLite SELECT: grouping, HAVING and bare columns
- SQLite aggregate functions: COUNT, SUM and AVG
- SQLite EXPLAIN QUERY PLAN
Download every example and solution · SQL Learning Hub and learning path.
Next: compare WHERE and HAVING, then preserve detail with window functions. Related: JOINs vs Subqueries. Practice: aggregation exercises and row-versus-group filtering exercises.
Corrections: report an issue with the query, SQLite version and expected result. AI-assisted writing with automated SQL checks and independent technical review.
