BigQuery Fundamentals

Quote

“Big data is like teenage sex: everyone talks about it, nobody really knows how to do it, everyone thinks everyone else is doing it, so everyone claims they are doing it.”

Dan Ariely, Facebook post (2013)

Load the jupysql extension and configure display settings for notebook SQL execution.

%load_ext sql
%config SqlMagic.displaycon = False
%config SqlMagic.displaylimit = 0

Connect to BigQuery project bq-wh-nb using Application Default Credentials (no password).

%sql bigquery://bq-wh-nb

Connecting to ‘bigquery://bq-wh-nb’

BigQuery Uses ADC — No Password

The bigquery:// connection uses Application Default Credentials — no password in the connection string. Locally: gcloud auth application-default login. On VMs/Cloud Run: the metadata server provides credentials automatically. See gcloud-authentication > The ADC Credential Search Order.

Schema Exploration

BigQuery exposes metadata through two interfaces: the Python client library (bigquery.Client.list_tables) for programmatic inventory and the ANSI-standard INFORMATION_SCHEMA views for SQL-based introspection. Both are free to query (metadata access is not billed per bytes scanned). Use these as the first step when working with an unfamiliar dataset — understand which tables exist, which medallion layer they belong to, and what data types each column uses.

Schema Exploration | List All Tables

First thing in any database — see what’s there. The Python client library lists all tables across the three medallion-layer datasets (bronze, silver, gold), which BigQuery organizes as separate schemas (called “datasets”). The result shows table names, row counts, and storage sizes.

List tables with row counts and storage sizes via the Python client

At the start of any BigQuery exploration session, or after a new dataset is created or tables are added/removed. It is typically triggered by first contact with an unfamiliar project or dataset, or verifying that a pipeline load created the expected tables. Python client library call (bigquery.Client). Read-only — metadata access is free and not billed per bytes scanned. Requires bigquery.tables.list and bigquery.tables.get permissions (included in roles/bigquery.dataViewer). Build a complete inventory of all tables across the medallion layers — names, row counts, and storage sizes — to understand the data landscape before writing queries.

FieldSourceTypeMeaning
datasetLoop variableSTRINGBigQuery dataset name corresponding to a medallion layer (stoxx_bronze, stoxx_silver, stoxx_gold)
tableTable.table_idSTRINGTable name within the dataset
rowsTable.num_rowsINT64Total row count as reported by BigQuery storage metadata (updated asynchronously — may lag by minutes after a load)
size_mbTable.num_bytes / 1024 / 1024FLOAT64 (MB)Logical storage size in megabytes. Tables under 10 MB show as 0.00 due to rounding

List all tables across the three medallion datasets with row counts and storage sizes.

from google.cloud import bigquery
bq = bigquery.Client(project='bq-wh-nb')
 
rows = []
for ds in ['stoxx_bronze', 'stoxx_silver', 'stoxx_gold']:
    for table in bq.list_tables(ds):
        t = bq.get_table(table)
        rows.append({'dataset': ds, 'table': t.table_id,
                     'rows': t.num_rows, 'size_mb': round(t.num_bytes / 1024 / 1024, 2)})
 
import pandas as pd
pd.DataFrame(rows).sort_values(['dataset', 'table']).reset_index(drop=True)
datasettablerowssize_mb
0stoxx_bronzedim_country2120.00
1stoxx_bronzedim_index40.00
2stoxx_bronzeeurostoxx50_ohlcv500.00
3stoxx_bronzeindex_dim1690.27
4stoxx_bronzeoil20_ohlcv190.00

Schema Exploration | Inspect Column Types

Check data types before writing queries — float vs int vs varchar changes how you aggregate and join.

Inspect column names, types, and nullability with INFORMATION_SCHEMA

Before writing any query against a table, or when debugging unexpected type coercion or NULL behavior. It is typically triggered by first interaction with a table, or encountering a type mismatch error in a JOIN or aggregation. SQL query against INFORMATION_SCHEMA.COLUMNS. Read-only, free (metadata queries are not billed). Requires bigquery.tables.get permission. Confirm column names, data types, and nullability so that downstream queries use correct types and handle NULLs explicitly.

FieldSourceTypeMeaning
column_nameINFORMATION_SCHEMA.COLUMNS.column_nameSTRINGName of the column in the table
data_typeINFORMATION_SCHEMA.COLUMNS.data_typeSTRINGBigQuery data type — INT64, FLOAT64, STRING, DATE, TIMESTAMP, BOOL, etc.
is_nullableINFORMATION_SCHEMA.COLUMNS.is_nullableSTRINGYES if the column accepts NULL values, NO if it has a NOT NULL constraint

Inspect column names, data types, and nullability for the silver OHLCV table via INFORMATION_SCHEMA.

SELECT
    column_name,
    data_type,
    is_nullable
FROM `bq-wh-nb.stoxx_silver`.INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'eurostoxx50_ohlcv'
ORDER BY ordinal_position

12 rows affected.

column_namedata_typeis_nullable
idINT64YES
symbolSTRINGYES
dateDATEYES
openFLOAT64YES
highFLOAT64YES

SELECT, Filtering & Sorting

SELECT is the workhorse of GoogleSQL — pick columns, filter rows with WHERE, sort with ORDER BY, and truncate output with LIMIT. Column selection matters more in BigQuery than in SQL Server because billing is driven by bytes scanned: every column named in the SELECT list reads its full column data, while LIMIT does not reduce cost. The subsections below cover basic filtering and multi-condition predicates, plus the cost-aware syntax differences between GoogleSQL and T-SQL.

SELECT, Filtering & Sorting | Basic SELECT with WHERE

The fundamental query: pick columns, filter rows, sort results. LIMIT N limits output (BigQuery). PostgreSQL uses LIMIT N.

LIMIT does NOT reduce bytes scanned

SELECT * FROM table LIMIT 10 still scans the ENTIRE table — BigQuery reads all matching data, then truncates the result. You pay for the full scan regardless of LIMIT. To reduce cost, select only the columns you need and filter on partitioned/clustered columns. See querying-and-cost-optimization.

Safe Pattern

Name specific columns instead of SELECT *, and always filter on the partition column when querying large tables: WHERE date >= '2026-01-01'. Use bq query --dry_run to preview bytes before running an unfamiliar query.

Backtick escaping for table references

BigQuery requires backticks around project.dataset.table when the project ID contains hyphens: `my-project.dataset.table`. Without backticks, the parser interprets the hyphen as minus. Column names that are reserved words (close, open) also need backticks, whereas SQL Server uses [brackets].

Retrieve the 10 most recent ASML trading days

During initial data exploration or to verify that the latest pipeline load landed correctly. It is typically triggered by need to confirm the most recent data available for a specific symbol, or spot-checking data freshness. GoogleSQL SELECT against stoxx_silver.eurostoxx50_ohlcv. Read-only. Bytes scanned = only the columns named in the SELECT list. LIMIT does not reduce scan cost — all matching rows are scanned, then output is truncated. Retrieve the most recent OHLCV rows for a single stock to verify data completeness and recency.

FieldSourceTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGYahoo Finance ticker symbol with exchange suffix (e.g., ASML.AS = Euronext Amsterdam)
dateeurostoxx50_ohlcv.dateDATETrading date (exchange local calendar — no weekends or holidays unless gap-filled)
openeurostoxx50_ohlcv.openFLOAT64Opening price for the trading session (first trade price)
higheurostoxx50_ohlcv.highFLOAT64Highest price reached during the trading session
loweurostoxx50_ohlcv.lowFLOAT64Lowest price reached during the trading session
closeeurostoxx50_ohlcv.closeFLOAT64Closing price (last trade price — used for most analytics and return calculations)
volumeeurostoxx50_ohlcv.volumeINT64Total number of shares traded during the session

Retrieve the 10 most recent ASML trading days with full OHLCV columns.

SELECT
    symbol,
    date,
    `open`,
    high,
    low,
    `close`,
    volume
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS'
ORDER BY date DESC
LIMIT 10

10 rows affected.

symboldateopenhighlowclosevolume
ASML.AS2026-03-121194.81202.21187.81190.8128223
ASML.AS2026-03-111188.41210.81174.01198.8562904
ASML.AS2026-03-101188.41208.41172.21200.0800815
ASML.AS2026-03-091072.01147.61060.21147.6689086
ASML.AS2026-03-061186.01192.61112.81147.0857271

SELECT, Filtering & Sorting | Multi-Condition WHERE

Combine conditions with AND / OR. Use ABS() for absolute values. This finds high-volume days with large price swings — potential breakout or crash days.

Find high-volume days with large intraday price swings

During ad-hoc market analysis or when investigating anomalous trading activity. It is typically triggered by need to identify potential breakout or crash days for risk analysis, backtesting filters, or event-driven trading signals. GoogleSQL SELECT with multi-condition WHERE against stoxx_silver.eurostoxx50_ohlcv. Read-only. Scans symbol, date, close, open, volume columns. The computed daily_move_pct is derived inline — not stored. Surface high-volume trading days where the intraday price swing exceeded 3% — candidate events for breakout/crash classification.

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol with exchange suffix
dateeurostoxx50_ohlcv.dateDATETrading date
closeeurostoxx50_ohlcv.closeFLOAT64Closing price
volumeeurostoxx50_ohlcv.volumeINT64Shares traded — filter threshold is 5,000,000
daily_move_pct(close - open) / open * 100FLOAT64 (%)Intraday price change as a percentage. Positive = close above open (bullish). Negative = close below open (bearish). Filter threshold is absolute value > 3%

Find high-volume days with price swings exceeding 3% — potential breakout or crash events.

SELECT
    symbol,
    date,
    `close`,
    volume,
    ROUND((`close` - `open`) / `open` * 100, 2) AS daily_move_pct
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE volume > 5000000
  AND ABS((`close` - `open`) / `open`) > 0.03
  AND date >= '2025-01-01'
ORDER BY ABS((`close` - `open`) / `open`) DESC
LIMIT 15

15 rows affected.

symboldateclosevolumedaily_move_pct
IFX.DE2025-04-1025.7811549391-13.78
ENR.DE2025-04-0748.56855296013.59
SAN.MC2025-04-075.24312012918112.87
SAN.MC2025-04-105.66263808362-11.14
DSY.PA2026-02-1615.967671987-10.81

Aggregation (GROUP BY)

GROUP BY collapses rows sharing a common key into a single row per group, evaluated after WHERE filtering. BigQuery runs aggregations in parallel across slots — each slot processes a shard of the input and emits partial aggregates that are merged in a final step. Because BigQuery bills per bytes scanned, every GROUP BY query should reference only the columns actually needed for the grouping key and aggregate inputs — SELECT * in an aggregation is both unnecessary and expensive.

BigQuery Bills Per Bytes Scanned

BigQuery charges per bytes scanned — SELECT * on a 1TB table costs ~$5. Unlike SQL Server (fixed cost), BigQuery bills per query based on columns accessed. Always SELECT only the columns you need. A GROUP BY that reads all columns before aggregating is expensive. Use SELECT col1, col2, AGG(col3) not SELECT *, AGG(col3).

Safe Pattern

Always name only the columns your aggregation needs. In GROUP BY queries, list the grouping key and aggregate inputs explicitly — never SELECT *. Run bq query --dry_run --use_legacy_sql=false 'SELECT ...' to confirm bytes billed before executing expensive queries.

Aggregation GROUP BY | Aggregate by Stock

GROUP BY collapses rows into groups. Aggregate functions (AVG, COUNT, SUM, MIN, MAX) summarize each group. This ranks stocks by average trading volume — a liquidity measure.

Rank stocks by average daily trading volume

During liquidity analysis or when building a universe filter for a trading strategy. It is typically triggered by need to identify the most actively traded stocks for portfolio construction, or to verify that volume data is populated across the full history. GoogleSQL GROUP BY against stoxx_silver.eurostoxx50_ohlcv. Read-only. Scans symbol, volume, close, date columns. Aggregates across the entire table (no date filter — full history scan). Rank stocks by average daily trading volume to assess liquidity — a core input for index weighting and portfolio construction decisions.

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGGrouping key — one row per stock
trading_daysCOUNT(*)INT64Number of trading days with data for this symbol
avg_volumeAVG(CAST(volume AS FLOAT64))FLOAT64Mean daily trading volume across the full history. Cast to FLOAT64 to avoid integer truncation
avg_closeAVG(close)FLOAT64Mean closing price — provides scale context for interpreting volume (high-priced stocks often have lower volume)
first_dateMIN(date)DATEEarliest trading date in the dataset for this symbol
last_dateMAX(date)DATEMost recent trading date — if this differs across symbols, it may indicate a delisting or data gap

Rank Euro Stoxx 50 stocks by average daily trading volume across the full history.

SELECT
    symbol,
    COUNT(*) AS trading_days,
    ROUND(AVG(CAST(volume AS FLOAT64)), 0) AS avg_volume,
    ROUND(AVG(`close`), 2) AS avg_close,
    MIN(date) AS first_date,
    MAX(date) AS last_date
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
GROUP BY symbol
ORDER BY avg_volume DESC
LIMIT 10

10 rows affected.

symboltrading_daysavg_volumeavg_closefirst_datelast_date
ISP.MI132187588601.03.152021-01-042026-03-12
SAN.MC132941770987.04.432021-01-042026-03-12
ENEL.MI132124678699.06.822021-01-042026-03-12
BBVA.MC132916654457.08.652021-01-042026-03-12
UCG.MI132113903710.028.462021-01-042026-03-12

Aggregation GROUP BY | Aggregate by Time Period

Group by EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date) to build time-series summaries. Shows monthly high/low/average price and total volume — the basis for monthly performance reports.

BigQuery has three date/time types

TypeTimezoneUse When
DATENoneTrade dates, report dates
DATETIMENone (civil time)Local event times
TIMESTAMPUTC (absolute)Pipeline timestamps, audit logs

CURRENT_TIMESTAMP() returns UTC. CURRENT_DATE() returns date in UTC. For a specific timezone: DATE(CURRENT_TIMESTAMP(), 'Europe/Prague'). Mixing types in JOIN/WHERE causes implicit coercion.

Safe Pattern

Use DATE for trade/report dates, TIMESTAMP for pipeline audit columns. When comparing across types, cast explicitly: CAST(my_datetime AS TIMESTAMP). Never rely on implicit coercion in JOIN keys — it masks type mismatches that surface only on certain data.

BigQuery NULL handling differences

BigQuery uses IFNULL(expr, default) where SQL Server uses ISNULL(expr, default). COALESCE() works identically in both. BigQuery also has SAFE_DIVIDE(a, b) which returns NULL instead of error on division by zero — SQL Server has no equivalent.

Build a monthly time-series summary per stock

When building monthly performance reports or feeding a time-series visualization. It is typically triggered by need to see monthly aggregated price behavior (high/low/average) and total volume for trend analysis or reporting. GoogleSQL GROUP BY with EXTRACT(YEAR/MONTH) against stoxx_silver.eurostoxx50_ohlcv. Read-only. Scans symbol, date, close, volume columns. The EXTRACT functions on the WHERE-filtered column do not prevent partition pruning when combined with a direct date range filter (as shown here with date >= '2025-01-01'). Produce a monthly time-series summary per stock showing price range, average price, and total volume — the basis for monthly performance dashboards.

FieldSource / ComputationTypeMeaning
yrEXTRACT(YEAR FROM date)INT64Calendar year
moEXTRACT(MONTH FROM date)INT64Calendar month (1–12)
daysCOUNT(*)INT64Trading days in the month for this symbol (typically 20–23 for European exchanges)
month_lowMIN(close)FLOAT64Lowest closing price in the month
month_highMAX(close)FLOAT64Highest closing price in the month
avg_closeAVG(close)FLOAT64Mean closing price for the month
total_volumeSUM(volume)INT64Total shares traded in the month

Build a monthly time-series summary for ASML: high, low, average close, and total volume per month.

SELECT
    EXTRACT(YEAR FROM date) AS yr,
    EXTRACT(MONTH FROM date) AS mo,
    COUNT(*) AS days,
    ROUND(MIN(`close`), 2) AS month_low,
    ROUND(MAX(`close`), 2) AS month_high,
    ROUND(AVG(`close`), 2) AS avg_close,
    SUM(volume) AS total_volume
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS' AND date >= '2025-01-01'
GROUP BY EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date)
ORDER BY yr, mo
LIMIT 15

15 rows affected.

yrmodaysmonth_lowmonth_highavg_closetotal_volume
2025122646.6748.1714.7119121187
2025220678.6737.9713.0415276962
2025321606.0690.3656.5817508550
2025420550.0619.7581.022544929
2025521601.5686.6650.1813112045

JOINs Across Medallion Layers

JOIN combines rows from two or more tables on a matching key. In the medallion architecture, joins connect fact tables (OHLCV prices in silver) with dimension tables (company metadata) and pre-computed analytics (gold scores). BigQuery distributes both sides of a join across worker slots and performs a shuffle based on the join key — small dimension tables are automatically broadcast, but joins between two large tables trigger a full data shuffle. Clustering the join key on both sides reduces shuffle cost significantly.

Cross-engine comparison

BigQuery supports all standard JOIN types (INNER, LEFT, RIGHT, FULL, CROSS). SQL Server adds CROSS APPLY and OUTER APPLY for correlated lateral joins. Firestore has no server-side joins — denormalize your data model or perform client-side joins.

BigQuery JOINs Cause Data Shuffles

BigQuery JOINs can produce massive data shuffles across slots. Unlike SQL Server (indexed seeks), BigQuery distributes both sides of a JOIN across worker nodes. Joining two large tables forces a full data shuffle. For repeated joins, denormalize into a single wide table or use clustering on the join key to reduce shuffle cost.

Safe Pattern

Cluster large tables on the most common JOIN key (e.g., symbol, _index). For small dimension tables (< a few hundred MB), BigQuery will automatically broadcast them, avoiding a full shuffle. For repeated cross-table joins, consider materializing the joined result as a gold-layer table instead of re-joining on every query.

JOIN Across Medallion Layers | OHLCV + Dimension (Silver)

JOIN combines rows from two tables on a matching key. Here we join price data (silver OHLCV) with company metadata (silver dimension) to get the latest price + sector + country for each stock.

The subquery with ROW_NUMBER() picks only the most recent price per symbol.

Join latest price per stock with company dimension metadata

When building a current-state snapshot of the portfolio — latest price enriched with sector, country, and company name. It is typically triggered by dashboard refresh, ad-hoc portfolio review, or verifying that dimension metadata aligns with the latest price data. GoogleSQL JOIN between stoxx_silver.eurostoxx50_ohlcv and stoxx_silver.index_dim. Read-only. The subquery uses ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) to deduplicate to the latest date per symbol before joining. BigQuery will broadcast the small dimension table automatically. Produce a single enriched row per stock showing the most recent price alongside company metadata (sector, country, name).

FieldSourceTypeMeaning
symbolindex_dim.symbolSTRINGTicker symbol from the dimension table
short_nameindex_dim.short_nameSTRINGCompany short name (e.g., ASML HOLDING)
sectorindex_dim.sectorSTRINGGICS sector classification
countryindex_dim.countrySTRINGCountry of primary listing
last_closeeurostoxx50_ohlcv.close (aliased)FLOAT64Most recent closing price
last_dateeurostoxx50_ohlcv.date (aliased)DATEDate of the most recent price record
volumeeurostoxx50_ohlcv.volumeINT64Volume on the most recent trading day

Join the latest price per stock (via ROW_NUMBER deduplication) with dimension metadata.

SELECT
    d.symbol,
    d.short_name,
    d.sector,
    d.country,
    p.`close` AS last_close,
    p.date AS last_date,
    p.volume
FROM `bq-wh-nb.stoxx_silver.index_dim` d
JOIN (
    SELECT symbol, `close`, date, volume,
           ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
) p ON d.symbol = p.symbol AND p.rn = 1
WHERE d._index = 'euro_stoxx_50' AND d.is_current = TRUE
ORDER BY p.`close` DESC
LIMIT 15

15 rows affected.

symbolshort_namesectorcountrylast_closelast_datevolume
RMS.PAHERMES INTLConsumer CyclicalFrance1906.02026-03-1218681
RHM.DERHEINMETALL AGIndustrialsGermany1551.52026-03-12158741
ASML.ASASML HOLDINGTechnologyNetherlands1190.82026-03-12128223
ADYEN.ASADYENTechnologyNetherlands925.72026-03-1227887
ARGX.BRARGENX SEHealthcareNetherlands626.62026-03-1214083

JOIN Across Medallion Layers | Gold Scores + Dimension (Cross-Layer)

The gold layer has pre-computed composite scores. We join with the dimension table to add human-readable names and sector labels — this is what a dashboard query looks like.

Join gold composite scores with dimension labels for a ranked dashboard

When producing a ranked stock dashboard that combines pre-computed gold scores with human-readable dimension labels. It is typically triggered by daily dashboard refresh, portfolio review, or verifying that the scoring pipeline produced sensible results. GoogleSQL JOIN between stoxx_gold.scores_daily and stoxx_silver.index_dim. Read-only. The subquery on score_date fetches the latest scoring run. BigQuery broadcasts the dimension table automatically. Produce the final ranked stock dashboard joining composite scores (value, momentum, sentiment) with company metadata and index weight.

FieldSource / ComputationTypeMeaning
rankscores_daily.composite_rankINT64Overall rank within the index (1 = best composite score)
symbolscores_daily.symbolSTRINGTicker symbol
short_nameindex_dim.short_nameSTRINGCompany short name
sectorindex_dim.sectorSTRINGGICS sector classification
scoreROUND(composite_score, 4)FLOAT64Weighted composite of value, momentum, and sentiment scores
valueROUND(relative_value_score, 3)FLOAT64Relative value component (higher = cheaper vs peers)
momentumROUND(momentum_score, 3)FLOAT64Price momentum component (higher = stronger recent trend)
sentimentROUND(sentiment_score, 3)FLOAT64Market sentiment component (higher = more positive analyst signals)
current_pricescores_daily.current_priceFLOAT64Price at time of scoring
weight_pctindex_weight * 100FLOAT64 (%)Stock’s weight in the index as a percentage

Join gold-layer composite scores with dimension metadata to produce a ranked stock dashboard.

SELECT
    s.composite_rank AS `rank`,
    s.symbol,
    d.short_name,
    d.sector,
    ROUND(s.composite_score, 4) AS score,
    ROUND(s.relative_value_score, 3) AS value,
    ROUND(s.momentum_score, 3) AS momentum,
    ROUND(s.sentiment_score, 3) AS sentiment,
    s.current_price,
    ROUND(s.index_weight * 100, 2) AS weight_pct
FROM `bq-wh-nb.stoxx_gold.scores_daily` s
JOIN `bq-wh-nb.stoxx_silver.index_dim` d ON s.symbol = d.symbol AND d._index = s._index AND d.is_current = TRUE
WHERE s._index = 'euro_stoxx_50'
  AND s.score_date = (SELECT MAX(score_date) FROM `bq-wh-nb.stoxx_gold.scores_daily` WHERE _index = 'euro_stoxx_50')
ORDER BY s.composite_rank
LIMIT 15

15 rows affected.

ranksymbolshort_namesectorscorevaluemomentumsentimentcurrent_priceweight_pct
1BNP.PABNP PARIBAS ACT.AFinancial Services0.67961.4970.460.08187.441.94
2VOW.DEVOLKSWAGEN AGConsumer Cyclical0.57561.028-0.3821.08192.850.93
3DTE.DEDEUTSCHE TELEKOM AGCommunication Services0.4870.2260.7060.52932.553.13
4TTE.PATOTALENERGIESEnergy0.39130.5851.307-0.71969.82.95
5ABI.BRAB INBEVConsumer Defensive0.38520.2510.5370.36862.762.43

Window Functions

Window functions compute a value for each row based on a “window” of related rows — without collapsing the result set like GROUP BY. The OVER() clause defines the window: PARTITION BY groups rows (like GROUP BY but without collapsing), ORDER BY sorts within each partition, and the frame clause (ROWS BETWEEN) controls which rows the function sees. BigQuery distributes window function computation across slots — each slot handles a subset of partitions in parallel.

Cross-engine comparison

Window functions are available in BigQuery (GoogleSQL) and SQL Server (T-SQL) with near-identical syntax. Key difference: BigQuery supports QUALIFY for filtering on window results without a subquery (not ANSI SQL, not available in SQL Server). Firestore has no window functions — ranking and running totals must be computed client-side.

Window Functions | Moving Averages (SMA)

A moving average smooths price data over N days. Used for trend detection:

  • SMA 30 (short-term): responsive to recent price action
  • SMA 90 (long-term): filters out noise
  • Price above SMA = bullish momentum. Below = bearish.

AVG() OVER (ROWS BETWEEN N PRECEDING AND CURRENT ROW) — the window slides forward one row at a time.

Compute 30-day and 90-day SMAs with a sliding window

When generating trend signals or building technical analysis overlays for time-series data. It is typically triggered by need to compute short-term (SMA 30) and long-term (SMA 90) moving averages for trend detection. Price above SMA = bullish momentum; price crossing below = bearish signal. GoogleSQL window function AVG() OVER (ROWS BETWEEN N PRECEDING AND CURRENT ROW) against stoxx_silver.eurostoxx50_ohlcv. Read-only. The ROWS frame ensures exactly 30 or 90 physical rows are averaged (not RANGE, which would group ties). Compute 30-day and 90-day simple moving averages (SMA) to identify trend direction and potential crossover signals.

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol
dateeurostoxx50_ohlcv.dateDATETrading date
closeROUND(close, 2)FLOAT64Closing price
sma_30AVG(close) OVER (ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)FLOAT6430-day simple moving average — responsive to recent price action
sma_90AVG(close) OVER (ROWS BETWEEN 89 PRECEDING AND CURRENT ROW)FLOAT6490-day simple moving average — filters out noise, shows longer-term trend

Compute 30-day and 90-day simple moving averages for ASML’s closing price.

SELECT
    symbol,
    date,
    ROUND(`close`, 2) AS `close`,
    ROUND(AVG(`close`) OVER (
        PARTITION BY symbol ORDER BY date
        ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
    ), 2) AS sma_30,
    ROUND(AVG(`close`) OVER (
        PARTITION BY symbol ORDER BY date
        ROWS BETWEEN 89 PRECEDING AND CURRENT ROW
    ), 2) AS sma_90
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS'
ORDER BY date DESC
LIMIT 15

15 rows affected.

symboldateclosesma_30sma_90
ASML.AS2026-03-121190.81204.411052.59
ASML.AS2026-03-111198.81204.451049.65
ASML.AS2026-03-101200.01204.311046.53
ASML.AS2026-03-091147.61204.891043.62
ASML.AS2026-03-061147.01205.911041.08

Window Functions | LAG / LEAD Compare Rows

LAG(col, N) returns the value from N rows before the current row. LEAD(col, N) returns the value from N rows after.

Use cases:

  • Daily returns: (close - LAG(close)) / LAG(close)
  • Gap detection: DATE_DIFF(date, LAG(date), DAY) — a days_gap value >1 indicates a weekend (normal: 3 for Fri→Mon) or holiday (>3 is unusual and worth investigating)
  • Trend direction: compare today vs yesterday

Calculate daily return percentage and detect calendar gaps

When computing daily return time series or auditing the trading calendar for unexpected gaps. It is typically triggered by building a return series for risk/performance analytics, or investigating why a rolling calculation produced unexpected results (often caused by hidden gaps). GoogleSQL window functions LAG() and DATE_DIFF() against stoxx_silver.eurostoxx50_ohlcv. Read-only. LAG is partitioned by symbol and ordered by date — each row sees only its own symbol’s history. Compute daily return as percentage change from the previous close, and detect calendar gaps (days_gap > 3 indicates a holiday or data issue beyond a normal weekend).

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol
dateeurostoxx50_ohlcv.dateDATETrading date
closeROUND(close, 2)FLOAT64Closing price
prev_closeLAG(close) OVER (PARTITION BY symbol ORDER BY date)FLOAT64Previous trading day’s closing price (NULL for the first row per symbol)
daily_return_pct(close - prev_close) / prev_close * 100FLOAT64 (%)Daily return as a percentage. Positive = price increased. First row per symbol is NULL
days_gapDATE_DIFF(date, LAG(date), DAY)INT64Calendar days since previous trading date. Normal values: 1 (consecutive weekday), 3 (Friday→Monday). Values > 3 indicate holidays or data gaps

Calculate daily return percentage and detect calendar gaps using LAG on close price and date.

SELECT
    symbol,
    date,
    ROUND(`close`, 2) AS `close`,
    ROUND(LAG(`close`) OVER (PARTITION BY symbol ORDER BY date), 2) AS prev_close,
    ROUND(
        (`close` - LAG(`close`) OVER (PARTITION BY symbol ORDER BY date))
        / LAG(`close`) OVER (PARTITION BY symbol ORDER BY date) * 100,
    2) AS daily_return_pct,
    DATE_DIFF(date
    , LAG(date) OVER (PARTITION BY symbol ORDER BY date), DAY) AS days_gap
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS'
ORDER BY date DESC
LIMIT 15

15 rows affected.

symboldatecloseprev_closedaily_return_pctdays_gap
ASML.AS2026-03-121190.81198.8-0.671
ASML.AS2026-03-111198.81200.0-0.11
ASML.AS2026-03-101200.01147.64.571
ASML.AS2026-03-091147.61147.00.053
ASML.AS2026-03-061147.01186.0-3.291

Window Functions | RANK / DENSE_RANK / NTILE Ranking

  • RANK(): assigns rank with gaps (1, 2, 2, 4)
  • DENSE_RANK(): no gaps (1, 2, 2, 3)
  • ROW_NUMBER(): unique, no ties (1, 2, 3, 4)
  • NTILE(N): divide rows into N equal buckets (quartiles, deciles)

This is the core of the gold scoring engine — rank stocks by composite score.

Two-CTE Self-Join Pattern

CTE bounds computes the year’s first and last trading dates in one scan. CTE ytd self-joins to get the opening and closing prices for each symbol. The final SELECT ranks by YTD return.

Rank stocks by YTD return and assign quartile buckets

When building a YTD performance ranking or segmenting stocks into quantile buckets for portfolio construction. It is typically triggered by end-of-day scoring run, periodic performance review, or constructing a quantile-based trading signal. GoogleSQL CTEs with self-join and window functions against stoxx_silver.eurostoxx50_ohlcv. Read-only. The bounds CTE scans the table once to find the first and last trading dates of the current year. The ytd CTE self-joins to pair each symbol’s opening and closing prices. Compute year-to-date return per stock, rank them best to worst, and assign quartile buckets (1 = top performers, 4 = laggards).

FieldSource / ComputationTypeMeaning
symbolytd.symbolSTRINGTicker symbol
ytd_return(latest_close - first_close) / NULLIF(first_close, 0)FLOAT64Year-to-date return as a decimal (0.2073 = +20.73%)
rank_bestRANK() OVER (ORDER BY ytd_return DESC)INT64Rank from best to worst (1 = highest YTD return)
rank_worstRANK() OVER (ORDER BY ytd_return ASC)INT64Rank from worst to best (1 = lowest YTD return)
quartileNTILE(4) OVER (ORDER BY ytd_return DESC)INT64Quartile bucket: 1 = top 25%, 2 = 25–50%, 3 = 50–75%, 4 = bottom 25%

Compute YTD return per stock, then rank and assign quartile buckets using RANK and NTILE.

WITH bounds AS (
    SELECT
        MIN(CASE WHEN EXTRACT(YEAR FROM date)
            = EXTRACT(YEAR FROM CURRENT_DATE()) THEN date END) AS first_date,
        MAX(date) AS last_date
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
),
ytd AS (
    SELECT f.symbol,
        ROUND((l.`close` - f.`close`) / NULLIF(f.`close`, 0), 4) AS ytd_return
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv` f
    JOIN `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv` l ON f.symbol = l.symbol
    JOIN bounds b ON f.date = b.first_date AND l.date = b.last_date
)
SELECT symbol, ytd_return,
    RANK() OVER (ORDER BY ytd_return DESC) AS rank_best,
    RANK() OVER (ORDER BY ytd_return ASC) AS rank_worst,
    NTILE(4) OVER (ORDER BY ytd_return DESC) AS quartile
FROM ytd
ORDER BY rank_best LIMIT 10

10 rows affected.

symbolytd_returnrank_bestrank_worstquartile
ENI.MI0.30421501
ENR.DE0.25082491
TTE.PA0.24373481
ASML.AS0.20734471
AD.AS0.17725461

CTEs & Subqueries

A CTE (WITH name AS (SELECT ...)) creates a named temporary result set scoped to the enclosing query. CTEs improve readability by breaking complex queries into named steps. BigQuery also supports recursive CTEs (covered in the advanced patterns file).

Cross-engine comparison

BigQuery supports recursive CTEs (500 iteration default). SQL Server also supports recursive CTEs (100 iteration default). Firestore has no query-level CTE or subquery capability.

CTEs & Subqueries | Sector Heatmap

A CTE (WITH name AS (SELECT ...)) is a named temporary result set. Chaining CTEs makes complex queries readable — each step has a name.

This builds a sector heatmap: average score, best/worst rank per sector.

Build a sector heatmap with chained CTEs

When building a sector-level dashboard or comparing sector performance for allocation decisions. It is typically triggered by daily scoring run complete — need to roll up stock-level scores to sector-level aggregates for portfolio managers. GoogleSQL chained CTEs joining stoxx_gold.scores_daily with stoxx_silver.index_dim. Read-only. Two CTEs: latest_scores enriches individual stock scores with sector labels; sector_stats aggregates by sector. Produce a sector heatmap showing average composite score, average value/momentum scores, and best/worst rank per sector — a single-query sector overview.

FieldSource / ComputationTypeMeaning
sectorindex_dim.sectorSTRINGGICS sector classification
stocksCOUNT(*)INT64Number of stocks in the sector within the index
avg_scoreAVG(composite_score)FLOAT64Mean composite score across all stocks in the sector
avg_valueAVG(relative_value_score)FLOAT64Mean relative value score — higher indicates sector is undervalued vs peers
avg_momentumAVG(momentum_score)FLOAT64Mean momentum score — higher indicates sector has stronger recent price trend
best_rankMIN(composite_rank)INT64Best-ranked stock in the sector (lowest number = best)
worst_rankMAX(composite_rank)INT64Worst-ranked stock in the sector

Chain two CTEs to compute per-sector average scores and rank ranges from the latest gold scoring run.

WITH latest_scores AS (
    SELECT s.symbol, s.composite_score, s.relative_value_score,
           s.momentum_score, s.sentiment_score, s.composite_rank,
           s.current_price, s.index_weight, d.sector, d.short_name
    FROM `bq-wh-nb.stoxx_gold.scores_daily` s
    JOIN `bq-wh-nb.stoxx_silver.index_dim` d ON s.symbol = d.symbol AND d._index = s._index AND d.is_current = TRUE
    WHERE s._index = 'euro_stoxx_50'
      AND s.score_date = (SELECT MAX(score_date) FROM `bq-wh-nb.stoxx_gold.scores_daily` WHERE _index = 'euro_stoxx_50')
),
sector_stats AS (
    SELECT
        sector,
        COUNT(*) AS stocks,
        ROUND(AVG(composite_score), 4) AS avg_score,
        ROUND(AVG(relative_value_score), 4) AS avg_value,
        ROUND(AVG(momentum_score), 4) AS avg_momentum,
        MIN(composite_rank) AS best_rank,
        MAX(composite_rank) AS worst_rank
    FROM latest_scores
    GROUP BY sector
)
SELECT * FROM sector_stats
ORDER BY avg_score DESC

10 rows affected.

sectorstocksavg_scoreavg_valueavg_momentumbest_rankworst_rank
Communication Services10.4870.2260.706433
Energy20.32860.57441.6426411
Healthcare40.0812-0.07-0.37221032
Technology50.05220.0128-0.6536647
Industrials100.05040.0-0.0204843

CTEs & Subqueries | Chained CTEs Cross-Index Comparison

Multiple CTEs chained together. Compares YTD performance, volatility, and valuation across all 4 indices — the kind of query an index provider runs daily.

Compare key metrics across all four indices

When comparing index-level performance metrics across the full stoxx universe for cross-index analysis. It is typically triggered by daily performance reporting, portfolio allocation review, or verifying that the index performance pipeline is producing consistent results across all indices. GoogleSQL CTE with ROW_NUMBER against stoxx_gold.index_performance joined with stoxx_bronze.dim_index. Read-only. Fetches the latest performance row per index. Produce a cross-index comparison showing YTD return, 30-day return and volatility, stock count, average P/E, and dividend yield — the kind of summary an index provider reviews daily.

FieldSource / ComputationTypeMeaning
_indexindex_performance._indexSTRINGInternal index key (e.g., euro_stoxx_50, oil_20)
display_namedim_index.display_nameSTRINGHuman-readable index name
perf_dateindex_performance.perf_dateDATEDate of the latest performance calculation
ytd_pctytd_return * 100FLOAT64 (%)Year-to-date return as a percentage
ret_30d_pctrolling_30d_return * 100FLOAT64 (%)Rolling 30-day return as a percentage
vol_30d_pctrolling_30d_volatility * 100FLOAT64 (%)Rolling 30-day volatility (annualized standard deviation of daily returns) as a percentage
stocks_countindex_performance.stocks_countINT64Number of constituent stocks in the index
avg_peindex_performance.avg_peFLOAT64Average forward price-to-earnings ratio across constituents
div_yield_pctavg_dividend_yield * 100FLOAT64 (%)Average dividend yield across constituents as a percentage

Compare YTD return, 30-day volatility, P/E, and dividend yield across all four indices.

WITH latest_perf AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY _index ORDER BY perf_date DESC) AS rn
    FROM `bq-wh-nb.stoxx_gold.index_performance`
)
SELECT
    p._index,
    d.display_name,
    p.perf_date,
    ROUND(p.ytd_return * 100, 2) AS ytd_pct,
    ROUND(p.rolling_30d_return * 100, 2) AS ret_30d_pct,
    ROUND(p.rolling_30d_volatility * 100, 2) AS vol_30d_pct,
    p.stocks_count,
    ROUND(p.avg_pe, 1) AS avg_pe,
    ROUND(p.avg_dividend_yield * 100, 2) AS div_yield_pct
FROM latest_perf p
JOIN `bq-wh-nb.stoxx_bronze.dim_index` d ON p._index = d.index_key
WHERE p.rn = 1
ORDER BY ytd_pct DESC

4 rows affected.

_indexdisplay_nameperf_dateytd_pctret_30d_pctvol_30d_pctstocks_countavg_pediv_yield_pct
oil_20Oil & Gas 202026-03-1127.715.3321.931916.13.28
stoxx_asia_50STOXX Asia/Pacific 502026-03-125.452.6823.35015.81.96
stoxx_usa_50STOXX USA 502026-03-113.710.613.325020.81.42
euro_stoxx_50Euro Stoxx 502026-03-12-2.39-2.0818.065014.02.9

Data Quality Checks

Quality gates validate data integrity at each medallion layer boundary. Run these checks after every load — if any check returns a non-zero count, investigate before promoting data to the next layer. The UNION ALL pattern below stacks multiple independent checks into a single result set, making it easy to scan for issues in one query.

Data Quality Checks | UNION ALL Quality Gate

Every pipeline needs quality gates. UNION ALL stacks multiple checks into one result. Run this after every load — if any check returns non-zero, investigate before promoting to gold.

UNION ALL Quality Gate Pattern

Stack multiple checks into one result set. Each check returns a named row with an issue count. Any non-zero value needs investigation before promoting to gold.

The first cell checks structural integrity (null prices, negative values, high < low). The second checks operational health (gap-filled row count, data freshness).

Run structural quality checks (NULLs, negatives, impossible values)

After every pipeline load, before promoting data from silver to gold. It is typically triggered by completion of a silver-layer load — this is a gate that must pass before any downstream transforms execute. GoogleSQL UNION ALL of three independent COUNT queries against stoxx_silver.eurostoxx50_ohlcv. Read-only. Each check scans only the relevant columns. Any non-zero issues count requires investigation. Validate structural integrity of the silver OHLCV data — catch null prices, negative prices, and physically impossible values (high < low) before they contaminate gold-layer analytics.

FieldSource / ComputationTypeMeaning
check_nameString literalSTRINGName of the quality check: null_prices, negative_prices, high_lt_low
issuesCOUNT(*) with specific WHERE filterINT64Number of rows failing the check. 0 = pass. Any non-zero value requires investigation before gold promotion

Run structural quality checks: null prices, negative prices, and impossible high < low.

SELECT 'null_prices' AS check_name, COUNT(*) AS issues
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE `close` IS NULL OR `open` IS NULL
UNION ALL
SELECT 'negative_prices', COUNT(*)
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE `close` < 0 OR `open` < 0
UNION ALL
SELECT 'high_lt_low', COUNT(*)
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE high < low

Run operational freshness and gap-fill checks

After every pipeline load, alongside the structural checks above. It is typically triggered by completion of a silver-layer load — monitors pipeline health and data currency. GoogleSQL UNION ALL of two queries against stoxx_silver.eurostoxx50_ohlcv. Read-only. The gap_filled_rows check counts rows where is_filled = TRUE (synthetic rows created during gap-filling). The days_since_update check computes freshness. Monitor operational health — how many synthetic gap-filled rows exist, and how many days since the last data update. A high days_since_update value (> 1 on a business day) indicates the pipeline may have stalled.

FieldSource / ComputationTypeMeaning
check_nameString literalSTRINGName of the check: gap_filled_rows, days_since_update
issuesCOUNT(*) or DATE_DIFF(CURRENT_DATE(), MAX(date), DAY)INT64For gap_filled_rows: total synthetic rows (non-zero is informational, not necessarily a failure). For days_since_update: calendar days since last data — values > 1 on a weekday warrant investigation

Run operational checks: count of gap-filled synthetic rows and days since last data update.

SELECT 'gap_filled_rows' AS check_name, COUNT(*) AS issues
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE is_filled = TRUE
UNION ALL
SELECT 'days_since_update',
       DATE_DIFF(CURRENT_DATE(), MAX(date), DAY)
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`

5 rows affected.

check_nameissues
null_prices0
negative_prices0
high_lt_low0
gap_filled_rows6
days_since_update10
CheckValueWatchMeaningAction
null_prices0Any non-zeroRows where close or open is NULL — missing price dataInvestigate source feed. Do not promote to gold until resolved
negative_prices0Any non-zeroRows where close or open < 0 — physically impossible for equity pricesLikely data corruption or sign error in the feed. Quarantine affected rows
high_lt_low0Any non-zeroRows where high < low — violates the OHLC constraintSource feed error. Flag for manual review or exclude from analytics
gap_filled_rows1–20> 50Synthetic rows inserted during gap-filling (weekends, holidays). Small counts are expectedHigh counts may indicate excessive gap-filling. Verify holiday calendar alignment
days_since_update0–1> 1 (weekday)Calendar days since the most recent trading date in the tableValues > 1 on a weekday indicate the pipeline may have stalled. Check ingestion logs and _ingested_at timestamps in bronze

Bronze → Silver → Gold Transforms

The medallion architecture organizes data into three progressive layers: bronze (raw ingestion, minimal transformation), silver (cleaned, enriched, business-typed), and gold (aggregated, scored, dashboard-ready). Each transform query reads from the layer below and writes to the layer above.


flowchart LR
    B["Bronze<br>Raw OHLCV + dimensions<br>_ingested_at audit column"] --> S["Silver<br>Daily returns, gap-filling<br>is_filled flag, type casting"]
    S --> G["Gold<br>Z-score normalization<br>Composite rank, index performance"]

Related pattern

The transforms below query data that was first ingested through the data-loading-and-export pipeline. Understanding how data arrives in bronze helps explain the schemas these queries target.

Bronze → Silver → Gold Transforms | Daily Returns

The silver transform adds computed columns to raw data. Here, LAG() computes daily returns from the price time series. The is_filled flag marks gap-filled rows (weekends/holidays).

Compute daily return with LAG and NULLIF safe division

During the silver-layer transform phase — after raw OHLCV data is loaded and validated, before gold-layer scoring. It is typically triggered by successful completion of the silver quality gate checks. This transform adds the daily_return computed column to the silver dataset. GoogleSQL window function LAG() with NULLIF safe division against stoxx_silver.eurostoxx50_ohlcv. Read-only query (in production this would be an INSERT INTO ... SELECT or a scheduled query writing to a target table). Uses NULLIF(LAG(close), 0) to prevent division-by-zero errors. Compute daily return as a decimal change from the previous day’s close — the foundational input for rolling volatility, momentum scores, and risk analytics.

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol
dateeurostoxx50_ohlcv.dateDATETrading date
closeROUND(close, 2)FLOAT64Closing price
daily_return(close - LAG(close)) / NULLIF(LAG(close), 0)FLOAT64Daily return as a decimal (0.0457 = +4.57%). NULL for the first row per symbol. NULLIF prevents division by zero if a prior close is zero (delisted stock edge case)
is_filledeurostoxx50_ohlcv.is_filledBOOLTrue = synthetic gap-filled row (weekend/holiday). False = real trading data. Gap-filled rows carry forward the previous close, so their daily_return is 0 or near-zero

Compute daily return as a percentage change from the previous day’s close using LAG with NULLIF safe-division.

SELECT
    symbol,
    date,
    ROUND(`close`, 2) AS `close`,
    ROUND(
        (`close` - LAG(`close`) OVER (PARTITION BY symbol ORDER BY date))
        / NULLIF(LAG(`close`) OVER (PARTITION BY symbol ORDER BY date), 0),
    4) AS daily_return,
    is_filled
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS'
ORDER BY date DESC
LIMIT 10

10 rows affected.

symboldateclosedaily_returnis_filled
ASML.AS2026-03-121190.8-0.0067False
ASML.AS2026-03-111198.8-0.001False
ASML.AS2026-03-101200.00.0457False
ASML.AS2026-03-091147.60.0005False
ASML.AS2026-03-061147.0-0.0329False

Bronze → Silver → Gold Transforms | Z-Score Normalization

The gold transform normalizes scores across the index using z-scores: (value - mean) / stddev. Stocks are then ranked by composite score. This is the core of any index scoring engine.

Normalize composite scores to z-scores across the index

During the gold-layer scoring pipeline — after composite scores are computed, before final ranking and dashboard publication. It is typically triggered by completion of the scoring calculation. Z-score normalization makes scores comparable across scoring runs with different means/standard deviations. GoogleSQL CTE with window functions AVG() OVER () and STDDEV() OVER () against stoxx_gold.scores_daily. Read-only. The OVER () clause with no partition computes the mean and standard deviation across the entire index. Normalize composite scores to z-scores (standard deviations from the mean) and rank stocks — enables cross-period comparison since z-scores are scale-invariant.

FieldSource / ComputationTypeMeaning
symbolscores_daily.symbolSTRINGTicker symbol
raw_scoreROUND(composite_score, 4)FLOAT64Original composite score from the scoring pipeline
z_score(composite_score - mean) / NULLIF(stddev, 0)FLOAT64Z-score: number of standard deviations above (+) or below (-) the index mean. Values > 2.0 are strong outliers; values near 0 are average
rankDENSE_RANK() OVER (ORDER BY composite_score DESC)INT64Rank by composite score. Uses DENSE_RANK (no gaps) — if two stocks tie at rank 2, the next stock is rank 3

Normalize composite scores to z-scores across the index and rank stocks by composite score.

WITH base AS (
    SELECT symbol, composite_score,
           AVG(composite_score) OVER () AS mean_score,
           STDDEV(composite_score) OVER () AS std_score
    FROM `bq-wh-nb.stoxx_gold.scores_daily`
    WHERE _index = 'euro_stoxx_50'
      AND score_date = (SELECT MAX(score_date) FROM `bq-wh-nb.stoxx_gold.scores_daily` WHERE _index = 'euro_stoxx_50')
)
SELECT
    symbol,
    ROUND(composite_score, 4) AS raw_score,
    ROUND((composite_score - mean_score) / NULLIF(std_score, 0), 2) AS z_score,
    DENSE_RANK() OVER (ORDER BY composite_score DESC) AS `rank`
FROM base
ORDER BY `rank`
LIMIT 10

10 rows affected.

symbolraw_scorez_scorerank
BNP.PA0.67962.081
VOW.DE0.57561.762
DTE.DE0.4871.483
TTE.PA0.39131.184
ABI.BR0.38521.165

BigQuery Fundamentals Warnings

The table below lists the BigQuery-specific query anti-patterns that silently increase cost, degrade performance, or produce wrong results. Each entry corresponds to a pattern covered earlier in this note.

TopicWarning
SELECT *Scans all columns — BigQuery is columnar, so more columns = more bytes scanned = higher cost. Always list specific columns.
LIMIT does not reduce costBigQuery scans the full dataset matching the WHERE clause regardless of LIMIT. LIMIT only truncates the output.
Missing partition filterOn a partitioned table, queries without a partition filter scan every partition at full cost. Enable require_partition_filter to prevent this.
EXTRACT() on partition columnWHERE EXTRACT(YEAR FROM date) = 2025 prevents partition pruning. Rewrite as a range predicate.
Division by zeroBigQuery raises an error on division by zero (unlike SQL Server which returns NULL for float). Use SAFE_DIVIDE(a, b) or a / NULLIF(b, 0).
JOIN shufflesJOINing two large tables forces a full data shuffle across slots. Cluster large tables on the join key or materialize the join result.
Date type mixingComparing DATE and TIMESTAMP causes implicit coercion that can mask bugs. Always cast explicitly.

BigQuery Fundamentals Recommendations

Standing guidance for writing cost-efficient BigQuery queries in this medallion pipeline. Apply these as defaults unless a specific query has a documented reason to deviate.

AreaRecommendation
Cost controlAlways dry-run before expensive queries: bq query --dry_run "SELECT ...". Use job_config.dry_run = True in Python.
Partition designPartition by the most common WHERE filter column (usually date). Add require_partition_filter = TRUE to prevent full-table scans.
ClusteringCluster on the most common JOIN/WHERE column after the partition column (e.g., symbol after date). Maximum 4 clustering columns.
Column selectionName only the columns your query needs. Never use SELECT * in production queries.
Safe divisionUse SAFE_DIVIDE(a, b) for cleaner syntax, or a / NULLIF(b, 0) for cross-engine portability.
Quality gatesRun UNION ALL quality checks after every load. Automate the check and halt promotion to gold if any check returns non-zero.
Materialized viewsFor expensive aggregations hit repeatedly (dashboard queries), create a materialized view instead of re-scanning base tables.

BigQuery Fundamentals Troubleshooting

Symptoms you will encounter when a BigQuery query misbehaves or unexpectedly scans too many bytes, mapped to the most likely cause and the fix that resolves it in practice.

SymptomLikely causeFix
Query costs more than expectedMissing partition filter, or SELECT * scanning all columnsAdd a WHERE filter on the partition column. Select only needed columns. Dry-run to verify bytes.
SAFE_DIVIDE returns NULL unexpectedlyDenominator is zero or NULLCheck input data for zero/NULL values. Use IFNULL(SAFE_DIVIDE(a, b), 0) if zero is the desired default.
Query returns different results than SQL ServerFLOAT64 precision differences, or EXCEPT DISTINCT vs EXCEPT namingCheck rounding. BigQuery uses EXCEPT DISTINCT explicitly; SQL Server’s EXCEPT is implicitly distinct.
days_since_update shows high value in quality checkPipeline stalled or BigQuery table not refreshedCheck pipeline logs. Verify _ingested_at timestamps in bronze. Re-run ingestion if source data is available.
Moving average differs from SQL ServerDifferent frame clause semantics — BigQuery and SQL Server handle RANGE vs ROWS identically, but check for NULLs or FLOAT precisionEnsure both use ROWS BETWEEN N PRECEDING AND CURRENT ROW. Compare with ROUND() to rule out precision differences.

BigQuery Fundamentals Cross-References

Related notes that extend or depend on the patterns covered here.