Mohamed A. Adel.

SQL for Reporting

Topic 6 of 12 · In Reporting Analytics · September 9, 2026

⏱ 2 WeeksIntermediate

Write report-oriented SQL queries: complex aggregations, window functions, pivoting, CTEs, views, and performance optimization.

++++FREE LEARNING PATHREPORTING ANALYTICSREPORTING ANALYTICS · PHASE 2 · INTERMEDIATESQL FOR REPORTINGWrite report-oriented SQL queries: complex aggregat…· 2 / 3INTERMEDIATE2 WEEKSmotif / db3980 · 80 · MODULE 1.2

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:

FunctionPurposeReport Use
SUM() OVER()Running or moving totalCumulative revenue, YTD totals
ROW_NUMBER()Sequential row numberingRow numbering for pagination
RANK() / DENSE_RANK()Position within a partitionTop N products per region
LAG() / LEAD()Access previous/next row valuePeriod-over-period comparison
FIRST_VALUE() / LAST_VALUE()First/last value in a windowOpening/closing balance
NTILE()Divide into N bucketsQuartile or decile segmentation
Running total and period comparisonsql
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:

sqlsql
-- 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 averagesql
-- 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 cumulativesql
-- 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:

YoY, MoM, QoQ comparisonssql
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):

Conditional aggregation pivotsql
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;
PIVOT operator (SQL Server)sql
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.

CTE-based report pipelinesql
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
Creating a report viewsql
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) or TO_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)
Formatting for report outputsql
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):

  1. Write a query that produces monthly revenue with month-over-month change and YoY comparison using LAG.
  2. Create a running total of daily revenue using SUM() OVER.
  3. Pivot a sales table into a cross-tab showing revenue by product across months using conditional aggregation.
  4. Build a CTE pipeline: aggregate raw orders → calculate growth → rank regions → output the top 3 regions per quarter.
  5. Create a view that joins orders, products, and customers into a report-ready dataset.
  6. Analyze a slow query using EXPLAIN — identify the bottleneck and optimize it.

Deeper Worked Examples

These examples push further than the core content — each one is a complete, step-by-step solution you can follow and then reproduce on your own.

Monthly revenue with month-over-month and year-over-year in one query

Problem. The table monthly_revenue holds revenue for 36 consecutive months ending July 2026, where the latest month is 412,000, the previous month is 390,000 and the same month last year is 370,000. Write one query that returns each month's revenue, the month-over-month percentage change and the year-over-year percentage change using window functions, then confirm the July figures.
1

Step 1

Start with the base columns: SELECT month_date, revenue FROM monthly_revenue ORDER BY month_date, and confirm the table is ordered by month.
2

Step 2

Add the previous month with LAG(revenue, 1) OVER (ORDER BY month_date) AS prev_month, which reads the value one row behind.
3

Step 3

Add the year-over-year baseline with LAG(revenue, 12) OVER (ORDER BY month_date) AS prev_year, which reads the value twelve rows behind.
4

Step 4

Compute the deltas and percentages: revenue - prev_month AS mom_change, ROUND((revenue - prev_month) / NULLIF(prev_month, 0) * 100, 1) AS mom_pct, and the same pair with prev_year for the year-over-year values.
5

Step 5

Wrap the divisor in NULLIF so the earliest rows, which have no predecessor, do not divide by zero.
6

Step 6

Run the query and verify July: the MoM change is (412000 - 390000) / 390000 * 100 equals 5.6%, and the YoY change is (412000 - 370000) / 370000 * 100 equals 11.4%.

CTE pipeline producing the top three revenue regions for a quarter

Problem. The fact_sales and dim_date tables let you aggregate revenue by region and fiscal quarter. For Q2 2026, total revenue is 2,400,000 split as North 960,000, South 720,000, East 480,000 and West 240,000. Build a readable pipeline that returns the three highest-revenue regions with each one's share of the quarterly total.
1

Step 1

Write a CTE named quarterly that sums revenue by region and fiscal quarter, joining fact_sales to dim_date on date_key and grouping by region and quarter.
2

Step 2

Add a second CTE that computes SUM(revenue) OVER (PARTITION BY fiscal_quarter) AS quarter_total so each row carries its quarter's grand total for the share calculation.
3

Step 3

Add RANK() OVER (PARTITION BY fiscal_quarter ORDER BY revenue DESC) AS region_rank to position every region within its quarter.
4

Step 4

Add the share column with ROUND(revenue / quarter_total * 100, 1) AS share_pct so the report shows each region's contribution.
5

Step 5

Write the final SELECT with a WHERE clause filtering to Q2 2026 and region_rank <= 3, ordered by region_rank.
6

Step 6

Verify the output: North 40.0%, South 30.0% and East 20.0% appear, while West at 10.0% is correctly excluded as the fourth-ranked region.

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.

1

Task 1

Write a CTE that calculates monthly recurring revenue (MRR) per customer by joining subscriptions and invoices.
2

Task 2

Add window functions to compute MRR growth (month-over-month) and customer lifetime value (cumulative revenue).
3

Task 3

Create a second CTE that segments customers into cohorts by signup month.
4

Task 4

Build a cross-tab pivot showing MRR by cohort across the last 12 months.
5

Task 5

Create a view called vw_qbr_report that encapsulates the entire pipeline.
6

Task 6

Add formatting: date labels, null handling, rounding, and a customer status field (active/churned).
DeliverableWhat it proves
MRR CTEYou can calculate SaaS metrics from raw data
Window function pipelineYou can use LAG, SUM OVER, RANK
Cohort segmentationYou can build analytical groupings
Cross-tab pivotYou can reshape data for reports
Report viewYou can create reusable data layers
Formatted outputYou 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

The vocabulary this topic uses, defined the way a working professional would.
TermMeaning
Window functionA calculation performed across rows related to the current row that preserves every row, unlike GROUP BY which collapses them.
Common table expressionA 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.
ViewA stored query acting as a reusable data layer between raw tables and reports, keeping logic consistent and exposing only approved columns and rows.
PartitionThe grouping inside a window function, set with PARTITION BY, that restarts the calculation for each group such as a per-region running total.
Running totalA cumulative sum over time computed with SUM() OVER, such as year-to-date revenue, that never collapses the underlying rows.
Conditional aggregationPivoting data with CASE inside SUM, such as SUM(CASE WHEN month = Jan THEN revenue END), which works in every SQL dialect.
COALESCEA function that replaces NULL values with a chosen default, such as COALESCE(revenue, 0), so reports never show unexplained blanks.
Execution planThe database engine's strategy for running a query, explored with EXPLAIN, which reveals table scans and index misses that slow reports down.

Learning Resources

These curated sources go deeper than this module: official docs, books, interactive practice, and full courses. Mix them into your routine to go from comfortable to professional.

Window Functions Tutorial (Mode Analytics)

Clear, visual introduction to window functions with SQL examples for reporting.

Open resource ↗

SQL for Data Analysis (Khan Academy)

Free interactive SQL course covering aggregations, joins, and subqueries.

Open resource ↗

Explain Explanation (Use The Index, Luke)

The best resource for understanding SQL execution plans and query optimization.

Open resource ↗

SQL Pivoting Techniques (Database Journal)

Detailed guide to pivoting data in SQL using both PIVOT operator and CASE aggregation.

Open resource ↗

Advanced SQL for Data Science (Coursera — UC Davis)

University course covering window functions, CTEs, optimization and reporting patterns.

Open resource ↗

SQL Performance Explained (Markus Winand)

The definitive book on SQL indexing and performance — free online version available.

Open resource ↗

Chapter Quiz

Put this lesson to work: answer the questions below, get instant feedback and a pass/fail score. Score 70% or higher before you move on — that is your gate to the next topic.

Chapter Quiz — SQL for Reporting

Report-oriented queries, window functions, pivoting, CTEs, views, performance and report-ready formatting.

0 / 5

Question 1 of 5

To compute month-over-month revenue change in SQL, which window function reads the previous row's value?

Knowledge Check

GROUP BY collapses multiple rows into one summary row and removes detail. Window functions perform calculations across related rows while preserving every row in the result set.
Use the LAG() window function to access the previous month's value: LAG(revenue, 1) OVER (ORDER BY month). Then subtract to get the difference and divide by the previous value for percentage change.
CASE-based conditional aggregation works in all SQL dialects (MySQL, PostgreSQL, BigQuery, etc.). The PIVOT operator is specific to SQL Server and Oracle. Use CASE when portability or cross-platform compatibility matters.
A view encapsulates complex joins, filters and aggregations into a named, reusable query. It ensures consistency (all reports use the same logic), simplifies report queries, and supports security by exposing only needed columns/rows.
Select only needed columns (avoid SELECT *), filter early with WHERE clauses to reduce dataset size, and use indexed columns in JOIN and WHERE conditions. Additional techniques: avoid correlated subqueries, use EXPLAIN to analyze the execution plan, and materialize summary tables for very large datasets.
Use COALESCE(value, replacement) to replace NULLs with meaningful defaults (0 for amounts, 'N/A' for text). This prevents unexpected blanks or errors in report displays and calculations.

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.

Next Step

Congratulations — you just finished SQL for Reporting. The next topic is Executive Dashboards. Press on when you are ready: it will build directly on what you practiced here.