Intermediate Advanced 26 min read

Chapter 44: Analytical SQL & Data Warehousing

Operational databases are optimized for one thing: writing and retrieving individual records quickly. But the questions data scientists and analysts ask — "Which product line drove revenue growth last quarter?", "How does customer retention vary by acquisition cohort?" — require scanning millions of rows, joining across many tables, and aggregating in complex ways. Analytical SQL and data warehousing exist precisely to answer these questions at speed and scale.

This chapter develops a complete, practitioner-level understanding of analytical data systems. We begin with the foundational modeling choices — dimensional modeling, star and snowflake schemas, slowly changing dimensions — that determine how raw operational data gets reshaped into something analysts can query fluently. We then survey the columnar warehouse engines (BigQuery, Snowflake, Redshift) that execute those queries orders of magnitude faster than row-oriented databases, and explain why columnar storage provides that speedup. We cover the modern ELT pattern and the dbt transformation framework that has become the industry standard for building and testing analytical pipelines. Finally, we connect the SQL layer back to Python and pandas, because real workflows always span both.

Throughout, the focus is on why decisions are made, not just what the syntax looks like. A practitioner who understands the trade-offs between a star schema and a normalized model, who can explain predicate pushdown and partition pruning, and who knows when to reach for a materialized view versus a CTE, is equipped to design and optimize analytical systems rather than just use them.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)
Libraries covered: PandasSQLAlchemy

Learning Objectives

  • Design star and snowflake schemas from an operational data model, correctly identifying fact and dimension tables and choosing grain
  • Implement Type 1, Type 2, and Type 3 slowly changing dimensions (SCDs) in SQL
  • Explain how columnar storage, compression, and partition pruning enable analytical query performance and identify the warehouse features (BigQuery, Snowflake, Redshift) that exploit them
  • Write production-quality analytical SQL using window functions, CTEs, and GROUPING SETS for OLAP-style aggregations
  • Build a dbt project with models, tests, and incremental materializations that implement an ELT pipeline
  • Diagnose slow queries using EXPLAIN plans and apply indexing, clustering, and materialized view strategies to fix them
  • Load query results into pandas DataFrames and push aggregated data back to a warehouse using SQLAlchemy and the native warehouse connectors

44.1 Dimensional Modeling: Facts, Dimensions, and Schema Design Intermediate

Why Dimensional Modeling?

Relational normalization — decomposing data into third normal form (3NF) to eliminate redundancy — is ideal for transactional systems where writes dominate. For analytics, however, 3NF schemas are painful: a single business question might require joining ten or fifteen tables, the query plans are complex, and the SQL is hard to reason about. Dimensional modeling, popularized by Ralph Kimball, trades some write efficiency for dramatically better analytical ergonomics and query performance.

The core idea is to organize data around business processes (e.g., a sale, a web page view, a support ticket opened) rather than around entities. Each business process becomes a fact table whose rows represent individual events or measurements at a consistent grain. Surrounding the fact table are dimension tables that provide descriptive context.

Facts, Measures, and Grain

A fact table row represents one occurrence of a business event at a defined grain. Choosing the right grain is the most important modeling decision: it determines what questions the table can answer. Common choices are:

  • Transaction grain: one row per individual sale line-item. Most flexible — any aggregation is possible.
  • Snapshot grain: one row per entity per time period (e.g., one row per customer per day showing their account balance). Useful for period-over-period analysis.
  • Accumulating snapshot grain: one row per business process instance that gets updated as the process moves through stages (e.g., an order that passes through placed → shipped → delivered).

Fact columns are either measures (additive numbers like revenue, quantity, duration<em>seconds) or foreign keys to dimension tables. Additive measures can be summed across any dimension. Semi-additive measures (e.g., account balance) can be summed across some dimensions but not time. Non-additive measures (e.g., ratios, percentages) should be stored as their components and computed at query time.

Dimension Tables

Dimension tables hold the who, what, where, when, why, and how of each event. They are typically wide (many columns, often 50–100) and relatively short (millions of rows at most, compared to billions in a fact table). Dimensions always have a surrogate primary key — a system-generated integer — separate from the natural key from the source system. This decoupling is essential for slowly changing dimensions (covered in the next section).

A date dimension is nearly universal. Pre-populating it with derived columns (day of week, fiscal quarter, isholiday, etc.) moves expensive EXTRACT and CASE logic out of every query:

SQL
CREATE TABLE dim_date (
    date_key        INT PRIMARY KEY,  -- e.g., 20240315
    full_date       DATE NOT NULL,
    year            SMALLINT,
    quarter         SMALLINT,
    month           SMALLINT,
    month_name      VARCHAR(9),
    week_of_year    SMALLINT,
    day_of_week     SMALLINT,
    is_weekend      BOOLEAN,
    is_holiday      BOOLEAN,
    fiscal_year     SMALLINT,
    fiscal_quarter  SMALLINT
);

Star Schema vs. Snowflake Schema

In a star schema, every dimension table connects directly to the fact table. The schema looks like a star when drawn as an entity-relationship diagram. Dimension tables are deliberately denormalized: a dim<em>product table might contain both product</em>name and category<em>name even though category is logically a separate entity.

In a snowflake schema, dimension tables are normalized — dim</em>product holds a foreign key to dim<em>category, which holds a foreign key to dim</em>department, and so on. The schema looks like a snowflake when drawn.

Which to choose? Star schemas are almost always preferred for analytics:

  • Fewer joins means simpler SQL and faster queries.
  • Modern columnar warehouses cache dimension tables aggressively; the storage cost of denormalization is negligible.
  • Analysts can understand the schema without knowing the internal normalization hierarchy.

Snowflake schemas are occasionally justified when a dimension table is extremely large (tens of millions of rows) and the normalized sub-dimension adds significant filtering, or when ETL processes write to dimension sub-tables independently.

A Worked Example: E-Commerce Sales

Suppose an operational database has tables orders, order_items, customers, products, and stores. The business question is: "What was daily revenue by product category and store region?"

SQL
-- Fact table at order-line grain
CREATE TABLE fct_sales (
    sale_key        BIGINT PRIMARY KEY,
    date_key        INT REFERENCES dim_date(date_key),
    customer_key    INT REFERENCES dim_customer(customer_key),
    product_key     INT REFERENCES dim_product(product_key),
    store_key       INT REFERENCES dim_store(store_key),
    quantity        INT         NOT NULL,
    unit_price      NUMERIC(10,2) NOT NULL,
    discount_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
    revenue         NUMERIC(10,2) GENERATED ALWAYS AS
                        (quantity * unit_price - discount_amount) STORED
);

-- Answering the business question becomes a simple two-join query
SELECT
    d.full_date,
    p.category_name,
    s.region,
    SUM(f.revenue)  AS total_revenue
FROM fct_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_store    s ON f.store_key   = s.store_key
GROUP BY 1, 2, 3
ORDER BY 1, 4 DESC;

This query is readable, efficient, and would have required five or six joins against the normalized operational schema. The fact/dimension split makes the analytical layer both faster and more maintainable.

Code Examples

Populating a Date Dimension with a Recursive CTE

Generate all dates for a given year into dim_date using a recursive CTE — no external scripts required.

SQL
-- Works in PostgreSQL, Snowflake, BigQuery (with minor syntax adjustments)
WITH RECURSIVE date_spine AS (
    SELECT DATE '2024-01-01' AS d
    UNION ALL
    SELECT d + INTERVAL '1 day'
    FROM   date_spine
    WHERE  d < DATE '2024-12-31'
)
INSERT INTO dim_date (
    date_key, full_date, year, quarter, month,
    month_name, week_of_year, day_of_week, is_weekend
)
SELECT
    TO_CHAR(d, 'YYYYMMDD')::INT,
    d,
    EXTRACT(YEAR  FROM d)::SMALLINT,
    EXTRACT(QUARTER FROM d)::SMALLINT,
    EXTRACT(MONTH FROM d)::SMALLINT,
    TO_CHAR(d, 'Month'),
    EXTRACT(WEEK  FROM d)::SMALLINT,
    EXTRACT(DOW   FROM d)::SMALLINT,
    EXTRACT(DOW   FROM d) IN (0, 6)
FROM date_spine;
Output
INSERT 0 366

Validate Star Schema Foreign Key Integrity with pandas

After loading dimensional tables, quickly verify referential integrity before any analysis.

PYTHON
import pandas as pd

# Simulate small in-memory DataFrames (replace with read_sql in practice)
fct = pd.DataFrame({
    'sale_key':    [1, 2, 3, 4],
    'product_key': [10, 20, 99, 10],   # 99 is an orphan
    'date_key':    [20240101, 20240101, 20240102, 20240103],
})
dim_product = pd.DataFrame({'product_key': [10, 20, 30]})
dim_date    = pd.DataFrame({'date_key': [20240101, 20240102, 20240103]})

def check_fk(fact_df, dim_df, key_col):
    orphans = ~fact_df[key_col].isin(dim_df[key_col])
    if orphans.any():
        print(f"[FAIL] {orphans.sum()} orphan(s) in {key_col}:")
        print(fact_df.loc[orphans, key_col].tolist())
    else:
        print(f"[OK]   {key_col} — all keys match")

check_fk(fct, dim_product, 'product_key')
check_fk(fct, dim_date,    'date_key')
Output
[FAIL] 1 orphan(s) in product_key:
[99]
[OK]   date_key — all keys match

44.2 Slowly Changing Dimensions (SCDs) Intermediate

The Problem: Dimensions Are Not Static

Customers move cities. Products are re-categorized. Sales reps transfer to new regions. Any dimension attribute can change over time, and how you handle those changes determines whether historical reports remain accurate. Slowly changing dimensions (SCDs) are the set of standard patterns for managing this problem.

Type 1: Overwrite

The simplest approach: just update the existing row. No history is kept.

SQL
-- A customer changes their email
UPDATE dim_customer
SET    email = 'new@example.com',
       updated_at = CURRENT_TIMESTAMP
WHERE  customer_key = 4219;

When to use it: For corrections (a typo in a name), or when history genuinely does not matter (e.g., a phone number used only for outbound contact). All historical fact rows will now join to the updated attribute value.

Pitfall: If you later ask "what region was this customer in when they made their purchase in 2022?", a Type 1 dimension cannot answer that — the old value is gone.

Type 2: Add a New Row

Type 2 is the workhorse of SCD management. When an attribute changes, close the current row by setting an end date and an is<em>current flag, then insert a new row with a new surrogate key, the updated attribute, a start date, and an open end date.

SQL
-- Step 1: Close the old row
UPDATE dim_customer
SET    valid_to   = CURRENT_DATE - INTERVAL '1 day',
       is_current = FALSE
WHERE  customer_nk = 'C-8801'   -- natural key
  AND  is_current  = TRUE;

-- Step 2: Insert the new row
INSERT INTO dim_customer (
    customer_nk, first_name, last_name, city, state,
    valid_from, valid_to, is_current
)
VALUES (
    'C-8801', 'Maria', 'Santos', 'Austin', 'TX',
    CURRENT_DATE, DATE '9999-12-31', TRUE
);

Now fact rows that were loaded when the customer lived in Boston carry the surrogate key that points to the Boston row, and new fact rows carry the Austin surrogate key. Queries automatically get the right historical snapshot:

SQL
-- Revenue by the customer's city AT TIME OF PURCHASE
SELECT c.city, SUM(f.revenue)
FROM   fct_sales f
JOIN   dim_customer c ON f.customer_key = c.customer_key
GROUP  BY 1;

No date-range join is needed because the surrogate key already encodes the historical version.

Schema requirements for Type 2:

  • customer</em>key — surrogate primary key (changes with each version)
  • customer<em>nk — natural key (stable across versions)
  • valid</em>from — inclusive start date
  • valid<em>to — exclusive or inclusive end date (choose one convention and stick to it)
  • is</em>current — Boolean flag for the latest version (avoids filtering on valid<em>to = &#039;9999-12-31&#039; everywhere)

Common pitfall: forgetting to use the natural key when updating — using the surrogate key will close only one version, not all active ones.

Type 3: Add a New Column

Type 3 tracks only the current and previous value of a specific attribute. Instead of new rows, you add columns:

SQL
ALTER TABLE dim_customer
    ADD COLUMN prev_city   VARCHAR(100),
    ADD COLUMN city_changed_date DATE;

-- On change:
UPDATE dim_customer
SET    prev_city        = city,
       city             = 'Austin',
       city_changed_date = CURRENT_DATE
WHERE  customer_nk = 'C-8801';

Type 3 is rarely the right choice because it handles only one change and only for pre-designated columns. It is occasionally useful for "as-of" queries where you want to compare current vs. prior values without a full Type 2 history explosion.

Type 4 and Hybrid Approaches

Type 4 uses a separate history table alongside the current-state dimension. The main dimension always reflects the current record; a dim</em>customer<em>history table stores all prior versions. This keeps the main dimension clean for analysts who rarely need history while preserving the full audit trail.

Modern warehouse tools like dbt expose a snapshot command that automates Type 2 logic — given a source query and a unique key, dbt manages valid</em>from, valid<em>to, and dbt</em>scd<em>id columns automatically:

YAML
# snapshots/customer_snapshot.yml
snapshots:
  - name: customer_snapshot
    config:
      strategy: timestamp
      unique_key: customer_nk
      updated_at: updated_at
      target_schema: snapshots

SQL
-- snapshots/customer_snapshot.sql
{% snapshot customer_snapshot %}
    {{ config(target_schema='snapshots') }}
    SELECT * FROM {{ source('ops', 'customers') }}
{% endsnapshot %}

After dbt snapshot runs, the resulting table contains dbt</em>valid<em>from, dbt</em>valid<em>to, and dbt</em>scd_id columns, fully managed.

Choosing the Right Type

  • Use Type 1 when the change is a data correction or history is contractually irrelevant.
  • Use Type 2 as the default for any attribute where historical accuracy matters.
  • Use Type 3 only when you need exactly one prior value for a specific attribute and disk/query complexity is a concern.
  • Consider dbt snapshots or a purpose-built CDC (change data capture) system when managing SCDs at scale.

Code Examples

Point-in-Time Query Using Type 2 SCD via Date-Range Join

If surrogate keys were not used (e.g., the fact table stored the natural key), you must join on a date range to reconstruct the historical view.

SQL
-- Fact table stores natural key + event date
-- dim_customer has valid_from / valid_to
SELECT
    f.order_id,
    f.order_date,
    c.city          AS city_at_purchase,
    f.revenue
FROM fct_orders_nk f   -- uses natural key
JOIN dim_customer  c
  ON  f.customer_nk = c.customer_nk
 AND  f.order_date >= c.valid_from
 AND  f.order_date <  c.valid_to
ORDER BY f.order_id;
Output
-- Returns each order with the customer city that was active on the order date

Simulate Type 2 SCD Merge in Python/pandas

Useful for testing SCD logic before pushing to the warehouse.

PYTHON
import pandas as pd
from datetime import date

current_dim = pd.DataFrame([
    {'customer_key': 1, 'customer_nk': 'C-01', 'city': 'Boston',
     'valid_from': date(2022, 1, 1), 'valid_to': date(9999, 12, 31), 'is_current': True},
    {'customer_key': 2, 'customer_nk': 'C-02', 'city': 'Denver',
     'valid_from': date(2021, 6, 1), 'valid_to': date(9999, 12, 31), 'is_current': True},
])

incoming = pd.DataFrame([
    {'customer_nk': 'C-01', 'city': 'Austin'},   # changed
    {'customer_nk': 'C-02', 'city': 'Denver'},   # unchanged
])

change_date = date(2024, 3, 15)
next_key    = current_dim['customer_key'].max() + 1

new_rows = []
for _, row in incoming.iterrows():
    cur = current_dim[
        (current_dim.customer_nk == row.customer_nk) &
        (current_dim.is_current)
    ].iloc[0]

    if cur['city'] != row['city']:               # attribute changed
        current_dim.loc[cur.name, 'valid_to']    = change_date
        current_dim.loc[cur.name, 'is_current']  = False
        new_rows.append({'customer_key': next_key,
                         'customer_nk':  row.customer_nk,
                         'city':         row.city,
                         'valid_from':   change_date,
                         'valid_to':     date(9999, 12, 31),
                         'is_current':   True})
        next_key += 1

result = pd.concat([current_dim, pd.DataFrame(new_rows)], ignore_index=True)
print(result[['customer_key','customer_nk','city','valid_from','valid_to','is_current']])
Output
   customer_key customer_nk    city  valid_from    valid_to  is_current
0             1       C-01  Boston  2022-01-01  2024-03-15       False
1             2       C-02  Denver  2021-06-01  9999-12-31        True
2             3       C-01  Austin  2024-03-15  9999-12-31        True

44.3 Columnar Warehouses, OLAP vs. OLTP, and Query Execution Intermediate

OLTP vs. OLAP: A Fundamental Divide

Online Transaction Processing (OLTP) systems are built for high-throughput, low-latency individual record operations. An e-commerce checkout writes a few rows to orders and order<em>items and commits in milliseconds. Row-oriented storage — where all columns of a single row are stored contiguously on disk — is ideal for this: fetching one order means one sequential disk read.

Online Analytical Processing (OLAP) systems answer questions over millions or billions of rows. A query computing monthly revenue by region reads revenue and order</em>date from every row in fct<em>sales but ignores customer</em>email, shipping<em>address, and twenty other columns. Row-oriented storage is terrible for this: every row must be read from disk to extract two columns.

Columnar Storage: The Analytical Engine

Columnar databases store each column as a separate on-disk file or block. Only the columns referenced in a query are read. For a table with 50 columns, a query touching 5 columns reads 10% of the data a row store would read. This is the primary reason modern warehouses (BigQuery, Snowflake, Redshift) are so fast.

Columnar storage also enables aggressive compression:

  • Run-length encoding (RLE): A column with values [TX, TX, TX, TX, CA, CA, ...] stores (TX, 4), (CA, 2), ... instead of repeating strings. On low-cardinality columns like state or status, compression ratios of 10–100x are common.
  • Dictionary encoding: Replace repeated string values with integer IDs; store the dictionary separately.
  • Delta encoding: Store differences between successive values in sorted numeric columns — effective for timestamps and sequential IDs.

Compression reduces I/O and also enables SIMD vectorized processing: CPU instructions that operate on 256 or 512 bits at once can process 8–16 integer values per cycle when columns are dense and compressed.

Partition Pruning and Clustering

Even with columnar storage, scanning billions of rows for last month's data is expensive. Partitioning divides a table's storage into non-overlapping segments based on a column value (usually a date).

SQL
-- BigQuery: declare a partitioned table
CREATE TABLE `project.dataset.fct_sales`
PARTITION BY DATE(order_date)
CLUSTER BY product_category, store_region
AS SELECT ...

When a query filters on WHERE order</em>date BETWEEN &#039;2024-01-01&#039; AND &#039;2024-03-31&#039;, BigQuery reads only the partitions for those three months — a process called partition pruning. With clustering, rows within each partition are physically sorted by product<em>category and then store</em>region, so additional filter conditions can skip large blocks of data entirely.

Snowflake uses micro-partitions (50–500 MB compressed files, each storing min/max metadata per column) and Automatic Clustering to keep related rows together. Redshift uses sort keys (similar to clustering) and distribution keys (controlling which node stores each row) to reduce network shuffle during joins.

Query Execution: What the Warehouse Actually Does

Understanding execution helps you write faster queries. When you submit a SQL query to a columnar warehouse:

  1. The planner parses SQL into a logical plan (a tree of relational algebra operators).
  2. The optimizer rewrites the logical plan — pushing filters down (predicate pushdown), reordering joins, choosing between broadcast and hash joins.
  3. The physical plan is compiled to vectorized native code or interpreted bytecode.
  4. Execution is distributed across worker nodes; each node reads its assigned data partitions in parallel.
  5. Shuffle (redistribution of rows for joins and aggregations) is the most expensive step — minimizing it is the main goal of distribution key choices in Redshift and join strategy choices in BigQuery.

Broadcast joins copy a small dimension table to every worker, eliminating shuffle entirely. BigQuery does this automatically for tables under ~100 MB. In Snowflake you can hint it: SELECT /<em>+ broadcast(dim_customer) </em>/ ....

Reading and Writing Query Plans

SQL
-- PostgreSQL / Redshift (EXPLAIN)
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.category_name, SUM(f.revenue)
FROM   fct_sales f
JOIN   dim_product p ON f.product_key = p.product_key
WHERE  f.order_date >= '2024-01-01'
GROUP  BY 1;

Key things to look for in the output:

  • Seq Scan vs. Index Scan: A sequential scan on a large fact table is normal and expected in a warehouse (columnar engines batch-scan efficiently). On a small dimension table, a sequential scan when a tiny result is expected signals a missing index.
  • Hash Join vs. Nested Loop: Hash joins are preferred for large tables. Nested loops are efficient only when the inner relation is tiny or accessed via index.
  • Actual Rows vs. Estimated Rows: Large discrepancies signal stale statistics — run ANALYZE or VACUUM ANALYZE.
  • Cost numbers: These are arbitrary units but relative comparisons matter. A subtree with cost 1,000,000 dominating a plan with total cost 1,010,000 is your optimization target.
  • Materialized Views

For aggregations that are expensive but queried repeatedly, a materialized view pre-computes and stores the result:

SQL
-- Snowflake / BigQuery / PostgreSQL (syntax varies slightly)
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT
    d.full_date,
    p.category_name,
    s.region,
    SUM(f.revenue)   AS total_revenue,
    COUNT(*)         AS transaction_count
FROM fct_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_store   s ON f.store_key   = s.store_key
GROUP BY 1, 2, 3;

BigQuery and Snowflake can transparently rewrite queries to use a materialized view even when the query does not reference it by name — called intelligent view matching — so existing dashboards automatically benefit without any changes.

Code Examples

Demonstrate Columnar vs. Row Storage I/O with pandas

Show the storage and read-time difference between parquet (columnar) and CSV (row-oriented) when a query touches only two of many columns.

PYTHON
import pandas as pd
import numpy as np
import time, os

rng = np.random.default_rng(42)
n = 2_000_000

df = pd.DataFrame({
    'order_id':       np.arange(n),
    'order_date':     pd.date_range('2020-01-01', periods=n, freq='s'),
    'customer_id':    rng.integers(1, 100_000, n),
    'product_id':     rng.integers(1, 10_000, n),
    'revenue':        rng.uniform(5, 500, n).round(2),
    'category':       rng.choice(['Electronics','Apparel','Home','Sports'], n),
    'region':         rng.choice(['North','South','East','West'], n),
    'discount':       rng.uniform(0, 0.5, n).round(3),
    'shipping_cost':  rng.uniform(0, 20, n).round(2),
    'return_flag':    rng.choice([True, False], n),
})

df.to_csv('/tmp/sales.csv', index=False)
df.to_parquet('/tmp/sales.parquet', index=False)

csv_size     = os.path.getsize('/tmp/sales.csv')
parquet_size = os.path.getsize('/tmp/sales.parquet')
print(f"CSV size:     {csv_size/1e6:.1f} MB")
print(f"Parquet size: {parquet_size/1e6:.1f} MB")

t0 = time.perf_counter()
pd.read_csv('/tmp/sales.csv', usecols=['revenue','category']).groupby('category')['revenue'].sum()
print(f"CSV read+agg:     {time.perf_counter()-t0:.3f}s")

t0 = time.perf_counter()
pd.read_parquet('/tmp/sales.parquet', columns=['revenue','category']).groupby('category')['revenue'].sum()
print(f"Parquet read+agg: {time.perf_counter()-t0:.3f}s")
Output
CSV size:     228.4 MB
Parquet size:  22.1 MB
CSV read+agg:     4.821s
Parquet read+agg: 0.213s

BigQuery Partition Pruning — Before and After

Show how adding a partition filter eliminates bytes scanned.

SQL
-- Without partition filter: full table scan
SELECT SUM(revenue)
FROM `project.dataset.fct_sales`
WHERE region = 'North';
-- Bytes scanned: ~50 GB (all partitions)

-- With partition filter: only recent quarter scanned
SELECT SUM(revenue)
FROM `project.dataset.fct_sales`
WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31'
  AND region = 'North';
-- Bytes scanned: ~3.8 GB (3 monthly partitions)
-- Cost reduction: ~92%
Output
-- The BigQuery UI shows "This query will process 3.8 GB" after adding the date filter

44.4 Analytical SQL: Window Functions, CTEs, and OLAP Aggregations Advanced

Why Window Functions?

Aggregate functions with GROUP BY collapse rows — the result has one row per group. Window functions compute an aggregate without collapsing rows, letting you attach group-level statistics to individual rows. This enables an entire class of analytical patterns: running totals, moving averages, ranks, percentiles, lag/lead comparisons.

The syntax is: function() OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE ...). Every clause is optional.

Ranking and Ordering

SQL
-- Rank products by revenue within each category,
-- breaking ties differently with RANK vs DENSE_RANK
SELECT
    product_name,
    category,
    SUM(revenue)                                               AS total_rev,
    RANK()       OVER (PARTITION BY category ORDER BY SUM(revenue) DESC) AS rnk,
    DENSE_RANK() OVER (PARTITION BY category ORDER BY SUM(revenue) DESC) AS dense_rnk,
    ROW_NUMBER() OVER (PARTITION BY category ORDER BY SUM(revenue) DESC) AS row_num
FROM fct_sales f
JOIN dim_product p ON f.product_key = p.product_key
GROUP BY product_name, category;

RANK leaves gaps after ties (1, 2, 2, 4). DENSE<em>RANK does not (1, 2, 2, 3). ROW</em>NUMBER assigns a unique number regardless of ties. Use RANK/DENSE<em>RANK when business rules care about ties; use ROW</em>NUMBER when you need exactly one row per group (e.g., deduplication).

Running Totals and Moving Averages

The ROWS BETWEEN frame specification controls which rows are included in the window calculation relative to the current row.

SQL
SELECT
    order_date,
    daily_revenue,
    -- Running total (all rows from start to current)
    SUM(daily_revenue)
        OVER (ORDER BY order_date
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
        AS running_total,
    -- 7-day moving average
    AVG(daily_revenue)
        OVER (ORDER BY order_date
              ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
        AS ma_7d
FROM (
    SELECT order_date, SUM(revenue) AS daily_revenue
    FROM   fct_sales
    GROUP  BY order_date
) daily
ORDER BY order_date;

RANGE BETWEEN uses the value of the ORDER BY column rather than row position — useful for time-based windows where data may have gaps.

LAG and LEAD: Period-over-Period Comparisons

SQL
SELECT
    year_month,
    monthly_revenue,
    LAG(monthly_revenue, 1) OVER (ORDER BY year_month)  AS prev_month_rev,
    monthly_revenue
        - LAG(monthly_revenue, 1) OVER (ORDER BY year_month)
        AS mom_change,
    ROUND(100.0 * (
        monthly_revenue
        / NULLIF(LAG(monthly_revenue, 1) OVER (ORDER BY year_month), 0)
        - 1
    ), 2)                                               AS mom_pct_change
FROM (
    SELECT
        TO_CHAR(order_date, 'YYYY-MM') AS year_month,
        SUM(revenue)                   AS monthly_revenue
    FROM   fct_sales
    GROUP  BY 1
) monthly
ORDER BY 1;

Always wrap the lag value in NULLIF(..., 0) before division to avoid division-by-zero errors when a prior period had no revenue.

GROUPING SETS, ROLLUP, and CUBE

Standard GROUP BY gives one aggregation level. GROUPING SETS lets you compute multiple aggregation levels in a single scan — critical for OLAP-style summary tables.

SQL
-- Three aggregation levels: date+category, date alone, category alone, grand total
SELECT
    d.full_date,
    p.category_name,
    SUM(f.revenue)              AS revenue,
    GROUPING(d.full_date)       AS is_date_subtotal,
    GROUPING(p.category_name)   AS is_cat_subtotal
FROM fct_sales f
JOIN dim_date    d ON f.date_key    = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
GROUP BY GROUPING SETS (
    (d.full_date, p.category_name),  -- detail level
    (d.full_date),                   -- daily totals
    (p.category_name),               -- category totals
    ()                               -- grand total
);

ROLLUP(a, b, c) is syntactic sugar for grouping sets going from most detailed to grand total: (a,b,c), (a,b), (a), (). CUBE(a, b) generates all $2^n$ combinations.

The GROUPING() function returns 1 when the column is part of a subtotal row (i.e., NULL because it was rolled up), enabling you to distinguish a genuine NULL value from a subtotal null.

CTEs and the WITH Clause

Common Table Expressions (CTEs) name intermediate results, making complex multi-step queries readable and maintainable. They are not a performance optimization in most engines (the planner inlines them), but they are essential for code clarity.

SQL
WITH
daily AS (
    SELECT order_date, SUM(revenue) AS rev
    FROM   fct_sales
    GROUP  BY order_date
),
with_ma AS (
    SELECT
        order_date,
        rev,
        AVG(rev) OVER (
            ORDER BY order_date
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ) AS ma_7d
    FROM daily
),
anomalies AS (
    SELECT *
    FROM   with_ma
    WHERE  rev > 2.5 * ma_7d   -- revenue spike > 2.5x moving average
)
SELECT * FROM anomalies ORDER BY order_date;

Recursive CTEs are covered in Chapter 43. For analytical work, non-recursive CTEs dominate.

Percentiles and Distribution Functions

SQL
-- PERCENTILE_CONT interpolates; PERCENTILE_DISC returns an actual row value
SELECT
    category_name,
    PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY revenue) AS median_order_value,
    PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY revenue) AS p95_order_value,
    NTILE(4) OVER (ORDER BY revenue)                       AS quartile
FROM fct_sales f
JOIN dim_product p ON f.product_key = p.product_key;

NTILE(n) divides the ordered result into n buckets and assigns a bucket number. It is commonly used to label rows as top decile, top quintile, etc.

Code Examples

Cohort Retention Analysis with Window Functions

Classic cohort analysis: for each signup month, what fraction of users are still active each subsequent month?

SQL
WITH cohorts AS (
    -- Identify each user's first purchase month (their cohort)
    SELECT
        customer_nk,
        DATE_TRUNC('month', MIN(order_date))::DATE AS cohort_month
    FROM fct_sales f
    JOIN dim_customer c ON f.customer_key = c.customer_key
    WHERE c.is_current
    GROUP BY 1
),
activity AS (
    SELECT DISTINCT
        c.customer_nk,
        co.cohort_month,
        DATE_TRUNC('month', f.order_date)::DATE AS activity_month
    FROM fct_sales f
    JOIN dim_customer c ON f.customer_key = c.customer_key
    JOIN cohorts co      ON c.customer_nk  = co.customer_nk
    WHERE c.is_current
),
cohort_sizes AS (
    SELECT cohort_month, COUNT(DISTINCT customer_nk) AS cohort_size
    FROM cohorts
    GROUP BY 1
)
SELECT
    a.cohort_month,
    EXTRACT(YEAR FROM AGE(a.activity_month, a.cohort_month)) * 12 +
    EXTRACT(MONTH FROM AGE(a.activity_month, a.cohort_month)) AS months_since_cohort,
    COUNT(DISTINCT a.customer_nk)                             AS active_users,
    cs.cohort_size,
    ROUND(100.0 * COUNT(DISTINCT a.customer_nk) / cs.cohort_size, 1) AS retention_pct
FROM activity a
JOIN cohort_sizes cs ON a.cohort_month = cs.cohort_month
GROUP BY 1, 2, cs.cohort_size
ORDER BY 1, 2;
Output
-- cohort_month | months_since_cohort | active_users | cohort_size | retention_pct
-- 2024-01-01   |                   0 |         2341 |        2341 |         100.0
-- 2024-01-01   |                   1 |         1423 |        2341 |          60.8
-- 2024-01-01   |                   2 |          987 |        2341 |          42.2
-- ...

Reproduce GROUPING SETS Logic in pandas for Validation

Generate multi-level aggregation matching a SQL GROUPING SETS result for unit testing.

PYTHON
import pandas as pd

df = pd.DataFrame({
    'date':     ['2024-01', '2024-01', '2024-02', '2024-02'],
    'category': ['Electronics', 'Apparel', 'Electronics', 'Apparel'],
    'revenue':  [1000, 500, 1200, 600],
})

# Replicate GROUPING SETS ((date, category), (date), (category), ())
detail  = df.groupby(['date','category'], as_index=False)['revenue'].sum()
by_date = df.groupby('date',             as_index=False)['revenue'].sum().assign(category='__ALL__')
by_cat  = df.groupby('category',         as_index=False)['revenue'].sum().assign(date='__ALL__')
grand   = pd.DataFrame([{'date': '__ALL__', 'category': '__ALL__',
                          'revenue': df.revenue.sum()}])

result = pd.concat([detail, by_date, by_cat, grand], ignore_index=True)
print(result.sort_values(['date','category']).to_string(index=False))
Output
     date     category  revenue
  __ALL__      __ALL__     3300
  __ALL__      Apparel     1100
  __ALL__ Electronics     2200
  2024-01      __ALL__     1500
  2024-01      Apparel      500
  2024-01  Electronics     1000
  2024-02      __ALL__     1800
  2024-02      Apparel      600
  2024-02  Electronics     1200

44.5 ELT Pipelines and dbt: Building a Transformation Layer Advanced

ETL vs. ELT

The traditional Extract-Transform-Load (ETL) pattern moves data from source systems, transforms it in an intermediate compute environment (often a dedicated ETL server), and loads clean data into the warehouse. This was pragmatic when warehouse compute was expensive and storage was cheap.

Modern cloud warehouses have inverted these economics: compute scales elastically and is billed per-second, while storage is pennies per gigabyte. Extract-Load-Transform (ELT) loads raw data directly into the warehouse and performs all transformations inside it, using SQL. The warehouse becomes both the storage layer and the transformation engine.

Advantages of ELT:

  • No separate transformation infrastructure to maintain.
  • Raw data is always preserved — transformations can be rerun from the original source.
  • SQL is a universal language accessible to analysts, not just engineers.
  • Incremental transformations can exploit warehouse-native features (partitioning, clustering).
  • dbt: The Transformation Framework

dbt (data build tool) is the dominant framework for ELT transformations. It compiles SQL SELECT statements (called models) into DDL/DML and runs them in the correct dependency order. Key concepts:

  • Model: A .sql file containing a single SELECT statement. dbt wraps it in CREATE TABLE AS or CREATE VIEW AS based on its materialization setting.
  • Source: A declaration of raw tables loaded by an ingestion tool (Fivetran, Airbyte, etc.).
  • Ref: {{ ref(&#039;model<em>name&#039;) }} — the Jinja macro that declares a dependency between models. dbt builds a DAG from all ref() calls.
  • Test: Out-of-the-box not</em>null, unique, accepted<em>values, and relationships tests, plus custom SQL tests.
  • Project Structure

A production dbt project typically follows this layered convention:

models/
  staging/         -- 1:1 with source tables; rename columns, cast types
    stg_orders.sql
    stg_customers.sql
  intermediate/    -- joins and transformations not ready for final use
    int_order_items_enriched.sql
  marts/           -- final dimensional tables for analytics
    fct_sales.sql
    dim_customer.sql
    dim_product.sql

A Complete dbt Model: fct</em>sales

SQL
-- models/marts/fct_sales.sql
{{ config(
    materialized = 'incremental',
    unique_key   = 'sale_key',
    partition_by = {"field": "order_date", "data_type": "date"},
    cluster_by   = ['product_category', 'store_region']
) }}

WITH orders AS (
    SELECT * FROM {{ ref('stg_orders') }}
),
order_items AS (
    SELECT * FROM {{ ref('stg_order_items') }}
),
customers AS (
    SELECT * FROM {{ ref('dim_customer') }}
),
products AS (
    SELECT * FROM {{ ref('dim_product') }}
)

SELECT
    {{ dbt_utils.generate_surrogate_key(['oi.order_id', 'oi.line_num']) }}
        AS sale_key,
    o.order_date,
    c.customer_key,
    p.product_key,
    oi.quantity,
    oi.unit_price,
    oi.discount_amount,
    oi.quantity * oi.unit_price - oi.discount_amount  AS revenue,
    p.category_name                                    AS product_category,
    s.region                                           AS store_region
FROM order_items oi
JOIN orders   o ON oi.order_id   = o.order_id
JOIN customers c ON o.customer_nk = c.customer_nk AND c.is_current
JOIN products  p ON oi.product_nk = p.product_nk
{% if is_incremental() %}
WHERE o.order_date > (SELECT MAX(order_date) FROM {{ this }})
{% endif %}

The is_incremental() block appends only new rows on subsequent runs, dramatically reducing runtime for large tables. The first run (or a dbt run --full-refresh) creates the table from scratch.

Schema Tests

YAML
# models/marts/schema.yml
models:
  - name: fct_sales
    columns:
      - name: sale_key
        tests:
          - unique
          - not_null
      - name: customer_key
        tests:
          - not_null
          - relationships:
              to: ref('dim_customer')
              field: customer_key
      - name: revenue
        tests:
          - not_null
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 100000

Run dbt test after every dbt run. These tests catch data quality issues before they reach dashboards.

Sources and Freshness

YAML
# models/staging/sources.yml
sources:
  - name: raw
    database: my_project
    schema: raw_data
    freshness:
      warn_after: {count: 12, period: hour}
      error_after: {count: 24, period: hour}
    loaded_at_field: _loaded_at
    tables:
      - name: orders
      - name: order_items

dbt source freshness alerts when the ingestion pipeline has stalled, catching data gaps before they silently produce wrong numbers.

Running and Deploying dbt

BASH
# Install and initialize
pip install dbt-bigquery          # or dbt-snowflake, dbt-redshift
dbt init my_project

# Run everything
dbt run

# Run only changed models and their downstream dependents
dbt run --select state:modified+

# Test, document, and serve docs locally
dbt test
dbt docs generate
dbt docs serve

In production, dbt is typically orchestrated by Airflow, Prefect, or dbt Cloud on a schedule, with Slack/email alerts on failures.

Code Examples

Full dbt Project Initialization and First Run

End-to-end commands for creating a dbt project, configuring a BigQuery profile, and running the first transformation.

BASH
# 1. Install dbt with the BigQuery adapter
pip install dbt-bigquery

# 2. Initialize a new project (creates directory structure)
dbt init sales_warehouse
cd sales_warehouse

# 3. Configure profiles.yml (usually in ~/.dbt/profiles.yml)
cat > ~/.dbt/profiles.yml << 'EOF'
sales_warehouse:
  target: dev
  outputs:
    dev:
      type: bigquery
      method: oauth
      project: my-gcp-project
      dataset: dbt_dev
      threads: 4
      timeout_seconds: 300
    prod:
      type: bigquery
      method: service-account
      project: my-gcp-project
      dataset: analytics
      keyfile: /secrets/sa-key.json
      threads: 8
      timeout_seconds: 600
EOF

# 4. Test connection
dbt debug

# 5. Run all models
dbt run

# 6. Run tests
dbt test

# 7. Generate and serve documentation
dbt docs generate && dbt docs serve --port 8080
Output
Running with dbt=1.8.0
Found 12 models, 4 sources, 31 tests

13:24:01 | Concurrency: 4 threads
13:24:01 | 1 of 12 START table model dbt_dev.stg_orders ................. [RUN]
13:24:03 | 1 of 12 OK created table model dbt_dev.stg_orders ............ [CREATE TABLE (1.2m rows) in 2.1s]
...

dbt Macro: Reusable Fiscal Quarter Calculation

Define a Jinja macro in dbt for fiscal quarter logic and call it from any model.

SQL
-- macros/fiscal_quarter.sql
{% macro fiscal_quarter(date_col, fiscal_year_start_month=4) %}
    CASE
        WHEN EXTRACT(MONTH FROM {{ date_col }}) >= {{ fiscal_year_start_month }}
        THEN CONCAT(
            'FY', CAST(EXTRACT(YEAR FROM {{ date_col }}) + 1 AS STRING), '-Q',
            CAST(CEIL((EXTRACT(MONTH FROM {{ date_col }}) - {{ fiscal_year_start_month }} + 1) / 3.0) AS STRING)
        )
        ELSE CONCAT(
            'FY', CAST(EXTRACT(YEAR FROM {{ date_col }}) AS STRING), '-Q',
            CAST(CEIL((EXTRACT(MONTH FROM {{ date_col }}) + 12 - {{ fiscal_year_start_month }} + 1) / 3.0) AS STRING)
        )
    END
{% endmacro %}

-- Usage in a model (April fiscal year start):
SELECT
    order_date,
    {{ fiscal_quarter('order_date', fiscal_year_start_month=4) }} AS fiscal_quarter,
    SUM(revenue) AS revenue
FROM {{ ref('fct_sales') }}
GROUP BY 1, 2
Output
-- order_date  | fiscal_quarter | revenue
-- 2024-03-15  | FY2024-Q4      | 125000
-- 2024-04-01  | FY2025-Q1      |  87500

44.6 Connecting SQL to Python: pandas, SQLAlchemy, and Warehouse Connectors Intermediate

Why Bridge SQL and Python?

SQL is unrivaled for set-based transformations at scale, but Python owns statistical modeling, machine learning, and visualization. Production data science workflows constantly cross this boundary: pull a cohort from the warehouse into pandas for survival analysis, train a model, write predictions back to the warehouse for serving. Understanding the tools for this bridge — and their performance characteristics — is essential.

SQLAlchemy: The Universal Adapter

SQLAlchemy provides a common Python interface to dozens of databases. Its Core layer generates SQL programmatically; its ORM layer maps Python classes to tables. For analytics, Core is usually preferable — it keeps you close to SQL without the complexity of object mapping.

PYTHON
from sqlalchemy import create_engine, text
import pandas as pd

# PostgreSQL via psycopg2
engine = create_engine(
    "postgresql+psycopg2://user:password@host:5432/warehouse",
    pool_size=5,
    connect_args={"options": "-c statement_timeout=120000"}  # 120s timeout
)

# Execute parameterized queries (NEVER use f-strings for user input)
with engine.connect() as conn:
    result = conn.execute(
        text("SELECT * FROM fct_sales WHERE order_date >= :start"),
        {"start": "2024-01-01"}
    )
    df = pd.DataFrame(result.fetchall(), columns=result.keys())

Always use parameterized queries. String interpolation into SQL is a SQL injection vulnerability even in internal analytical tools.

pandas read<em>sql and to</em>sql

PYTHON
import pandas as pd
from sqlalchemy import create_engine

engine = create_engine("postgresql+psycopg2://...")

# Pull a large result set in chunks to avoid OOM
query = """
    SELECT customer_nk, order_date, revenue
    FROM   fct_sales f
    JOIN   dim_customer c ON f.customer_key = c.customer_key
    WHERE  c.is_current
      AND  order_date >= '2023-01-01'
"""

chunks = []
for chunk in pd.read_sql(query, engine, chunksize=100_000):
    # Apply any Python-side transformations before accumulating
    chunk['revenue_log'] = chunk['revenue'].apply(lambda x: max(x, 0.01))
    chunks.append(chunk)

df = pd.concat(chunks, ignore_index=True)
print(f"Loaded {len(df):,} rows")

# Write predictions back to the warehouse
predictions = df[['customer_nk', 'order_date']].copy()
predictions['churn_probability'] = model.predict_proba(features)[:, 1]

predictions.to_sql(
    name='ml_churn_predictions',
    con=engine,
    schema='analytics',
    if_exists='replace',     # or 'append'
    index=False,
    method='multi',          # batch INSERT; much faster than row-by-row
    chunksize=5_000
)

to<em>sql performance note: The default single-row insert is extremely slow for large DataFrames. Always set method=&#039;multi&#039; or, for even better performance on PostgreSQL, use psycopg2's execute</em>values via a custom method callable.

Native Warehouse Connectors

For BigQuery, Snowflake, and Redshift, native Python connectors outperform the generic SQLAlchemy path by 2–10x because they use warehouse-specific bulk transfer protocols.

PYTHON
# BigQuery: google-cloud-bigquery + pandas-gbq
from google.cloud import bigquery

client = bigquery.Client(project='my-gcp-project')

query = """
    SELECT product_key, SUM(revenue) AS total_rev
    FROM   `my-gcp-project.analytics.fct_sales`
    WHERE  order_date BETWEEN '2024-01-01' AND '2024-12-31'
    GROUP  BY 1
"""

# Returns a DataFrame directly
df = client.query(query).to_dataframe()

# Write back using BigQuery Storage Write API (fastest)
job_config = bigquery.LoadJobConfig(
    write_disposition='WRITE_TRUNCATE',
    autodetect=True
)
job = client.load_table_from_dataframe(
    df_predictions,
    'my-gcp-project.analytics.churn_scores',
    job_config=job_config
)
job.result()   # wait for completion
print(f"Loaded {job.output_rows} rows")

PYTHON
# Snowflake: snowflake-connector-python with write_pandas
import snowflake.connector
from snowflake.connector.pandas_tools import write_pandas

conn = snowflake.connector.connect(
    account   = 'org-account',
    user      = 'analyst',
    password  = 'secret',
    warehouse = 'TRANSFORM_WH',
    database  = 'ANALYTICS',
    schema    = 'MARTS'
)

# write_pandas uses Snowflake's COPY INTO for bulk loading
success, nchunks, nrows, _ = write_pandas(
    conn, df_predictions,
    table_name='CHURN_SCORES',
    auto_create_table=True
)
print(f"Wrote {nrows} rows in {nchunks} chunk(s), success={success}")

Type Alignment Between SQL and pandas

Type mismatches cause silent data corruption. Key rules:

  • SQL BIGINT → pandas int64 — but if the column has NULLs, pandas will use float64 unless you use pd.Int64Dtype() (nullable integer).
  • SQL NUMERIC(10,2) → may become Python Decimal rather than float64. Force the cast in the query: CAST(revenue AS FLOAT8) or use pd.to<em>numeric(df.revenue).
  • SQL TIMESTAMP WITH TIME ZONE → pandas datetime64[ns, UTC]. Be explicit: pd.to</em>datetime(df.ts, utc=True).
  • SQL DATE → python datetime.date objects in the cursor, not pd.Timestamp. Use pd.to<em>datetime to normalize.
  • Performance Patterns for Large Transfers

  • Push work into the warehouse: filter, aggregate, and join in SQL before pulling to Python. Never SELECT * from a fact table and filter in pandas.
  • Use columnar formats for intermediate storage: write to GCS/S3 as Parquet, then load into pandas with pd.read</em>parquet. Faster than JDBC for bulk transfers.
  • Predicate pushdown with read_sql: pass WHERE clauses as query parameters, not post-hoc DataFrame filters.
  • Connection pooling: reuse connections rather than opening a new connection for each query — connection setup can take 100–500ms on cloud warehouses.

Code Examples

Fast PostgreSQL Bulk Load with psycopg2 execute_values

Using execute_values instead of pandas to_sql for high-throughput inserts into PostgreSQL.

PYTHON
import psycopg2
from psycopg2.extras import execute_values
import pandas as pd
import numpy as np
import time

# Sample DataFrame to insert
rng = np.random.default_rng(42)
n = 100_000
df = pd.DataFrame({
    'customer_nk':   [f'C-{i:06d}' for i in range(n)],
    'score':         rng.uniform(0, 1, n).round(4),
    'scored_at':     pd.Timestamp.utcnow()
})

conn = psycopg2.connect("host=localhost dbname=warehouse user=analyst")

with conn.cursor() as cur:
    cur.execute("""
        CREATE TABLE IF NOT EXISTS churn_scores (
            customer_nk VARCHAR(20),
            score       NUMERIC(6,4),
            scored_at   TIMESTAMPTZ
        )
    """)
    conn.commit()

    rows = list(df.itertuples(index=False, name=None))

    t0 = time.perf_counter()
    execute_values(
        cur,
        "INSERT INTO churn_scores VALUES %s",
        rows,
        page_size=5_000   # batch size per round-trip
    )
    conn.commit()
    elapsed = time.perf_counter() - t0

print(f"Inserted {n:,} rows in {elapsed:.2f}s ({n/elapsed:,.0f} rows/sec)")
conn.close()
Output
Inserted 100,000 rows in 1.23s (81,301 rows/sec)

Streaming Large Query Results from BigQuery with Arrow

Use the BigQuery Storage API (Arrow format) for fast, memory-efficient bulk downloads.

PYTHON
from google.cloud import bigquery
import pandas as pd

client = bigquery.Client(project='my-gcp-project')

query = """
    SELECT
        customer_nk,
        DATE_TRUNC(order_date, MONTH) AS order_month,
        SUM(revenue)                  AS monthly_rev,
        COUNT(*)                      AS order_count
    FROM `my-gcp-project.analytics.fct_sales`
    WHERE order_date >= '2022-01-01'
    GROUP BY 1, 2
"""

# create_bqstorage_client=True triggers Storage Read API
# ~5-10x faster than standard REST for large result sets
job = client.query(query)
df  = job.to_dataframe(create_bqstorage_client=True)

print(f"Shape: {df.shape}")
print(df.dtypes)
print(df.head(3).to_string())
Output
Shape: (2840192, 4)
customer_nk       object
order_month       datetime64[us, UTC]
monthly_rev       float64
order_count       int64
dtype: object
  customer_nk         order_month  monthly_rev  order_count
0   C-000001  2022-01-01 00:00:00+00:00       450.30            3
1   C-000001  2022-02-01 00:00:00+00:00       112.00            1
2   C-000002  2022-01-01 00:00:00+00:00      2300.75            7