Daily KPIs such as DAU and sales can fluctuate sharply because of day-of-week effects, campaigns, and accidental spikes. A moving average smooths this noise and highlights the underlying trend. Adding a frame clause (ROWS BETWEEN ...) to a window function lets you specify only the “most recent N rows, including the current row” as the aggregation range.
AVG(dau) OVER ( ORDER BY event_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- today + previous 2 days = 3-day window ) -- Without an explicit frame, the default is RANGE UNBOUNDED PRECEDING ... CURRENT ROW -- = a cumulative average from the first row, not a moving average (important)
ROWS divides the frame by the physical number of rows (the most recent three rows). RANGE treats rows with the same ORDER BY value as a group. Moving averages need a row-count frame, so always use ROWS.From the daily_active table, calculate DAU for each date and a three-day moving average including the current date (dau_ma3). Return event_date, dau, dau_ma3 in ascending event_date order, and round the moving average to two decimal places.
| event_date | dau |
|---|---|
| 2024-03-01 | 100 |
| 2024-03-02 | 120 |
| 2024-03-03 | 110 |
| 2024-03-04 | 90 |
| 2024-03-05 | 150 |
| 2024-03-06 | 140 |
| 2024-03-07 | 160 |
※ In practice, change 2 PRECEDING to 6 PRECEDING for a seven-day moving average (the syntax is the same).
Expected output (event_date ascending):
| event_date | dau | dau_ma3 |
|---|---|---|
| 2024-03-01 | 100 | 100.00 |
| 2024-03-02 | 120 | 110.00 |
| 2024-03-03 | 110 | 110.00 |
| 2024-03-04 | 90 | 106.67 |
| 2024-03-05 | 150 | 116.67 |
| 2024-03-06 | 140 | 126.67 |
| 2024-03-07 | 160 | 150.00 |
The first two days have only one or two rows available, so their averages use partial frames. 03-04 = (120+110+90)/3 = 106.67.
SELECT event_date, dau, ROUND( AVG(dau) OVER ( ORDER BY event_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 3-day window including today ), 2 ) AS dau_ma3 FROM daily_active ORDER BY event_date; /* Execution order (SQL's logical evaluation order): 1. FROM daily_active → read the rows 2. ORDER BY event_date (inside the window) → fix the frame order 3. AVG(dau) OVER (... ROWS ...) → average today + previous 2 days 4. ROUND(..., 2) → round to two decimal places 5. SELECT / ORDER BY event_date → output in ascending date order */
LEGEND
① FROM daily_active (7 rows)
FROM daily_activeRead all daily DAU rows. The drop on 03-04 and rise from 03-05 through 03-07 show large day-to-day fluctuations, making the trend difficult to read from raw values.| event_date | dau |
|---|---|
| 03-01 | 100 |
| 03-02 | 120 |
| 03-03 | 110 |
| 03-04 | 90 |
| 03-05 | 150 |
| 03-06 | 140 |
| 03-07 | 160 |
RANGE UNBOUNDED PRECEDING, a cumulative average from the first row. To limit it to the most recent N rows, always specify ROWS BETWEEN n PRECEDING AND CURRENT ROW.COUNT(*) OVER (...) = 7.ROWS BETWEEN 6 PRECEDING AND CURRENT ROW produces a seven-day moving average, while 3 PRECEDING AND 3 FOLLOWING produces a centered moving average. Changing only the frame boundaries expresses many kinds of smoothing.AVG(dau) OVER (ORDER BY event_date) produces a cumulative average from the beginning, which becomes increasingly slow to react as days pass. A ROWS frame is required for a moving average.JOIN ... ON b.date BETWEEN a.date-2 AND a.date is verbose, and missing dates can shift the window. A window frame is accurate and fast in one line.“What percentage of sales does each category represent?” and “How far toward the total do we get when accumulating categories from the top?” are the core questions of Pareto and ABC analysis for identifying priority products. Window functions let you place the grand total and a running total next to each detail row without removing the detail rows.
SUM(sales) OVER () -- empty OVER = one window over all rows; attach the grand total to each row SUM(sales) OVER (ORDER BY sales DESC) -- running total in the specified order
From the category_sales table, which contains pre-aggregated sales by category, calculate the sales mix (sales_pct) and cumulative sales mix in descending sales order (cum_pct). Return category, sales, sales_pct, cum_pct in descending sales order, and round the ratios to two decimal places.
| category | sales |
|---|---|
| Electronics | 5000 |
| Apparel | 3000 |
| Home | 1500 |
| Books | 800 |
| Toys | 700 |
※ Grand total = 5000+3000+1500+800+700 = 11000.
Expected output (sales descending):
| category | sales | sales_pct | cum_pct |
|---|---|---|---|
| Electronics | 5000 | 45.45 | 45.45 |
| Apparel | 3000 | 27.27 | 72.73 |
| Home | 1500 | 13.64 | 86.36 |
| Books | 800 | 7.27 | 93.64 |
| Toys | 700 | 6.36 | 100.00 |
At 72.73% cumulative, the top two categories account for about seven-tenths of the total (the Pareto A group).
SELECT category, sales, ROUND(sales * 100.0 / SUM(sales) OVER (), 2) AS sales_pct, -- sales mix = each row ÷ grand total ROUND( SUM(sales) OVER (ORDER BY sales DESC) * 100.0 -- running total / SUM(sales) OVER (), 2 ) AS cum_pct -- cumulative sales mix FROM category_sales ORDER BY sales DESC; /* Execution order (SQL's logical evaluation order): 1. FROM category_sales → read the rows 2. SUM(sales) OVER () → attach the grand total to every row 3. SUM(sales) OVER (ORDER BY sales DESC) → calculate the running total in sales-descending order 4. SELECT calculates the ratios → calculate the sales mix 5. ORDER BY sales DESC → output in descending sales order */
LEGEND
① FROM category_sales (5 rows)
FROM category_salesRead the pre-aggregated sales by category. The data is already close to sales-descending order, but the window function's ORDER BY guarantees the order.| category | sales |
|---|---|
| Electronics | 5000 |
| Apparel | 3000 |
| Home | 1500 |
| Books | 800 |
| Toys | 700 |
SUM(sales) OVER () does not collapse rows; it attaches the grand total to every detail row. This lets you calculate detail ÷ grand total on the same row. It is more concise than recalculating the grand total in a subquery.SUM(sales) OVER (ORDER BY sales DESC) uses the default frame from the first row through the current row, so it returns a running total. Running total ÷ grand total is the cumulative sales mix, the y-value of the Pareto curve.3000/11000 is truncated to 0. Use * 100.0 (or ::numeric) to promote the calculation to floating point before ROUND.ORDER BY sales DESC ROWS UNBOUNDED PRECEDING and add a tie-breaker column.Group-internal rankings such as “the top three products in each category” and “rank within a department” are implemented with PARTITION BY. There are three ranking functions, and the most important point is that they handle ties differently.
RANK() OVER (PARTITION BY category ORDER BY sales DESC) -- ties share a rank; skip the next 1,1,3 DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) -- ties share a rank; do not skip 1,1,2 ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) -- force consecutive numbers 1,2,3
PARTITION BY resets the ranking within each group while preserving the rows. Rank 1 is assigned again at the beginning of each partition.From the product_sales table, assign the within-category sales rank in three ways: RANK, DENSE_RANK, and ROW_NUMBER. Return category, product, sales, rnk, dense_rnk, row_num ordered by category ascending, sales descending, and product ascending.
| category | product | sales |
|---|---|---|
| Food | Apple | 500 |
| Food | Bread | 500 |
| Food | Cake | 300 |
| Food | Donut | 200 |
| Beverage | Cola | 400 |
| Beverage | Tea | 250 |
| Beverage | Water | 150 |
※ Food's Apple and Bread both have sales of 500 (a tie). This is where the three functions differ.
Expected output (category ascending, sales descending):
| category | product | sales | rnk | dense_rnk | row_num |
|---|---|---|---|---|---|
| Beverage | Cola | 400 | 1 | 1 | 1 |
| Beverage | Tea | 250 | 2 | 2 | 2 |
| Beverage | Water | 150 | 3 | 3 | 3 |
| Food | Apple | 500 | 1 | 1 | 1 |
| Food | Bread | 500 | 1 | 1 | 2 |
| Food | Cake | 300 | 3 | 2 | 3 |
| Food | Donut | 200 | 4 | 3 | 4 |
Apple and Bread tie: RANK gives both 1 and makes the next rank 3 (skipping 2), DENSE_RANK makes the next rank 2, and ROW_NUMBER forces the numbers 1 and 2.
SELECT category, product, sales, RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rnk, -- ties share a rank; skip the next DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dense_rnk, -- ties share a rank; do not skip ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC, product) AS row_num -- force consecutive numbers (tie-break with a second key) FROM product_sales ORDER BY category, sales DESC, product; /* Execution order (SQL's logical evaluation order): 1. FROM product_sales → read the rows 2. PARTITION BY category → split by category 3. ORDER BY sales DESC (inside the window) → sales descending within each partition 4. RANK/DENSE_RANK/ROW_NUMBER → assign ranks 5. ORDER BY → arrange the final display order */
LEGEND
① FROM product_sales (7 rows)
FROM product_salesRead sales by category and product. Food's Apple and Bread are tied at 500; keep that tie in mind.| category | product | sales |
|---|---|---|
| Food | Apple | 500 |
| Food | Bread | 500 |
| Food | Cake | 300 |
| Food | Donut | 200 |
| Beverage | Cola | 400 |
| Beverage | Tea | 250 |
| Beverage | Water | 150 |
RANK assigns the same rank and skips the next number (1,1,3), DENSE_RANK does not skip (1,1,2), and ROW_NUMBER assigns a unique sequence even for a tie (1,2). The function depends on what “within the top three” means for the requirement.PARTITION BY category gives Beverage and Food independent sequences. Without PARTITION BY, one continuous ranking is assigned across the entire table.WHERE row_num <= 3 directly. The standard pattern is WITH ranked AS (SELECT ..., ROW_NUMBER() ...) SELECT * FROM ranked WHERE row_num <= 3.ORDER BY sales DESC, product, to stabilize the result.HAVING narrows the result by an aggregate condition, such as “repeat customers with at least two completed orders.” Understanding its division of labor with WHERE, which filters individual rows, is essential. PostgreSQL's FILTER clause also lets you specify the rows counted by each aggregate function in a readable way.
-- WHERE : filter “rows” before grouping -- HAVING : filter “groups” after grouping (aggregate values can be used) COUNT(*) FILTER (WHERE status = 'completed') -- count only rows matching the condition -- = equivalent to SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END), but more readable
COUNT(*) >= 2. WHERE cannot use an aggregate value because aggregation has not happened yet.From the orders table, extract heavy users with at least two completed orders, and return their completed-order count and completed-amount total. Return user_id, completed_orders, completed_amount ordered by completed_amount descending.
| user_id | order_id | amount | status |
|---|---|---|---|
| U1 | O1 | 2000 | completed |
| U1 | O2 | 1500 | completed |
| U1 | O3 | 800 | cancelled |
| U2 | O4 | 500 | completed |
| U3 | O5 | 3000 | completed |
| U3 | O6 | 2500 | completed |
| U3 | O7 | 1000 | completed |
| U4 | O8 | 300 | cancelled |
Expected output (completed_amount descending):
| user_id | completed_orders | completed_amount |
|---|---|---|
| U3 | 3 | 6500 |
| U1 | 2 | 3500 |
U2 has one completed order (<2), and U4 has zero completed orders, so both are excluded. U1's cancelled O3 is excluded, leaving two completed orders totaling 3500.
SELECT user_id, COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, -- count only completed orders COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amount -- sum completed amounts only; use 0 when none match FROM orders GROUP BY user_id HAVING COUNT(*) FILTER (WHERE status = 'completed') >= 2 -- filter groups after aggregation ORDER BY completed_amount DESC; /* Execution order (SQL's logical evaluation order): 1. FROM orders → read 8 rows 2. GROUP BY user_id → form four groups: U1, U2, U3, U4 3. Aggregation (FILTER targets completed only) → U1:2/3500, U2:1/500, U3:3/6500, U4:0/0 4. HAVING completed_orders >= 2 → exclude U2(1) and U4(0) 5. SELECT / ORDER BY completed_amount DESC → output in descending amount order */
LEGEND
① FROM orders (8 rows)
FROM ordersRead every order row. Completed and cancelled statuses are mixed, and cancelled orders must not be counted as sales.| user_id | order_id | amount | status |
|---|---|---|---|
| U1 | O1 | 2000 | completed |
| U1 | O2 | 1500 | completed |
| U1 | O3 | 800 | cancelled |
| U2 | O4 | 500 | completed |
| U3 | O5 | 3000 | completed |
| U3 | O6 | 2500 | completed |
| U3 | O7 | 1000 | completed |
| U4 | O8 | 300 | cancelled |
HAVING COUNT(*) >= 2.SUM(CASE WHEN ... THEN 1 ELSE 0 END) used in Q3 can be written in PostgreSQL as COUNT(*) FILTER (WHERE ...). Multiple conditional aggregates in one query remain easy to understand.WHERE order_date >= '2024-01-01' to filter rows by period first, followed by GROUP BY and HAVING SUM(amount) >= 10000 to keep only high-value groups.WHERE COUNT(*) >= 2 produces an error because aggregate functions cannot be used in WHERE. Put aggregate conditions in HAVING. Conversely, row-level conditions such as pre-filtering status should go in WHERE for better performance.SUM(amount) mixes cancelled amounts into sales. Explicitly define what counts as sales with FILTER (or WHERE) and keep the definition consistent.Churn is the reverse side of retention and a vital KPI for SaaS and subscription businesses. To find “users who were present last month but absent this month,” the standard pattern is an anti-join: LEFT JOIN and keep only rows with no matching row on the other side (IS NULL).
LEFT JOIN ... ON condition WHERE right_table.key IS NULL -- no match = extract the set difference -- present in prev (last month) but absent from curr (this month) = churn
curr.month = '2024-04', in the ON clause. If you put it in WHERE, NULL rows fail NULL = '2024-04' and disappear, causing the LEFT JOIN to degenerate into an INNER JOIN and breaking the anti-join.From monthly_active, which records monthly active users, identify users who were active in 2024-03 but inactive in 2024-04. Return user_id in ascending user_id order. Also confirm the churn rate in the explanation.
| month | user_id |
|---|---|
| 2024-03 | U1 |
| 2024-03 | U2 |
| 2024-03 | U3 |
| 2024-03 | U4 |
| 2024-04 | U1 |
| 2024-04 | U3 |
| 2024-04 | U5 |
※ March={U1,U2,U3,U4}; April={U1,U3,U5}. U5 is new, not churned.
Expected output (churned users, user_id ascending):
| user_id |
|---|
| U2 |
| U4 |
U2 and U4 were present in March but absent in April, so they churned. Churn rate = 2 churned users ÷ 4 March active users = 50.00%.
SELECT prev.user_id -- present in March but absent in April = churned user FROM monthly_active AS prev LEFT JOIN monthly_active AS curr ON curr.user_id = prev.user_id AND curr.month = '2024-04' -- put the right-table filter in ON WHERE prev.month = '2024-03' AND curr.user_id IS NULL -- no match = inactive in April ORDER BY prev.user_id; /* Execution order (SQL's logical evaluation order): 1. FROM prev LEFT JOIN curr → match the next month to prev (keep unmatched rows as NULL) 2. WHERE prev.month → limit to users in the reference month 3. AND curr.user_id IS NULL → keep only rows with no match in the next month = churn 4. SELECT prev.user_id / ORDER BY → output in ascending order -- Churn rate (extra): -- COUNT(*) FILTER (WHERE curr.user_id IS NULL) * 100.0 / COUNT(*) -- = 2 / 4 * 100 = 50.00 (denominator: March active users) */
LEGEND
① FROM monthly_active (7 rows)
FROM monthly_activeThese are all monthly-active records: U1–U4 in March and U1, U3, and U5 in April. The same table is self-joined in two roles, prev (last month) and curr (this month).| month | user_id |
|---|---|
| 2024-03 | U1 |
| 2024-03 | U2 |
| 2024-03 | U3 |
| 2024-03 | U4 |
| 2024-04 | U1 |
| 2024-04 | U3 |
| 2024-04 | U5 |
WHERE right.key IS NULL to extract only rows with no matching row. This gives the difference set “present in prev but absent in curr” and applies to churn, non-purchasers, missing records, and more.curr.month='2024-04' in ON. The reference-side condition prev.month='2024-03' belongs in WHERE because prev is the left table whose rows are retained. This placement distinction is the heart of outer joins.WHERE NOT EXISTS (SELECT 1 FROM monthly_active c WHERE c.user_id=prev.user_id AND c.month='2024-04') is equivalent and is often preferred in practice because it is readable and has fewer NULL traps. NOT IN becomes false for every row if the target side contains NULL, so NOT EXISTS is safer.WHERE curr.month='2024-04' makes the unmatched rows fail NULL='2024-04' and disappear. The anti-join then returns no churned users—a classic bug. Put the right-table filter in ON.