Blog · How-to guides · Finance and FP&A teams
SQL for data analysis in twelve numbered queries, all run on the same eight-invoice sales table: filtering, grouping, HAVING, joins, finding gaps with a LEFT JOIN, CASE bands, month truncation, running totals, share of total, month-over-month change with LAG, deduplication with ROW_NUMBER and a CTE that chains the steps. Every result is shown, with the check that each summary sums back to the table.
SQL for data analysis means using SELECT queries to filter, group, join and summarize tables. A dozen patterns do most of the work: WHERE, GROUP BY with SUM and COUNT, HAVING, joins, CASE, date truncation, and window functions for running totals, shares and period-over-period change. Below are all twelve, numbered, on one eight-row sales table, with the result each one returns.
The queries are written in PostgreSQL. Where SQL Server or MySQL differ, the difference is noted. One applies to several queries: SQL Server does not accept a column position such as GROUP BY 1, so there repeat the expression in the GROUP BY.
Two tables. invoices holds one row per invoice, Q1 2026, amounts in USD:
| invoice_id | customer | invoice_date | amount |
|---|---|---|---|
| 1 | Acme | 2026-01-14 | 12000 |
| 2 | Birch | 2026-01-20 | 8500 |
| 3 | Acme | 2026-02-03 | 9000 |
| 4 | Cobalt | 2026-02-11 | 15000 |
| 5 | Birch | 2026-02-25 | 8500 |
| 6 | Acme | 2026-03-09 | 14500 |
| 7 | Cobalt | 2026-03-18 | 4000 |
| 8 | Birch | 2026-03-30 | 11000 |
customers holds one row per customer:
| customer | segment | region |
|---|---|---|
| Acme | Manufacturing | Midwest |
| Birch | Retail | Northeast |
| Cobalt | Manufacturing | South |
| Dunmore | Wholesale | West |
The control total is 82,500: the sum of amount over all eight rows. Every summary below must add back to it. Before querying a real export, prepare it for analysis: one row per invoice, one date format, no subtotal rows.
Query 1. Filter rows with WHERE. March invoices only:
SELECT invoice_id, customer, invoice_date, amount
FROM invoices
WHERE invoice_date >= DATE '2026-03-01'
AND invoice_date < DATE '2026-04-01';
Returns invoices 6, 7 and 8, totaling 29,500. The range is "on or after the first, before the next first", which stays correct if the column is a timestamp.
Query 2. Aggregate with GROUP BY. Revenue and invoice count per customer:
SELECT customer, SUM(amount) AS revenue, COUNT(*) AS invoices
FROM invoices
GROUP BY customer
ORDER BY revenue DESC;
| customer | revenue | invoices |
|---|---|---|
| Acme | 35500 | 3 |
| Birch | 28000 | 3 |
| Cobalt | 19000 | 2 |
Query 3. Filter groups with HAVING. Customers above 25,000:
SELECT customer, SUM(amount) AS revenue
FROM invoices
GROUP BY customer
HAVING SUM(amount) > 25000;
Returns Acme (35,500) and Birch (28,000). WHERE filters rows before grouping; HAVING filters groups after it.
Query 4. JOIN for an attribute. Revenue by segment:
SELECT c.segment, SUM(i.amount) AS revenue, COUNT(*) AS invoices
FROM invoices i
JOIN customers c ON c.customer = i.customer
GROUP BY c.segment
ORDER BY revenue DESC;
| segment | revenue | invoices |
|---|---|---|
| Manufacturing | 54500 | 5 |
| Retail | 28000 | 3 |
Eight invoice rows in, eight rows out of the join, and 54,500 + 28,000 = 82,500.
Query 5. LEFT JOIN with IS NULL to find gaps. Customers with no invoice this quarter:
SELECT c.customer
FROM customers c
LEFT JOIN invoices i
ON i.customer = c.customer
AND i.invoice_date >= DATE '2026-01-01'
AND i.invoice_date < DATE '2026-04-01'
WHERE i.invoice_id IS NULL;
Returns Dunmore. The date condition sits in the ON clause; moved to WHERE, it would remove the null rows and the query would return nothing.
Query 6. CASE to band values. Invoices by size:
SELECT CASE WHEN amount >= 10000 THEN 'Large'
WHEN amount >= 5000 THEN 'Medium'
ELSE 'Small' END AS size_band,
COUNT(*) AS invoices, SUM(amount) AS revenue
FROM invoices
GROUP BY 1
ORDER BY revenue DESC;
| size_band | invoices | revenue |
|---|---|---|
| Large | 4 | 52500 |
| Medium | 3 | 26000 |
| Small | 1 | 4000 |
CASE takes the first branch that is true, so the order of the WHEN lines matters.
Query 7. Group by month. PostgreSQL's date_trunc cuts a date back to the start of its month:
SELECT DATE_TRUNC('month', invoice_date) AS month, SUM(amount) AS revenue
FROM invoices
GROUP BY 1
ORDER BY 1;
Returns January 20,500, February 32,500, March 29,500. In SQL Server, DATETRUNC(month, invoice_date) does the same from SQL Server 2022 (16.x); on older versions use DATEFROMPARTS(YEAR(invoice_date), MONTH(invoice_date), 1). In MySQL, DATE_FORMAT(invoice_date, '%Y-%m-01').
A window function, in the PostgreSQL tutorial's words, "performs a calculation across a set of table rows that are somehow related to the current row", but the rows "retain their separate identities" instead of collapsing into one. The OVER clause defines which rows.
Query 8. Running total. With ORDER BY inside OVER, the frame runs from the first row to the current one:
SELECT DATE_TRUNC('month', invoice_date) AS month,
SUM(amount) AS revenue,
SUM(SUM(amount)) OVER (ORDER BY DATE_TRUNC('month', invoice_date)) AS running_total
FROM invoices
GROUP BY 1
ORDER BY 1;
The inner SUM is the monthly total; the outer SUM OVER adds the months up in order.
Query 9. Share of total. An empty OVER () makes the whole result one window:
SELECT customer,
SUM(amount) AS revenue,
SUM(amount) * 1.0 / SUM(SUM(amount)) OVER () AS share
FROM invoices
GROUP BY customer
ORDER BY revenue DESC;
Query 10. Month over month with LAG. LAG reads a value from the previous row "without the use of a self-join", and returns NULL when there is no previous row:
SELECT DATE_TRUNC('month', invoice_date) AS month,
SUM(amount) AS revenue,
LAG(SUM(amount)) OVER (ORDER BY DATE_TRUNC('month', invoice_date)) AS prior_month
FROM invoices
GROUP BY 1
ORDER BY 1;
Window functions are allowed only in the SELECT list and ORDER BY, never in WHERE or HAVING. To filter on one, wrap the query, as Query 11 does.
Query 11. ROW_NUMBER to keep one row per key. The latest invoice for each customer:
SELECT *
FROM (
SELECT i.*,
ROW_NUMBER() OVER (PARTITION BY customer ORDER BY invoice_date DESC) AS rn
FROM invoices i
) t
WHERE rn = 1;
| invoice_id | customer | invoice_date | amount |
|---|---|---|---|
| 6 | Acme | 2026-03-09 | 14500 |
| 8 | Birch | 2026-03-30 | 11000 |
| 7 | Cobalt | 2026-03-18 | 4000 |
The same pattern removes duplicate rows from a messy export: partition by the columns that should be unique, keep rn = 1.
Query 12. A CTE to chain the steps. A WITH clause names the monthly totals once, and the main query reads them like a table:
WITH m AS (
SELECT DATE_TRUNC('month', invoice_date) AS month, SUM(amount) AS revenue
FROM invoices
GROUP BY 1
)
SELECT month,
revenue,
(revenue - LAG(revenue) OVER (ORDER BY month)) * 1.0
/ LAG(revenue) OVER (ORDER BY month) AS mom_change,
SUM(revenue) OVER (ORDER BY month) AS running_total
FROM m
ORDER BY month;
Each step is readable on its own, and a third step can be added as another CTE without nesting.
Queries 7, 8, 10 and 12 by month:
| month | revenue | prior_month | mom_change | running_total |
|---|---|---|---|---|
| 2026-01-01 | 20500 | NULL | NULL | 20500 |
| 2026-02-01 | 32500 | 20500 | 58.5% | 53000 |
| 2026-03-01 | 29500 | 32500 | -9.2% | 82500 |
February: (32,500 − 20,500) / 20,500 = 58.5%. March: (29,500 − 32,500) / 32,500 = −9.2%. January has no prior month, so LAG returns NULL and the change is NULL, not zero.
Query 9, share of total by customer:
| customer | revenue | share |
|---|---|---|
| Acme | 35500 | 43.0% |
| Birch | 28000 | 33.9% |
| Cobalt | 19000 | 23.0% |
| Total | 82500 | 99.9% |
The shares are rounded to one decimal, so they show 99.9% rather than 100%. Unrounded they are 43.03%, 33.94% and 23.03%, which sum to 100%.
Two checks catch most SQL mistakes before anyone reads the result.
Totals. Every grouped result must add back to the control total: SELECT SUM(amount) FROM invoices returns 82,500. By month, 20,500 + 32,500 + 29,500 = 82,500. By customer, 35,500 + 28,000 + 19,000 = 82,500. By segment, 54,500 + 28,000 = 82,500. By size band, 52,500 + 26,000 + 4,000 = 82,500. The running total ends on 82,500.
Row counts. Count rows before and after every join. invoices has 8 rows, and the join in Query 4 returns 8. If it returned more, the join key is not unique on the customers side and amounts are being counted twice. The same discipline applies when the two tables come from different systems; see reconciling the CRM to the ledger.
COUNT(*) counts every row; COUNT(column) counts only non-null values. SUM ignores nulls, and PostgreSQL's aggregate docs note that "sum of no rows returns null, not zero", so wrap it in COALESCE(SUM(amount), 0) where a zero is meant. Counting rows where you meant to sum values is its own trap; see count-weighted and value-weighted.BETWEEN '2026-01-01' AND '2026-01-31' on a timestamp column stops at midnight at the start of January 31 and misses most of that day. Use >= the start and < the next start.If the results end up in a spreadsheet anyway, the same summaries are a pivot table in Excel away, and Excel or an analytics tool covers when the spreadsheet stops being enough.
Most finance teams have the exports but not the time to write and check twelve queries every month. Covirage's tools run the equivalent of these queries on the files a finance team uploads, check that every summary adds back to the control total, and cite the rows behind each figure; the external AI model turns a plain question into the right tool call and explains the result, without doing the arithmetic. See Covirage for finance teams. To turn rows into columns in the query itself, see SQL PIVOT, and to have the query written from a plain question, see text-to-SQL.
SQL is the core skill for most analyst roles, because the data lives in databases. Most roles also expect a spreadsheet tool, a BI or visualization tool, some statistics, and the ability to explain results to non-specialists.
SUM, COUNT and AVG with GROUP BY; CASE; COALESCE; date functions such as DATE_TRUNC or DATEPART; and window functions: ROW_NUMBER, RANK, LAG, LEAD and SUM OVER for running totals and shares.
Both, for different jobs. SQL handles large tables, joins and repeatable queries against the source. Excel is quicker for small one-time analysis and presentation. Many analysts query in SQL and finish in Excel or a BI tool.
A function that computes across a set of rows related to the current row without collapsing them into one, using OVER(). Running totals, share of total, rankings and the previous period's value with LAG are all window functions.