SQL for Reporting
Topic 6 of 12 · In Reporting Analytics · September 9, 2026
Write report-oriented SQL queries: complex aggregations, window functions, pivoting, CTEs, views, and performance optimization.
Overview
SQL is the language that connects you to the data. While Excel and Power BI handle presentation, SQL handles extraction and preparation — and for many reports, the query is the report. A well-written SQL query can produce a perfectly formatted, aggregated, time-compared dataset that loads directly into a report or dashboard with zero manual steps.
This topic focuses on report-oriented SQL — not general database programming, but the specific patterns analysts use to produce reporting data. You will learn complex aggregations, window functions for running totals and period comparisons, pivoting data for cross-tab reports, CTEs for readable multi-step logic, creating views that serve as report data layers, and the performance considerations that keep large-report queries fast.
Before You Start
- Re-read Advanced Excel for Reporting, since the report-oriented mindset carries across tools.
- Have the STAR schema dataset from the data warehouse topic loaded in your SQL client, with fact_sales, dim_date, dim_product and dim_region tables.
- Be able to write a basic query combining SELECT, WHERE, JOIN, GROUP BY, ORDER BY and the aggregates SUM, COUNT and AVG before the lesson.
- Understand what a GROUP BY row collapses: one summary row per group, with detail rows removed.
- Load a realistic sales sample of at least 10,000 rows so you can benchmark query performance.
Learning Objectives
- Write report-oriented queries with GROUP BY, HAVING and multi-level aggregation
- Use window functions for running totals, rank, period-over-period comparisons, and moving averages
- Pivot and unpivot data for cross-tab report layouts
- Build complex queries using Common Table Expressions (CTEs) for readability
- Create SQL views as reusable report data layers
- Apply performance optimization techniques: indexing, query planning, avoiding anti-patterns
- Format query output for direct use in reports (date formatting, rounding, null handling)
- Understand when to use SQL vs when to use a BI tool for report generation
Prerequisites
Complete Advanced Excel for Reporting. You should be comfortable with basic SQL: SELECT, WHERE, JOIN, GROUP BY, ORDER BY, and aggregate functions (SUM, COUNT, AVG). If you need a SQL refresher, see the Data Analysis path SQL topic.
Report-Oriented Query Patterns
A report-oriented query produces a result set that is ready for presentation — already aggregated, formatted, and organized the way the report needs it. Key patterns:
- Multi-level aggregation — aggregate by multiple dimensions (region, then product, then month) with subtotals
- Period comparison — compare this period to last period or to the same period last year
- Percentage of total — each row shows its share of the whole
- Ranking — assign a rank within each group (top N products per region)
- Cumulative / running total — sum accumulating over time
- Gap identification — find missing periods, inactive customers, or unmet thresholds
The goal is to return a result set that needs minimal post-processing. If the report tool (Power BI, Excel, SSRS) can display the query output directly, the report is faster to build and less error-prone.
Window Functions for Reporting
Window functions perform calculations across a set of rows related to the current row — without collapsing them like GROUP BY. They are the most powerful tool in a report-builder's SQL toolkit.
Core window functions:
| Function | Purpose | Report Use |
|---|---|---|
SUM() OVER() | Running or moving total | Cumulative revenue, YTD totals |
ROW_NUMBER() | Sequential row numbering | Row numbering for pagination |
RANK() / DENSE_RANK() | Position within a partition | Top N products per region |
LAG() / LEAD() | Access previous/next row value | Period-over-period comparison |
FIRST_VALUE() / LAST_VALUE() | First/last value in a window | Opening/closing balance |
NTILE() | Divide into N buckets | Quartile or decile segmentation |
SELECT
order_date,
revenue,
SUM(revenue) OVER (ORDER BY order_date) AS running_total,
LAG(revenue, 1) OVER (ORDER BY order_date) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY order_date) AS mom_change,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY order_date))
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY order_date), 0) * 100,
1
) AS mom_pct_change
FROM monthly_revenue
ORDER BY order_date;Running Totals, Moving Averages and Cumulative Metrics
These are among the most common report requirements:
Running total:
-- Running total of revenue by date
SELECT
order_date,
revenue,
SUM(revenue) OVER (ORDER BY order_date ROWS UNBOUNDED PRECEDING) AS running_total
FROM daily_revenue;-- 7-day moving average of daily revenue
SELECT
order_date,
revenue,
AVG(revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_revenue;-- YTD revenue by resetting at year boundary
SELECT
order_date,
revenue,
SUM(revenue) OVER (
PARTITION BY YEAR(order_date)
ORDER BY order_date
ROWS UNBOUNDED PRECEDING
) AS ytd_revenue
FROM daily_revenue;Period-over-Period Comparisons
Reports almost always compare current performance to a baseline — last month, last quarter, last year, or a target. SQL techniques:
SELECT
d.month_label,
r.revenue,
-- Month-over-month
LAG(r.revenue, 1) OVER (ORDER BY d.month_date) AS prev_month,
r.revenue - LAG(r.revenue, 1) OVER (ORDER BY d.month_date) AS mom_diff,
-- Year-over-year
LAG(r.revenue, 12) OVER (ORDER BY d.month_date) AS prev_year,
r.revenue - LAG(r.revenue, 12) OVER (ORDER BY d.month_date) AS yoy_diff,
-- % of target
ROUND(r.revenue / t.target_amount * 100, 1) AS pct_of_target
FROM monthly_revenue r
JOIN dim_date d ON r.month_date = d.month_date
LEFT JOIN monthly_targets t ON r.month_date = t.month_date
ORDER BY d.month_date;Pivoting Data for Cross-Tab Reports
Reports often need data in a wide (cross-tab) format: rows for products, columns for months. SQL provides two approaches:
Conditional aggregation (works everywhere):
SELECT
product_name,
SUM(CASE WHEN month = 'Jan' THEN revenue END) AS jan,
SUM(CASE WHEN month = 'Feb' THEN revenue END) AS feb,
SUM(CASE WHEN month = 'Mar' THEN revenue END) AS mar,
SUM(CASE WHEN month = 'Apr' THEN revenue END) AS apr,
SUM(revenue) AS total
FROM monthly_sales
GROUP BY product_name
ORDER BY total DESC;SELECT product_name, [Jan], [Feb], [Mar], [Apr], [Total]
FROM (
SELECT product_name, month, revenue
FROM monthly_sales
) AS src
PIVOT (
SUM(revenue)
FOR month IN ([Jan], [Feb], [Mar], [Apr])
) AS pvt
ORDER BY ([Jan] + [Feb] + [Mar] + [Apr]) DESC;CTEs for Complex Report Logic
Common Table Expressions (CTEs) let you break a complex query into named, readable steps — like building a report in stages. Each CTE is a temporary result set that the next CTE (or the final SELECT) can reference.
WITH
-- Step 1: Aggregate daily sales to monthly
monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
region,
SUM(revenue) AS revenue
FROM orders
GROUP BY 1, 2
),
-- Step 2: Calculate month-over-month growth
with_growth AS (
SELECT
*,
LAG(revenue, 1) OVER (PARTITION BY region ORDER BY month) AS prev_month,
ROUND(
(revenue - LAG(revenue, 1) OVER (PARTITION BY region ORDER BY month))
/ NULLIF(LAG(revenue, 1) OVER (PARTITION BY region ORDER BY month), 0) * 100,
1
) AS growth_pct
FROM monthly
),
-- Step 3: Rank regions by latest month revenue
ranked AS (
SELECT
*,
RANK() OVER (PARTITION BY month ORDER BY revenue DESC) AS region_rank
FROM with_growth
)
-- Final: return report-ready output
SELECT
FORMAT(month, 'MMM YYYY') AS period,
region,
revenue,
prev_month,
growth_pct,
region_rank
FROM ranked
WHERE month = (SELECT MAX(month) FROM ranked)
ORDER BY region_rank;Views as Report Data Layers
A SQL view is a named query stored in the database. Views serve as a clean, reusable data layer between raw tables and your reports.
Benefits for reporting:
- Simplification — a view can join, filter and aggregate multiple tables into one readable result set
- Consistency — every report that uses the same view gets the same logic
- Security — expose only the columns and rows a report needs (row-level security via views)
- Maintainability — change the view logic once, all reports using it update automatically
CREATE VIEW vw_monthly_sales_report AS
SELECT
d.fiscal_month,
d.fiscal_quarter,
p.product_category,
r.region_name,
SUM(f.revenue) AS revenue,
SUM(f.units_sold) AS units,
ROUND(SUM(f.profit) / NULLIF(SUM(f.revenue), 0) * 100, 1) AS margin_pct,
SUM(f.revenue) - LAG(SUM(f.revenue), 1) OVER (
PARTITION BY p.product_category, r.region_name
ORDER BY d.fiscal_month
) AS mom_change
FROM fact_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_region r ON f.region_key = r.region_key
GROUP BY 1, 2, 3, 4;Performance Optimization for Reporting Queries
Large reports can take minutes to run if the query is not optimized. Key techniques:
- Select only the columns you need —
SELECT *pulls unnecessary data and slows the query - Filter early — use WHERE clauses to reduce the dataset before aggregation
- Use indexed columns in JOIN and WHERE clauses — check the execution plan for table scans
- Avoid correlated subqueries — they execute once per row. Replace with JOINs or CTEs
- Use EXPLAIN / Execution Plan — understand how the database engine processes your query
- Materialize summary tables — for very large datasets, pre-aggregate into summary tables on a schedule
- Partition large tables — by date or region, so queries scan only relevant partitions
Report-Ready Data Formatting in SQL
When the query output goes directly to a report, formatting in SQL saves time downstream:
- Date formatting —
FORMAT(order_date, 'MMM DD, YYYY')(SQL Server) orTO_CHAR(order_date, 'Mon DD, YYYY')(PostgreSQL/Oracle) - Null handling —
COALESCE(sales, 0)to replace NULLs with zeros for display - Rounding —
ROUND(revenue, 2)for currency,ROUND(pct, 1)for percentages - Case statements for categories —
CASE WHEN score >= 90 THEN 'A' WHEN score >= 80 THEN 'B' ... END - Concatenation —
CONCAT(first_name, ' ', last_name)for full names - Conditional totals —
SUM(CASE WHEN status = 'Active' THEN 1 ELSE 0 END)
SELECT
CONCAT(c.first_name, ' ', c.last_name) AS customer_name,
COALESCE(SUM(o.revenue), 0) AS total_revenue,
COUNT(o.order_id) AS order_count,
MAX(o.order_date) AS last_order_date,
DATEDIFF(day, MAX(o.order_date), GETDATE()) AS days_since_last_order,
CASE
WHEN DATEDIFF(day, MAX(o.order_date), GETDATE()) <= 30 THEN 'Active'
WHEN DATEDIFF(day, MAX(o.order_date), GETDATE()) <= 90 THEN 'At Risk'
ELSE 'Churned'
END AS customer_status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.first_name, c.last_name
ORDER BY total_revenue DESC;Hands-On Practice
Use any SQL-accessible database (MySQL, PostgreSQL, SQL Server, or a cloud sandbox like BigQuery free tier):
- Write a query that produces monthly revenue with month-over-month change and YoY comparison using LAG.
- Create a running total of daily revenue using SUM() OVER.
- Pivot a sales table into a cross-tab showing revenue by product across months using conditional aggregation.
- Build a CTE pipeline: aggregate raw orders → calculate growth → rank regions → output the top 3 regions per quarter.
- Create a view that joins orders, products, and customers into a report-ready dataset.
- Analyze a slow query using EXPLAIN — identify the bottleneck and optimize it.
Deeper Worked Examples
Monthly revenue with month-over-month and year-over-year in one query
Step 1
Step 2
Step 3
Step 4
Step 5
Step 6
CTE pipeline producing the top three revenue regions for a quarter
Step 1
Step 2
Step 3
Step 4
Step 5
Step 6
Common Mistakes
- Using SELECT * when only 5 columns are needed — pulls unnecessary data and slows every query
- Writing correlated subqueries when a JOIN or window function is simpler and faster
- Forgetting NULL handling — NULLs in aggregations produce unexpected results in reports
- Not testing queries at production scale — a query that is fast on 1K rows may be unusable on 10M
- Hardcoding date ranges instead of using relative date logic (DATEADD, INTERVAL)
- Pivoting in application code when the database can do it more efficiently
- Skipping indexes on JOIN and WHERE columns — full table scans are the #1 cause of slow report queries
- Not using views to standardize report logic — duplicated query logic leads to inconsistent numbers across reports
Real-World Usage
In a mature data organization, report queries are stored in a version-controlled repository (Git), peer-reviewed before deployment, and registered in a data catalog. The analyst writes the query, a senior engineer reviews it for performance and correctness, and the result is published as a view or a materialized table that the BI tool connects to. This separation of concerns means the report builder does not need to understand the entire data model — they only need to know the report view's columns and definitions.
Mini Project
Business problem: A subscription SaaS company needs a quarterly business review (QBR) report. The raw data is in three tables: customers, subscriptions, and invoices.
Objective: build the complete SQL pipeline that produces a QBR-ready dataset.
Task 1
Task 2
Task 3
Task 4
Task 5
Task 6
| Deliverable | What it proves |
|---|---|
| MRR CTE | You can calculate SaaS metrics from raw data |
| Window function pipeline | You can use LAG, SUM OVER, RANK |
| Cohort segmentation | You can build analytical groupings |
| Cross-tab pivot | You can reshape data for reports |
| Report view | You can create reusable data layers |
| Formatted output | You can prepare report-ready data |
Key Takeaways
- Report-oriented SQL produces aggregated, formatted result sets ready for presentation
- Window functions enable running totals, ranks, period comparisons, and moving averages without collapsing rows
- LAG/LEAD are essential for period-over-period comparisons in reports
- Pivoting (conditional aggregation or PIVOT operator) creates cross-tab report layouts
- CTEs break complex queries into readable, maintainable steps
- Views provide a consistent, secure, reusable data layer for reports
- Performance matters: select only needed columns, filter early, index JOIN/WHERE columns, use EXPLAIN
- Format output in SQL: COALESCE for nulls, ROUND for precision, CASE for categories, DATE formatting
Key Terms & Glossary
| Term | Meaning |
|---|---|
| Window function | A calculation performed across rows related to the current row that preserves every row, unlike GROUP BY which collapses them. |
| Common table expression | A named, temporary result set that lets you break a complex query into readable steps that the next step can reference, like building a report in stages. |
| View | A stored query acting as a reusable data layer between raw tables and reports, keeping logic consistent and exposing only approved columns and rows. |
| Partition | The grouping inside a window function, set with PARTITION BY, that restarts the calculation for each group such as a per-region running total. |
| Running total | A cumulative sum over time computed with SUM() OVER, such as year-to-date revenue, that never collapses the underlying rows. |
| Conditional aggregation | Pivoting data with CASE inside SUM, such as SUM(CASE WHEN month = Jan THEN revenue END), which works in every SQL dialect. |
| COALESCE | A function that replaces NULL values with a chosen default, such as COALESCE(revenue, 0), so reports never show unexplained blanks. |
| Execution plan | The database engine's strategy for running a query, explored with EXPLAIN, which reveals table scans and index misses that slow reports down. |
Learning Resources
Window Functions Tutorial (Mode Analytics)
Clear, visual introduction to window functions with SQL examples for reporting.
SQL for Data Analysis (Khan Academy)
Free interactive SQL course covering aggregations, joins, and subqueries.
Explain Explanation (Use The Index, Luke)
The best resource for understanding SQL execution plans and query optimization.
SQL Pivoting Techniques (Database Journal)
Detailed guide to pivoting data in SQL using both PIVOT operator and CASE aggregation.
Advanced SQL for Data Science (Coursera — UC Davis)
University course covering window functions, CTEs, optimization and reporting patterns.
SQL Performance Explained (Markus Winand)
The definitive book on SQL indexing and performance — free online version available.
Chapter Quiz
Chapter Quiz — SQL for Reporting
Report-oriented queries, window functions, pivoting, CTEs, views, performance and report-ready formatting.
Question 1 of 5
To compute month-over-month revenue change in SQL, which window function reads the previous row's value?
Knowledge Check
End-of-Topic Exam
Hint: One collapses rows; the other preserves them.
Model answer: GROUP BY collapses multiple rows into one summary row per group and removes detail, which is correct for a simple total per category. Window functions compute values across related rows while preserving every row in the result set, which is why SUM(revenue) OVER (ORDER BY month_date) produces a running total next to each month. You cannot replace GROUP BY with window functions when you need each group as a single row, and you cannot produce a running total with GROUP BY alone.
Hint: You need the value from the previous row and a guarded division.
Model answer: Use LAG to access the previous month's value: SELECT revenue, LAG(revenue, 1) OVER (ORDER BY month) AS prev_month. Then compute the difference as revenue minus prev_month, and the percentage as (revenue - prev_month) divided by NULLIF(prev_month, 0) multiplied by 100. NULLIF prevents division by zero on the first month, and ROUND trims the percentage to one decimal place.
Hint: Portability across dialects is the deciding factor.
Model answer: Standardise on conditional aggregation, placing a CASE inside SUM as in SUM(CASE WHEN month = Jan THEN revenue END), because it works in every SQL dialect including MySQL, PostgreSQL and BigQuery. The PIVOT operator is specific to SQL Server and Oracle, so using it would tie the organisation to one platform and break the report on the other databases.
Hint: Reduce data pulled, filter early, and check the execution plan.
Model answer: Select only the columns the report needs instead of SELECT star, which slashes the data pulled from disk. Filter early with WHERE clauses so aggregation runs on a smaller set. Index the columns used in JOIN and WHERE clauses, then check the execution plan with EXPLAIN to confirm table scans disappear. Also replace correlated subqueries with JOINs or CTEs, and consider materialising a pre-aggregated summary table for very large datasets.