Overview
"Which products sell the best" and "how is revenue trending month over month" are both questions a plain `GROUP BY` can only partly answer. `GROUP BY` collapses rows into one summary row per group, but it cannot tell you a product's *rank* relative to the others in the same group, or a *running total* that carries forward from one row to the next — for that you need window functions, which compute a value across a set of related rows without collapsing them into fewer rows. This project builds a `sales` table and then layers a Common Table Expression (CTE) and three window functions on top of it to answer exactly those questions.
By the end of this tutorial you will have a `sales` table of individual line items, a `WITH ... AS (...)` CTE that pre-aggregates monthly totals per product, `RANK()` and `ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)` ranking the top-selling products within each month, and a running total built with `SUM(...) OVER (ORDER BY ... ROWS UNBOUNDED PRECEDING)` that shows cumulative revenue growing month by month.
- A `sales` table recording one row per individual sale, with product, quantity, and revenue.
- A CTE (`WITH monthly_totals AS (...)`) that aggregates revenue per product per month.
- `RANK() OVER (PARTITION BY sale_month ORDER BY total_revenue DESC)` to rank products within each month.
- `ROW_NUMBER() OVER (...)` to pick exactly the single top product per month, ties broken deterministically.
- A running monthly total using `SUM(...) OVER (ORDER BY ... ROWS UNBOUNDED PRECEDING)`.
Prerequisites
- CREATE TABLE basics in T-SQL and basic INSERT statements.
- GROUP BY and aggregate functions — `SUM()`, `COUNT()`, `AVG()`.
- The general idea of a CTE — a named, temporary result set defined with `WITH name AS (...)` and used like a table.
- A first look at window functions — the idea that `OVER (...)` computes a value across a set of rows without collapsing them.
- Date functions — extracting a year/month from a `DATE` column.
Project Structure
Unlike the previous two projects, this one is intentionally a single table — the interesting work here is entirely in how it is *queried*, not in how many tables it is split across. Every step below builds on `sales` directly, first through a CTE that reshapes it, then through window functions that add ranking and running totals on top.
| Table | Purpose | Key Columns |
|---|---|---|
| sales | One row per individual product sale | sale_id (PK), product_name, sale_date, quantity, revenue |
Step 1: Create the Sales Table
Each row is one line-item sale: a product, a date, and how much revenue it brought in. Keeping this at the individual-sale grain (rather than pre-aggregating by month) is deliberate — it is what lets the CTE and window functions in the later steps compute monthly totals, rankings, and running sums directly from raw transactional data, the same shape a real point-of-sale or e-commerce database would produce.
CREATE TABLE sales ( sale_id INT IDENTITY(1,1) PRIMARY KEY, -- Surrogate key, one row per individual sale product_name NVARCHAR(100) NOT NULL, -- Kept denormalized (no separate products table) to keep this project focused on querying sale_date DATE NOT NULL, -- The date this specific sale happened quantity INT NOT NULL, -- Units sold in this sale revenue DECIMAL(10,2) NOT NULL, -- Total revenue from this sale (quantity * unit price, already computed) CONSTRAINT CK_sales_quantity CHECK (quantity > 0), -- A sale of zero or negative units makes no sense CONSTRAINT CK_sales_revenue CHECK (revenue >= 0) -- Revenue can never be negative);Step 2: Insert Sample Sales Data
Sample data spanning two months and three products, with deliberately uneven revenue so the ranking queries in Step 4 produce a clear, non-tied top product each month.
INSERT INTO sales (product_name, sale_date, quantity, revenue) VALUES('Wireless Mouse', '2026-06-03', 10, 299.90),('Mechanical Keyboard', '2026-06-05', 4, 319.96),('27-inch Monitor', '2026-06-10', 2, 439.98),('Wireless Mouse', '2026-06-18', 6, 179.94),('Mechanical Keyboard', '2026-07-02', 8, 639.92),('Wireless Mouse', '2026-07-06', 15, 449.85),('27-inch Monitor', '2026-07-15', 3, 659.97),('27-inch Monitor', '2026-07-22', 1, 219.99);Step 3: Aggregate Monthly Totals with a CTE
A CTE — `WITH monthly_totals AS (...)` — defines a named, temporary result set that the rest of the query (or, in later steps, additional queries) can reference just like a table. Here it groups the raw `sales` rows by product and month, computing one summarized row per (product, month) pair. `DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1)` normalizes every sale in a given month down to that month's first day, so every sale in June groups under the same `sale_month` value regardless of which day it happened on.
WITH monthly_totals AS ( SELECT product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1) AS sale_month, -- Normalizes every date to the 1st of its month SUM(revenue) AS total_revenue, SUM(quantity) AS total_units FROM sales GROUP BY product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1))SELECT * FROM monthly_totalsORDER BY sale_month, total_revenue DESC;| product_name | sale_month | total_revenue | total_units |
|---|---|---|---|
| 27-inch Monitor | 2026-06-01 | 439.98 | 2 |
| Mechanical Keyboard | 2026-06-01 | 319.96 | 4 |
| Wireless Mouse | 2026-06-01 | 479.84 | 16 |
| 27-inch Monitor | 2026-07-01 | 879.96 | 4 |
| Mechanical Keyboard | 2026-07-01 | 639.92 | 8 |
| Wireless Mouse | 2026-07-01 | 449.85 | 15 |
Step 4: Rank Top Products with RANK() and ROW_NUMBER()
`PARTITION BY sale_month` restarts the ranking fresh for every month, so "rank 1" means "top product that month," not "top product overall." `RANK()` and `ROW_NUMBER()` differ only in how they handle ties: `RANK()` would give two tied products the same rank number and skip the next one (1, 1, 3), while `ROW_NUMBER()` always assigns a strictly increasing, unique number (1, 2, 3) even when values are equal — which is exactly what you want when you need to pick a single definitive "the" top product per month, tie or no tie.
WITH monthly_totals AS ( SELECT product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1) AS sale_month, SUM(revenue) AS total_revenue FROM sales GROUP BY product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1))SELECT product_name, sale_month, total_revenue, RANK() OVER (PARTITION BY sale_month ORDER BY total_revenue DESC) AS revenue_rank, -- Ties share a rank, next rank is skipped ROW_NUMBER() OVER (PARTITION BY sale_month ORDER BY total_revenue DESC) AS row_num -- Always unique, even on tiesFROM monthly_totalsORDER BY sale_month, revenue_rank;| product_name | sale_month | total_revenue | revenue_rank | row_num |
|---|---|---|---|---|
| Wireless Mouse | 2026-06-01 | 479.84 | 1 | 1 |
| 27-inch Monitor | 2026-06-01 | 439.98 | 2 | 2 |
| Mechanical Keyboard | 2026-06-01 | 319.96 | 3 | 3 |
| 27-inch Monitor | 2026-07-01 | 879.96 | 1 | 1 |
| Mechanical Keyboard | 2026-07-01 | 639.92 | 2 | 2 |
| Wireless Mouse | 2026-07-01 | 449.85 | 3 | 3 |
Step 5: Compute a Running Monthly Total
`SUM(total_revenue) OVER (ORDER BY sale_month ROWS UNBOUNDED PRECEDING)` is a running total: for each row, it sums every row from the very first one (`UNBOUNDED PRECEDING`) up through the current row, in the order given by `ORDER BY sale_month`. Unlike a plain `SUM()` with `GROUP BY`, which collapses everything into one final total, this window function keeps one row per month while still showing the cumulative figure — exactly what a "revenue so far this year" chart needs.
WITH monthly_revenue AS ( SELECT DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1) AS sale_month, SUM(revenue) AS month_revenue FROM sales GROUP BY DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1))SELECT sale_month, month_revenue, SUM(month_revenue) OVER ( ORDER BY sale_month ROWS UNBOUNDED PRECEDING -- Sum every row from the first through the current one ) AS running_totalFROM monthly_revenueORDER BY sale_month;| sale_month | month_revenue | running_total |
|---|---|---|
| 2026-06-01 | 1239.78 | 1239.78 |
| 2026-07-01 | 1969.73 | 3209.51 |
Complete Schema
The full schema, ready to run before any of the CTE/window-function queries above.
CREATE TABLE sales ( sale_id INT IDENTITY(1,1) PRIMARY KEY, product_name NVARCHAR(100) NOT NULL, sale_date DATE NOT NULL, quantity INT NOT NULL, revenue DECIMAL(10,2) NOT NULL, CONSTRAINT CK_sales_quantity CHECK (quantity > 0), CONSTRAINT CK_sales_revenue CHECK (revenue >= 0));Sample Queries
Two more analytics queries built the same way: a CTE to shape the data, then a window function on top.
-- The single top-selling product for each month, using ROW_NUMBER() to pick exactly one per groupWITH ranked AS ( SELECT product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1) AS sale_month, SUM(revenue) AS total_revenue, ROW_NUMBER() OVER ( PARTITION BY DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1) ORDER BY SUM(revenue) DESC ) AS rn FROM sales GROUP BY product_name, DATEFROMPARTS(YEAR(sale_date), MONTH(sale_date), 1))SELECT product_name, sale_month, total_revenueFROM rankedWHERE rn = 1ORDER BY sale_month;| product_name | sale_month | total_revenue |
|---|---|---|
| Wireless Mouse | 2026-06-01 | 479.84 |
| 27-inch Monitor | 2026-07-01 | 879.96 |
-- Each product's share of total revenue across the whole period, using a window SUM() with no PARTITION BY-- (an empty OVER() treats the entire result set as one window)SELECT product_name, SUM(revenue) AS product_revenue, SUM(revenue) * 100.0 / SUM(SUM(revenue)) OVER () AS pct_of_totalFROM salesGROUP BY product_nameORDER BY product_revenue DESC;| product_name | product_revenue | pct_of_total |
|---|---|---|
| 27-inch Monitor | 1319.94 | 41.12 |
| Mechanical Keyboard | 959.88 | 29.90 |
| Wireless Mouse | 929.69 | 28.98 |
Extend This Project
- Add a `LAG()`/`LEAD()` query comparing each month's revenue to the previous month's.
- Add a `region` column to `sales` and re-run the ranking queries `PARTITION BY region, sale_month`.
- Compute a moving 3-month average using `AVG(...) OVER (ORDER BY sale_month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)`.
- Normalize `product_name` into a real `products` table once the analytics queries stabilize.
- Wrap the top-product-per-month query in a `VIEW` called `monthly_top_sellers` for dashboards to query directly.
Summary
You used a CTE to pre-aggregate raw sales into monthly totals, then layered window functions on top without ever collapsing rows the way `GROUP BY` alone would: `RANK()` and `ROW_NUMBER()` ranked products within each month via `PARTITION BY`, and a running `SUM() OVER (... ROWS UNBOUNDED PRECEDING)` produced a cumulative total that grows month by month. This CTE-plus-window-function pattern — shape the data first, then rank or accumulate over it — is the backbone of almost every real analytics report you will write in T-SQL.