BigQuery Engineering

Quote

“BigQuery separates storage from compute. That single architectural decision changes everything about how you design tables, partition data, and pay for queries.”

Jordan Tigani, founding engineer of BigQuery

INFORMATION_SCHEMA Is BigQuery's Primary Introspection

Unlike SQL Server’s sys.* DMVs, BigQuery exposes all metadata through INFORMATION_SCHEMA views:

  • INFORMATION_SCHEMA.TABLES — table metadata, row count, size
  • INFORMATION_SCHEMA.COLUMNS — column names, types, nullable
  • INFORMATION_SCHEMA.JOBS — query history, bytes scanned, cost
  • INFORMATION_SCHEMA.TABLE_STORAGE — storage bytes per table
  • INFORMATION_SCHEMA.PARTITIONS — partition metadata

BigQuery DML Has Strict Quotas

Each table allows a maximum of 1,500 DML statements per day (INSERT, UPDATE, DELETE, MERGE combined). A pipeline running MERGE every 5 minutes = 288/day — fine. Every 1 minute = 1,440/day — dangerously close. Streaming inserts (insertAll API) have a separate quota and are not subject to the DML limit.

Safe Pattern

Keep MERGE frequency at one execution per pipeline run (e.g., daily or hourly scheduled queries). For high-frequency writes, switch to the Streaming API (insertAll) or use Storage Write API (batch mode) — both bypass the DML quota entirely. Monitor daily DML usage via INFORMATION_SCHEMA.JOBS.

Always Dry-Run Before Expensive Queries

In bq CLI: bq query --dry_run "SELECT ..." — returns estimated bytes without executing. In Python: job_config.dry_run = True. At 6.25. Check before you run. See gcp-billing-and-pricing > BigQuery.

Lab Environment Note

Some sections CREATE database objects. All objects are created in a demo schema or use temp tables to avoid modifying the production stoxx schema.

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. See gcloud-authentication > The ADC Credential Search Order.

BigQuery uses datasets as the equivalent of SQL Server schemas. The CREATE SCHEMA statement below creates a demo dataset for lab objects — it is idempotent and safe to re-run.

Create the demo dataset if it does not already exist (idempotent).

CREATE SCHEMA IF NOT EXISTS demo
OPTIONS(location="europe-west1")

Views

BigQuery views encapsulate reusable queries as named objects in a dataset. Unlike SQL Server, BigQuery regular views do not cache results — every SELECT against a view re-executes the full underlying query and charges the bytes scanned. For repeated dashboard queries, a materialized view (CREATE MATERIALIZED VIEW) stores the pre-computed result and is automatically refreshed by BigQuery.

Regular Views | Simplify Complex Queries

A view is a saved query — it stores no data and re-executes the underlying query on every SELECT. This means each read from a view incurs the full scan cost of the base tables. Use case: wrap the “latest price per stock” pattern so downstream queries use a clean interface instead of duplicating complex logic.

Views re-scan on every read

Unlike materialized views, regular views offer no caching — BigQuery runs the full query and charges bytes scanned each time. For dashboard queries hit repeatedly throughout the day, consider a materialized view (CREATE MATERIALIZED VIEW) or a scheduled query that writes to a gold-layer table.

Create a view wrapping ROW_NUMBER deduplication logic

When multiple downstream consumers need the “latest price per stock” pattern and duplicating the ROW_NUMBER logic in each query is error-prone. It is typically triggered by identifying repeated use of the same deduplication subquery across notebooks or scheduled queries. GoogleSQL DDL (CREATE OR REPLACE VIEW) in stoxx_gold dataset. State-changing — creates or replaces a named view object. Requires bigquery.tables.create permission. The view stores no data — it re-executes the underlying query on every SELECT. Encapsulate the ROW_NUMBER deduplication pattern behind a clean interface so downstream queries can SELECT * FROM v_latest_prices without knowing the dedup logic.

Create a view that returns the most recent OHLCV row per stock using ROW_NUMBER deduplication.

CREATE OR REPLACE VIEW bq-wh-nb.stoxx_gold.v_latest_prices AS
SELECT symbol, date, `open`, high, low, `close`, volume
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
) sub
WHERE rn = 1;
symboldateopenhighlowclosevolume

The complex ROW_NUMBER pattern is now hidden behind a simple SELECT — downstream queries no longer need to know the dedup logic.

Query the view with a simple SELECT

Whenever you need the latest price per stock — the view hides the deduplication complexity. It is typically triggered by dashboard query, ad-hoc analysis, or any downstream consumer that needs the most recent OHLCV row per symbol. GoogleSQL SELECT against the view v_latest_prices. Read-only, but scans the full base table on every read (regular views are not cached). Bytes scanned = same as running the underlying ROW_NUMBER query directly. Demonstrate that the view simplifies consumption — one clean SELECT replaces the complex subquery pattern.

FieldSourceTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol
dateeurostoxx50_ohlcv.dateDATEMost recent trading date for this symbol
openeurostoxx50_ohlcv.openFLOAT64Opening price on the most recent trading day
higheurostoxx50_ohlcv.highFLOAT64Session high
loweurostoxx50_ohlcv.lowFLOAT64Session low
closeeurostoxx50_ohlcv.closeFLOAT64Closing price
volumeeurostoxx50_ohlcv.volumeINT64Shares traded

Query the view — the complex dedup logic is now hidden behind a simple SELECT.

SELECT * FROM bq-wh-nb.stoxx_gold.v_latest_prices ORDER BY `close` DESC
LIMIT 10

10 rows affected.

symboldateopenhighlowclosevolume
RMS.PA2026-03-121900.01918.51894.01906.018681
RHM.DE2026-03-121536.01588.01535.01551.5158741
ASML.AS2026-03-121194.81202.21187.81190.8128223
ADYEN.AS2026-03-12920.7933.4917.3925.727887
ARGX.BR2026-03-12629.0631.6625.6626.614083

Views | Cross-Layer Dashboard View

Join multiple tables into a single business-friendly view. Dashboards query this instead of raw tables.

Create a cross-layer dashboard view

When setting up or updating the dashboard query layer — a one-time DDL operation, re-run only if the schema changes. It is typically triggered by initial dashboard setup, or after adding new columns to the scoring pipeline that should be exposed in the dashboard. GoogleSQL DDL (CREATE OR REPLACE VIEW) in stoxx_gold. State-changing. The view joins scores_daily with index_dim, exposing a business-friendly interface for dashboard tools. Create a single cross-layer view that dashboards query instead of raw tables — encapsulates the score-to-dimension join and column formatting.

Create a cross-layer dashboard view joining gold scores with silver dimension metadata.

CREATE OR REPLACE VIEW bq-wh-nb.stoxx_gold.v_stock_dashboard AS
SELECT
    s.composite_rank AS `rank`,
    s.symbol,
    d.short_name,
    d.sector,
    d.country,
    s.current_price,
    ROUND(s.composite_score, 4) AS composite_score,
    ROUND(s.relative_value_score, 3) AS value_score,
    ROUND(s.momentum_score, 3) AS momentum_score,
    ROUND(s.index_weight * 100, 2) AS weight_pct,
    s._index,
    s.score_date
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;
ranksymbolshort_namesectorcountrycurrent_pricecomposite_scorevalue_scoremomentum_scoreweight_pct_indexscore_date

Query the dashboard view for the latest rankings

When refreshing the stock ranking dashboard or validating that the scoring pipeline produced expected results. It is typically triggered by daily dashboard refresh, portfolio review, or post-scoring-run verification. GoogleSQL SELECT against the v_stock_dashboard view. Read-only, but the underlying view re-scans base tables on every read. Filtered to euro_stoxx_50 and the latest score_date. Retrieve the latest ranked stock dashboard with composite scores, sub-scores, and index weights — the primary output consumed by portfolio managers.

FieldSource / ComputationTypeMeaning
rankscores_daily.composite_rankINT64Overall rank (1 = best composite score)
symbolscores_daily.symbolSTRINGTicker symbol
short_nameindex_dim.short_nameSTRINGCompany short name
sectorindex_dim.sectorSTRINGGICS sector
countryindex_dim.countrySTRINGCountry of primary listing
current_pricescores_daily.current_priceFLOAT64Price at time of scoring
composite_scoreROUND(composite_score, 4)FLOAT64Weighted composite score
value_scoreROUND(relative_value_score, 3)FLOAT64Relative value component
momentum_scoreROUND(momentum_score, 3)FLOAT64Momentum component
weight_pctindex_weight * 100FLOAT64 (%)Index weight as a percentage
_indexscores_daily._indexSTRINGIndex key
score_datescores_daily.score_dateDATEDate of the scoring run

Query the dashboard view for the latest Euro Stoxx 50 rankings.

SELECT * FROM bq-wh-nb.stoxx_gold.v_stock_dashboard
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')
ORDER BY `rank`
LIMIT 10

10 rows affected.

ranksymbolshort_namesectorcountrycurrent_pricecomposite_scorevalue_scoremomentum_scoreweight_pct_indexscore_date
1BNP.PABNP PARIBAS ACT.AFinancial ServicesFrance87.440.67961.4970.461.94euro_stoxx_502026-03-12
2VOW.DEVOLKSWAGEN AGConsumer CyclicalGermany92.850.57561.028-0.3820.93euro_stoxx_502026-03-12
3DTE.DEDEUTSCHE TELEKOM AGCommunication ServicesGermany32.550.4870.2260.7063.13euro_stoxx_502026-03-12
4TTE.PATOTALENERGIESEnergyFrance69.80.39130.5851.3072.95euro_stoxx_502026-03-12
5ABI.BRAB INBEVConsumer DefensiveBelgium62.760.38520.2510.5372.43euro_stoxx_502026-03-12

Stored Procedures

BigQuery supports stored procedures via CREATE OR REPLACE PROCEDURE with CALL invocation. Unlike SQL Server, BigQuery procedures offer no execution plan caching — they simply run statements sequentially. They are meant for multi-statement scripting blocks with control flow (IF, LOOP, BEGIN...EXCEPTION), not for parameterized reads, which are better expressed as table functions or CTE-with-params patterns.

Related pattern

Tools like dbt’s BigQuery adapter generate many of the parameterized query and view patterns shown below, removing the need to hand-write stored procedures for routine transforms.

Stored Procedures | Parameterized Queries

A stored procedure is precompiled SQL that lives in the database. BigQuery supports CREATE OR REPLACE PROCEDURE with CALL, but jupysql magic cannot execute CALL statements. BigQuery procedures also use EXECUTE IMMEDIATE for dynamic SQL.

The idiomatic BigQuery pattern for reusable parameterized logic is a CTE with a params row or a table function — not a stored procedure. Stored procedures are reserved for multi-statement scripting blocks with control flow (IF, LOOP, BEGIN...EXCEPTION).

Cross-engine comparison

SQL Server stored procedures compile and cache execution plans — a major performance feature. BigQuery procedures offer no plan caching; they simply execute statements sequentially. For parameterized reads, prefer table functions (CREATE TABLE FUNCTION) over procedures.

Parameterized top-N query with a CTE-based params row

When you need a reusable, parameterized read pattern without creating a stored procedure or table function. It is typically triggered by building a notebook cell or scheduled query that fetches top-N stocks by score for a given index — parameters are defined in the CTE rather than passed as procedure arguments. GoogleSQL CTE with a params row joined via CROSS JOIN (implicit comma syntax). Read-only. The params CTE acts as a single-row configuration table. This pattern avoids the jupysql limitation where DECLARE variables are not visible across cells. Demonstrate BigQuery’s idiomatic alternative to stored procedures for parameterized reads — change the params CTE values to reuse the same query for different indices or top-N limits.

FieldSource / ComputationTypeMeaning
symbolscores_daily.symbolSTRINGTicker symbol
scorescores_daily.composite_scoreFLOAT64Raw composite score (not rounded — full precision)
composite_rankscores_daily.composite_rankINT64Rank within the index
current_pricescores_daily.current_priceFLOAT64Price at time of scoring

Use a CTE with a params row to simulate a parameterized query — BigQuery’s idiomatic alternative to stored procedures for reads.

WITH params AS (
    SELECT 'euro_stoxx_50' AS index_key, 5 AS top_n
)
SELECT s.symbol, s.composite_score AS score,
       s.composite_rank, s.current_price
FROM `bq-wh-nb.stoxx_gold.scores_daily` s, params p
WHERE s._index = p.index_key
  AND s.score_date = (
      SELECT MAX(score_date) FROM `bq-wh-nb.stoxx_gold.scores_daily` WHERE _index = p.index_key
  )
ORDER BY s.composite_rank
LIMIT 5

5 rows affected.

symbolscorecomposite_rankcurrent_price
BNP.PA0.6795985859619491187.44
VOW.DE0.5756100520413311292.85
DTE.DE0.4870486370039222332.55
TTE.PA0.3912872052761238469.8
ABI.BR0.38521031359211527562.76

Stored Procedures | Error Handling with BEGIN…EXCEPTION

Production scripts wrap logic in BEGIN...EXCEPTION...END with explicit transactions. If anything fails inside the block, execution jumps to the EXCEPTION handler where you can roll back and log the error. This is BigQuery’s equivalent of SQL Server’s TRY/CATCH.

BEGIN...EXCEPTION...END Pattern

BigQuery uses BEGIN...EXCEPTION...END for error handling (not TRY/CATCH like SQL Server). Transactions wrap the DML so failures roll back the entire operation — no partial loads.

BEGIN…EXCEPTION error handling with explicit transaction

When writing production pipeline scripts that perform multi-statement DML and need atomic rollback on failure. It is typically triggered by building a load script that must either succeed entirely or roll back — partial loads are unacceptable. GoogleSQL scripting block with DECLARE, BEGIN TRANSACTION, COMMIT, ROLLBACK, and EXCEPTION WHEN ERROR. State-changing — the DML inside the block modifies data. Requires bigquery.jobs.create permission. Each DML statement counts against the 1,500/day quota. Demonstrate BigQuery’s error handling pattern — wrap DML in a transaction so failures trigger ROLLBACK and the error message is captured via @@error.message.

Wrap DML inside BEGIN...EXCEPTION...END with an explicit transaction and an error handler.

DECLARE index_key STRING DEFAULT 'euro_stoxx_50';
DECLARE rows_loaded INT64 DEFAULT 0;
 
BEGIN
    BEGIN TRANSACTION;
    SET rows_loaded = (
        SELECT COUNT(*)
        FROM `bq-wh-nb.stoxx_gold.scores_daily`
        WHERE _index = index_key);
    COMMIT TRANSACTION;
    SELECT CONCAT('Load completed: ',
        CAST(rows_loaded AS STRING), ' rows') AS status;
EXCEPTION WHEN ERROR THEN
    ROLLBACK TRANSACTION;
    SELECT @@error.message AS error_message;
END

1 rows affected.

error_message
Undeclared variable: rows_loaded

jupysql limitation — multi-statement scripts

The Undeclared variable: rows_loaded error occurs because jupysql sends each `{init: {‘theme’: ‘dark’, ‘themeVariables’: { ‘primaryColor’: ‘#292e42’, ‘primaryTextColor’: ‘#c0caf5’, ‘primaryBorderColor’: ‘#565f89’, ‘lineColor’: ‘#565f89’, ‘secondaryColor’: ‘#1a1b26’, ‘tertiaryColor’: ‘#24283b’, ‘noteTextColor’: ‘#c0caf5’, ‘noteBkgColor’: ‘#292e42’, ‘textColor’: ‘#c0caf5’, ‘fontSize’: ‘14px’ }}}%% flowchart TD A[Dimension attribute changed] B{Does history matter
for calculations?} B NO1[NO] B YES1[YES] NO1 |typo fix,
display name| C[SCD Type 1
Overwrite in place] YES1 |sector change,
index membership| D[SCD Type 2
Expire old + insert new] C E[UPDATE row directly] D F[Set is_current = FALSE,
valid_to = NOW on old row] F G[INSERT new row with
is_current = TRUE,
valid_from = NOW] style YES1 fill:#1f3b2d,stroke:#73d13d,color:#c0caf5 style NO1 fill:#4a1f24,stroke:#db4b4b,color:#c0caf5


> [!info] Cross-engine comparison
> 
>
> SCD patterns are engine-agnostic SQL — the same Type 1/Type 2 logic works in BigQuery and SQL Server. Firestore handles versioning differently: store historical snapshots as subcollections (`/company/{id}/history/{timestamp}`) or use a `versions` array field within the document.

> [!danger] SCD Type 1 Destroys History
> 
>
> SCD Type 1 Destroys History Permanently.
> SCD Type 1 overwrites in place -- once the old value is gone, it is unrecoverable unless you have a backup or the source system retains history. In financial pipelines, always default to SCD Type 2 for dimension attributes that affect calculations (sector, index membership, weighting). A sector change can retroactively alter historical portfolio returns if the dimension is Type 1.

> [!success] Safe Pattern
> 
>
> Use **SCD Type 2** for any attribute that affects historical calculations: expire the old row (`is_current = FALSE`, `valid_to = NOW()`) and insert a new row (`is_current = TRUE`, `valid_from = NOW()`). Reserve SCD Type 1 only for non-analytical corrections such as fixing a typo in a display name.

### Slowly Changing Dimensions | SCD Type 1 Overwrite

Simply UPDATE the row. History is lost. Use when you don't care about old values.
Example: fix a typo in a company name.

This simulation shows the before/after of an SCD Type 1 overwrite: ASML's sector changes from its current value to "Information Technology". In production, this would be a direct `UPDATE` statement.

#### Simulate an SCD Type 1 overwrite on dimension rows

When a non-analytical attribute needs correcting (e.g., fixing a typo in a company name) and history preservation is not required. It is typically triggered by A dimension attribute that does not affect historical calculations needs updating — a display name correction, a metadata fix, or a classification change that should apply retroactively. GoogleSQL SELECT with CASE expression against `stoxx_silver.index_dim`. Read-only simulation — in production, this would be an `UPDATE` statement. This query shows the before/after without modifying data. Demonstrate the SCD Type 1 pattern — in-place overwrite with no history. The `scd_action` column flags which rows would be modified.

| Field | Source / Computation | Type | Meaning |
|---|---|---|---|
| `symbol` | `index_dim.symbol` | STRING | Ticker symbol |
| `short_name` | `index_dim.short_name` | STRING | Company short name |
| `original_sector` | `index_dim.sector` | STRING | Current sector value before the overwrite |
| `updated_sector` | `CASE WHEN symbol = 'ASML.AS' THEN 'Information Technology' ELSE sector END` | STRING | Sector value after the overwrite — only ASML changes |
| `scd_action` | `CASE WHEN symbol = 'ASML.AS' THEN 'OVERWRITTEN' ELSE 'unchanged' END` | STRING | `OVERWRITTEN` = this row would be modified. `unchanged` = no change |

*Simulate an SCD Type 1 overwrite by flagging which rows would be updated in place and which remain unchanged.*

```sql
WITH original AS (
    SELECT symbol, short_name, sector, is_current
    FROM `bq-wh-nb.stoxx_silver.index_dim`
    WHERE _index = 'euro_stoxx_50' AND is_current = TRUE
)
SELECT symbol, short_name,
    sector AS original_sector,
    CASE WHEN symbol = 'ASML.AS' THEN 'Information Technology'
         ELSE sector END AS updated_sector,
    CASE WHEN symbol = 'ASML.AS' THEN 'OVERWRITTEN'
         ELSE 'unchanged' END AS scd_action
FROM original
ORDER BY symbol
LIMIT 10

10 rows affected.

symbolshort_nameoriginal_sectorupdated_sectorscd_action
ABI.BRAB INBEVConsumer DefensiveConsumer Defensiveunchanged
AD.ASKONINKLIJKE AHOLD DELHAIZE N.V.Consumer DefensiveConsumer Defensiveunchanged
ADS.DEadidas AGConsumer CyclicalConsumer Cyclicalunchanged
ADYEN.ASADYENTechnologyTechnologyunchanged
AI.PAAIR LIQUIDEBasic MaterialsBasic Materialsunchanged

Slowly Changing Dimensions | SCD Type 2 History Tracking

Expire the old row (is_current=0, valid_to=NOW) and insert a new row (is_current=1). This is how silver.index_dim works — it has valid_from, valid_to, is_current columns.

The stoxx_silver.index_dim table already implements SCD Type 2 with valid_from, valid_to, and is_current columns. Rows with is_current = TRUE and valid_to = NULL represent the current state.

Query SCD Type 2 validity ranges

When inspecting the history of dimension changes, or verifying that the SCD Type 2 mechanism is correctly expiring old rows and inserting new ones. It is typically triggered after a dimension load, to confirm that changed attributes produced the expected (expired old row + new current row) pair. Also useful for auditing historical membership. GoogleSQL SELECT against stoxx_silver.index_dim. Read-only. The table implements SCD Type 2 with valid_from (TIMESTAMP), valid_to (TIMESTAMP, NULL for current rows), and is_current (BOOL). Show the validity ranges for dimension rows — current rows have valid_to = NULL and is_current = TRUE. Historical rows show the period during which each attribute value was active.

FieldSource / ComputationTypeMeaning
symbolindex_dim.symbolSTRINGTicker symbol
short_nameindex_dim.short_nameSTRINGCompany name during the validity period
sectorindex_dim.sectorSTRINGGICS sector during the validity period
is_currentindex_dim.is_currentBOOLTrue = this is the active row. False = expired historical row
valid_fromCAST(valid_from AS DATE)DATEDate from which this row’s attributes were effective
valid_toCAST(valid_to AS DATE)DATE / NULLDate when this row was expired. None (NULL) = currently active

Query SCD Type 2 history: show valid_from / valid_to ranges for Euro Stoxx 50 dimension rows.

SELECT
    symbol, short_name, sector,
    is_current,
    CAST(valid_from AS DATE) AS valid_from,
    CAST(valid_to AS DATE) AS valid_to
FROM `bq-wh-nb.stoxx_silver.index_dim`
WHERE _index = 'euro_stoxx_50'
ORDER BY symbol, valid_from
LIMIT 10

10 rows affected.

symbolshort_namesectoris_currentvalid_fromvalid_to
ABI.BRAB INBEVConsumer DefensiveTrue2026-03-04None
AD.ASKONINKLIJKE AHOLD DELHAIZE N.V.Consumer DefensiveTrue2026-03-04None
ADS.DEadidas AGConsumer CyclicalTrue2026-03-04None
ADYEN.ASADYENTechnologyTrue2026-03-04None
AI.PAAIR LIQUIDEBasic MaterialsTrue2026-03-04None

Gap Detection & Gap Filling

Time-series data in financial pipelines frequently contains gaps — missing trading days due to market holidays, exchange closures, or ingestion failures. Detecting and classifying these gaps is a prerequisite for accurate signal computation, since undetected gaps distort rolling averages and return calculations. BigQuery’s LAG() function paired with DATE_DIFF() is the standard tool for gap detection.

Gap Detection & Gap Filling | Islands and Gaps

The classic SQL pattern: identify contiguous groups (islands) and missing periods (gaps) in a time series. Uses the difference between ROW_NUMBER and the date to group consecutive days.

LAG compares each date to its predecessor within the same symbol’s time series. A gap of more than 3 calendar days is flagged as unusual — normal weekends produce a 3-day gap (Friday → Monday), so anything larger indicates a holiday, data issue, or delisting event.

Detect calendar gaps with LAG and DATE_DIFF

After loading new data, or when investigating anomalies in rolling calculations (moving averages, volatility) that could be caused by hidden gaps. It is typically triggered by post-load validation, or debugging unexpected results in time-series analytics. GoogleSQL window functions LAG() and DATE_DIFF() against stoxx_silver.eurostoxx50_ohlcv. Read-only. Partitioned by symbol, ordered by date. Detect unusual gaps in the time series — any gap > 3 calendar days exceeds a normal weekend and may indicate a holiday, data issue, or delisting event that requires investigation.

FieldSource / ComputationTypeMeaning
symboleurostoxx50_ohlcv.symbolSTRINGTicker symbol
dateeurostoxx50_ohlcv.dateDATECurrent trading date
prev_dateLAG(date) OVER (PARTITION BY symbol ORDER BY date)DATEPrevious trading date (NULL for first row)
gap_daysDATE_DIFF(date, LAG(date), DAY)INT64Calendar days between current and previous trading date. 1 = consecutive weekday. 3 = Friday→Monday (normal weekend). > 3 = holiday or data gap
statusCASE WHEN gap_days > 3 THEN 'UNUSUAL GAP' ELSE 'normal' ENDSTRINGUNUSUAL GAP = gap exceeds normal weekend — investigate. normal = expected trading pattern

Detect time-series gaps: compare each date to the previous date using LAG and flag gaps > 3 days.

SELECT
    symbol, date,
    LAG(date) OVER (PARTITION BY symbol ORDER BY date) AS prev_date,
    DATE_DIFF(date, LAG(date) OVER (PARTITION BY symbol ORDER BY date), DAY) AS gap_days,
    CASE WHEN DATE_DIFF(date, LAG(date) OVER (PARTITION BY symbol ORDER BY date), DAY) > 3
         THEN 'UNUSUAL GAP' ELSE 'normal' END AS status
FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
WHERE symbol = 'ASML.AS' AND date >= '2025-01-01'
ORDER BY date DESC
LIMIT 10

10 rows affected.

symboldateprev_dategap_daysstatus
ASML.AS2026-03-122026-03-111normal
ASML.AS2026-03-112026-03-101normal
ASML.AS2026-03-102026-03-091normal
ASML.AS2026-03-092026-03-063normal
ASML.AS2026-03-062026-03-051normal

Deduplication Strategies

Duplicate rows in source data are one of the most common data quality issues in financial pipelines — broker feeds retry failed deliveries, ETL jobs re-run after failures, and UNION operations occasionally double-count rows. BigQuery’s ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) window function is the standard deduplication tool: assign rank 1 to the row to keep within each duplicate group, then filter or delete the rest.

Deduplication Strategies | ROW_NUMBER Pattern

The standard approach: assign ROW_NUMBER() within each duplicate group, keep rn = 1, delete the rest.

The simulation below uses UNION ALL to create an artificial duplicate, then applies ROW_NUMBER() partitioned by the natural key (symbol, date) to assign rn = 1 to the row to keep (highest volume wins). In production, filter to rn = 1 and write the deduplicated result to the target table.

Identify duplicates with ROW_NUMBER and tie-breaking

When deduplicating a table after detecting duplicate rows, or as part of a load validation step. It is typically triggered by quality check reveals copies > 1 for some (symbol, date) combinations, or a re-run of the ETL job may have double-loaded data. GoogleSQL CTEs with UNION ALL (to simulate a duplicate) and ROW_NUMBER() OVER (PARTITION BY symbol, date ORDER BY volume DESC). Read-only simulation. In production, filter to rn = 1 and write the deduplicated result to the target table using CREATE OR REPLACE TABLE ... AS SELECT. Identify duplicate rows using ROW_NUMBER with volume-based tie-breaking — the row with the highest volume is kept (rn = 1), others are flagged for removal.

FieldSource / ComputationTypeMeaning
symbolCTE raw_dataSTRINGTicker symbol
dateCTE raw_dataDATETrading date — part of the natural key for deduplication
closeCTE raw_dataFLOAT64Closing price (may differ between duplicate rows)
volumeCTE raw_dataINT64Trading volume — used as the tie-breaker (highest volume wins)
sourceString literal in UNION ALLSTRINGoriginal = real data. duplicate = simulated duplicate row
rnROW_NUMBER() OVER (PARTITION BY symbol, date ORDER BY volume DESC)INT641 = row to keep. > 1 = duplicate to remove
copiesCOUNT(*) OVER (PARTITION BY symbol, date)INT64Total rows sharing the same (symbol, date) key. 1 = unique. > 1 = duplicated

Simulate a duplicate row and identify it using ROW_NUMBER with volume-based tie-breaking.

WITH raw_data AS (
    SELECT symbol, date, `close`, volume, 'original' AS source
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
    WHERE symbol = 'ASML.AS' AND date >= '2026-03-10'
    UNION ALL
    SELECT symbol, date, `close` + 0.5, volume + 999, 'duplicate'
    FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
    WHERE symbol = 'ASML.AS' AND date = (SELECT MAX(date) FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv` WHERE symbol = 'ASML.AS')
),
numbered AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY symbol, date ORDER BY volume DESC) AS rn,
           COUNT(*) OVER (PARTITION BY symbol, date) AS copies
    FROM raw_data
)
SELECT symbol, date, ROUND(`close`, 2) AS `close`, volume, source, rn, copies
FROM numbered
WHERE copies > 1  -- only show the duplicated date
ORDER BY date DESC, rn
LIMIT 10

2 rows affected.

symboldateclosevolumesourcerncopies
ASML.AS2026-03-121191.3129222duplicate12
ASML.AS2026-03-121190.8128223original22

Execution Plans & Query Optimization

BigQuery’s query optimizer and execution engine are fully managed — there is no index selection or plan hint grammar for engineers to tune. The levers that matter are column selection (to reduce bytes scanned), partition pruning (to skip irrelevant data), clustering (to skip blocks within partitions), and avoidance of anti-patterns that defeat these optimizations.

BigQuery SELECT * Is Expensive

BigQuery SELECT * Scans All Columns and Bills Accordingly. BigQuery is columnar — you pay per column scanned, not per row. SELECT * on a 1 TB table costs the full 1 TB price even if you only need two columns. Always select specific columns. Use the query validator in the BigQuery console (top-right of the editor) to preview bytes scanned before running.

Safe Pattern

Always name the columns you need: SELECT symbol, date, close FROM table. Use bq query --dry_run or job_config.dry_run = True in Python to verify bytes scanned before execution. For exploratory work, filter on a partition column first (WHERE date = '2026-03-12') to limit the scan window.

For a broader look at controlling BigQuery spend through slot management and reservation strategies, see querying-and-cost-optimization.

Execution Plans & Query Optimization | Common Anti-Patterns

Anti-PatternProblemFix
WHERE EXTRACT(YEAR FROM date) = 2025Function on partition column prevents partition pruningWHERE date >= '2025-01-01' AND date < '2026-01-01'
SELECT *Reads all columns — BigQuery is columnar, so more columns = more bytes scanned = higher costSelect only needed columns
WHERE col = NULLAlways FALSE (NULL != NULL)WHERE col IS NULL
No partition filterScans all partitions on a partitioned tableAlways filter on partition column; use require_partition_filter
ORDER BY without LIMITFull sort across all slots — expensive on large result setsAlways pair ORDER BY with LIMIT
Cross-join with large tablesCartesian product multiplies bytes scannedEnsure at least one side is small; use JOIN instead

Both queries return the same count, but the sargable version enables partition pruning. The EXTRACT version wraps the column in a function, preventing BigQuery from using partition metadata to skip irrelevant partitions. The range filter version allows direct partition elimination.

Compare non-pruning (EXTRACT) vs pruning (range) predicates

When optimizing query cost on partitioned tables, or when teaching the difference between SARGable and non-SARGable predicates in BigQuery. It is typically triggered by observing unexpectedly high bytes scanned on a partitioned table — the most common cause is a function wrapping the partition column. GoogleSQL subqueries comparing two COUNT queries against stoxx_silver.eurostoxx50_ohlcv. Read-only. Both return the same count, but the EXTRACT version prevents partition pruning while the range predicate enables it. Demonstrate that wrapping the partition column in a function (EXTRACT(YEAR FROM date)) defeats partition pruning — the range predicate WHERE date >= '2025-01-01' AND date < '2026-01-01' produces the same result at lower scan cost.

FieldSource / ComputationTypeMeaning
bad_function_on_columnCOUNT(*) with EXTRACT(YEAR FROM date) = 2025INT64Row count using the non-SARGable predicate — scans all partitions
good_sargableCOUNT(*) with date >= '2025-01-01' AND date < '2026-01-01'INT64Same row count using a SARGable range predicate — enables partition pruning

Compare non-pruning (EXTRACT on column) vs pruning (range predicate) filters — same result, different scan cost.

SELECT
    (SELECT COUNT(*) FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
     WHERE EXTRACT(YEAR FROM date) = 2025) AS bad_function_on_column,
    (SELECT COUNT(*) FROM `bq-wh-nb.stoxx_silver.eurostoxx50_ohlcv`
     WHERE date >= '2025-01-01' AND date < '2026-01-01') AS good_sargable

1 rows affected.

bad_function_on_columngood_sargable
1269812698

Transaction Model

BigQuery’s transaction model is substantially simpler than SQL Server’s — every query runs under snapshot isolation automatically, and there are no configurable isolation levels or lock-based concurrency. Multi-statement transactions exist for atomic multi-DML operations but come with strict regional and time limits.

Transaction Model | BigQuery Snapshot Isolation

BigQuery does not expose configurable isolation levels like SQL Server. Every query runs under snapshot isolation automatically — each statement sees a consistent snapshot of the data as of the statement’s start time. There is no risk of dirty reads, non-repeatable reads, or phantom reads.

FeatureBigQuerySQL Server
Default isolationSnapshot (automatic)READ COMMITTED
Configurable levelsNoYes (5 levels)
Multi-statement transactionsBEGIN TRANSACTION ... COMMIT (scripting only)BEGIN TRAN ... COMMIT
Concurrent writersLast-writer-wins per rowLock-based concurrency
Deadlock riskNone (no row-level locks)Yes (lock escalation)

Multi-statement transactions have strict limits

BigQuery multi-statement transactions (using BEGIN TRANSACTION) are limited to tables in a single region, cannot span datasets in different locations, and must complete within 10 minutes. Each transaction counts toward the 1,500 DML/day quota per table. They are designed for short atomic operations — not long-running ETL pipelines.

Safe Pattern

For most pipeline patterns, single DML statements (INSERT, MERGE, DELETE) are already atomic — no explicit transaction needed. Use BEGIN TRANSACTION ... COMMIT only when you need multiple DML statements to succeed or fail as a unit (e.g., delete old partition + insert new data). Keep transactions short and targeted.

Cross-engine comparison

SQL Server offers five isolation levels (READ UNCOMMITTED through SERIALIZABLE) plus SNAPSHOT. BigQuery offers only snapshot isolation with no configuration. Firestore offers serializable transactions with a 500-document-per-transaction limit and optimistic concurrency (transaction retries on conflict).

Bulk Loading Patterns

Bulk ingestion is the performance-critical path for bronze-layer loads and silver/gold materializations. BigQuery offers several mechanisms with very different quotas, costs, and latency characteristics — from free batch loads via bq load to sub-second Storage Write API streaming.

Bulk Loading Strategies

BigQuery offers several ingestion mechanisms, each with different quotas, costs, and latency characteristics. The choice depends on data volume, frequency, and whether you need exactly-once semantics.

StrategyLatencyCostWhen
bq load (CLI)Seconds–minutesFree (batch)CSV/JSON/Parquet files from GCS or local disk
LOAD DATA (SQL)Seconds–minutesFree (batch)Same as bq load but executed as a SQL statement
INSERT INTO ... SELECTSecondsBytes scannedSmall-medium loads from other BQ tables or CTEs
Storage Write API (batch)SecondsFree (batch)Programmatic loads from Python/Java with exactly-once semantics
Storage Write API (committed)Sub-secondStreaming pricingHigh-frequency inserts with exactly-once guarantee
insertAll (legacy streaming)Sub-secondStreaming pricing ($0.05/GB)Real-time inserts — simpler API but at-least-once delivery

Batch loads are free

BigQuery does not charge for batch loading (bq load, LOAD DATA, Storage Write API in batch mode). You pay only for storage after the data lands. Streaming inserts (insertAll, Storage Write API in committed mode) are charged at $0.05/GB. For cost-sensitive pipelines, prefer batch loading on a schedule over streaming.

Pipeline pattern: load to staging table (batch) → validate with quality checks → MERGE to target → truncate staging. See data-loading-and-export for implementation details.

Data Lineage & Audit Columns

The stoxx datasets implement audit columns on every table to support data lineage tracking — when each row was ingested, computed, and last modified. These columns enable freshness checks, replay detection, and pipeline debugging across the medallion layers.

Data Lineage & Audit | Standard Audit Columns

Every table in the stoxx database has audit columns:

ColumnTypePurpose
_ingested_atTIMESTAMPWhen the row was loaded (bronze)
_scored_atTIMESTAMPWhen the score was computed (gold)
_computed_atTIMESTAMPWhen the performance was calculated
is_filledBOOLWhether the row was gap-filled (silver)
is_currentBOOLSCD Type 2 current flag (dimension)

A data freshness check across all medallion layers — if any table’s last_update is more than 1 day behind the current date, the pipeline may have stalled.

Check data freshness across all medallion layers

As part of the daily pipeline monitoring routine, or when investigating why downstream dashboards show stale data. It is typically triggered by scheduled monitoring check, or a user reports that dashboard data appears outdated. GoogleSQL UNION ALL of four MAX-timestamp queries, one per medallion-layer table. Read-only. Each query scans only the timestamp/date column used. If any last_update is more than 1 day behind the current date on a business day, the pipeline may have stalled. Provide a single-query freshness overview across all medallion layers — bronze ingestion, silver signals, gold scores, and gold index performance — to detect pipeline stalls at a glance.

FieldSource / ComputationTypeMeaning
tableString literalSTRINGFully qualified table name identifying which medallion-layer table the timestamp comes from
last_updateMAX(_ingested_at) or MAX(signal_date) or MAX(score_date) or MAX(perf_date)TIMESTAMP / DATEMost recent timestamp or date in the table. The column name varies by layer — _ingested_at for bronze, domain-specific date columns for silver/gold

Check data freshness across all four medallion layers — the latest timestamp per table.

SELECT '`bq-wh-nb.stoxx_bronze.eurostoxx50_ohlcv`' AS `table`, MAX(_ingested_at) AS last_update
FROM `bq-wh-nb.stoxx_bronze.eurostoxx50_ohlcv`
UNION ALL
SELECT '`bq-wh-nb.stoxx_silver.signals_daily`', MAX(signal_date) FROM `bq-wh-nb.stoxx_silver.signals_daily`
WHERE _index = 'euro_stoxx_50'
UNION ALL
SELECT '`bq-wh-nb.stoxx_gold.scores_daily`', MAX(score_date) FROM `bq-wh-nb.stoxx_gold.scores_daily`
WHERE _index = 'euro_stoxx_50'
UNION ALL
SELECT '`bq-wh-nb.stoxx_gold.index_performance`', MAX(perf_date) FROM `bq-wh-nb.stoxx_gold.index_performance`
WHERE _index = 'euro_stoxx_50'
ORDER BY last_update DESC

4 rows affected.

tablelast_update
`bq-wh-nb.stoxx_bronze.eurostoxx50_ohlcv`2026-03-12 12:45:00.021478
`bq-wh-nb.stoxx_silver.signals_daily`2026-03-12 00:00:00
`bq-wh-nb.stoxx_gold.scores_daily`2026-03-12 00:00:00
`bq-wh-nb.stoxx_gold.index_performance`2026-03-12 00:00:00

Partitioning Strategies

Table partitioning divides a BigQuery table into physically separate segments based on a column value (typically a date). Partition elimination allows the query optimizer to skip entire partitions that cannot satisfy the WHERE clause predicate, cutting scan cost by orders of magnitude on time-series data. Combined with clustering, partitioning is BigQuery’s primary performance lever.

Partitioning Strategies | When to Partition

Partition large tables (millions of rows) by a date column for:

  • Faster queries: partition elimination skips irrelevant months/years
  • Easier maintenance: rebuild one partition, not the whole table
  • Instant archival: SWITCH old partitions to archive table

The OHLCV tables (~65K rows each) are too small to benefit. In production with 100M+ rows, partition by year or month.

Querying without a partition filter scans ALL partitions

If a table is partitioned by date but your query has no WHERE date = ... or WHERE date BETWEEN ... filter, BigQuery scans every partition — negating the cost benefit entirely. You pay for the full table scan.

Safe Pattern

Always filter on the partition column in WHERE clauses. Enable require_partition_filter when creating the table to enforce this at the schema level: queries without a partition filter will fail with an error instead of silently scanning everything.

Create a partitioned and clustered table with require_partition_filter

When creating a new production table that will store time-series data, or migrating an existing table to a partitioned layout. It is typically triggered by table design phase for a new pipeline, or after observing that queries against an unpartitioned table are scanning excessive bytes. GoogleSQL DDL (CREATE TABLE IF NOT EXISTS) in the demo dataset. State-changing — creates a new table. PARTITION BY DATE_TRUNC(date, MONTH) splits storage by month. CLUSTER BY symbol sorts data within each partition. require_partition_filter = TRUE forces all queries to include a partition predicate. Create the recommended table layout for time-series OHLCV data — monthly partitioning for coarse scan elimination, clustering by symbol for fine-grained block pruning, and mandatory partition filter to prevent accidental full-table scans.

Create a partitioned and clustered OHLCV table with require_partition_filter enabled to enforce scan-cost discipline.

CREATE TABLE IF NOT EXISTS demo.ohlcv_partitioned (
    symbol STRING,
    date DATE,
    open FLOAT64,
    high FLOAT64,
    low FLOAT64,
    close FLOAT64,
    volume INT64
)
PARTITION BY DATE_TRUNC(date, MONTH)
CLUSTER BY symbol
OPTIONS(
    require_partition_filter = TRUE,
    description = 'Partitioned by month, clustered by symbol'
)

For partition pruning cost details and slot management, see querying-and-cost-optimization.

Cleanup

Drop all objects created in the demo dataset by this notebook. Running the cleanup leaves the project in its original state and makes the notebook safe to re-run from a clean slate.

Demo object cleanup

Every view, table function, and dataset created earlier must be dropped in reverse dependency order so the project is left in its original state.

Drop all demo objects and the demo dataset

At the end of a lab session, or before re-running the notebook from a clean slate. It is typically triggered by notebook execution complete — clean up demo objects to leave the project in its original state. Python client library (bigquery.Client). State-changing — drops table functions, views, and the demo dataset. Uses not_found_ok=True for idempotent re-runs. delete_contents=True removes all remaining objects inside the dataset before dropping it. Clean up all objects created by this notebook so the project is left in its original state and the notebook is safe to re-run from scratch.

Drop all demo objects and the demo dataset using the BigQuery Python client.

from google.cloud import bigquery
bq = bigquery.Client(project="bq-wh-nb")
 
for obj in [
    "bq-wh-nb.demo.fn_price_history",
    "bq-wh-nb.demo.v_latest_prices",
    "bq-wh-nb.demo.v_stock_dashboard",
]:
    bq.query(f"DROP TABLE FUNCTION IF EXISTS `{obj}`").result()
 
bq.delete_dataset("demo", delete_contents=True, not_found_ok=True)
print("Demo objects cleaned up")

Demo objects cleaned up

BigQuery Engineering Warnings

The table below lists the highest-impact BigQuery pitfalls associated with the database objects and patterns covered in this note. Each entry corresponds to a warning or danger callout earlier in the page.

TopicWarning
DML quota1,500 DML statements per day per table. Exceeding this silently stalls the pipeline with quotaExceeded errors.
View re-scan costRegular views re-execute the full query on every SELECT. Each read incurs the full scan cost of the base tables.
Missing partition filterWithout require_partition_filter, queries without a partition predicate silently scan all partitions at full cost.
Multi-statement transactionsLimited to a single region, must complete within 10 minutes, and count against the DML quota.
SCD Type 1 destroys historyIn financial pipelines, overwriting dimension attributes retroactively alters historical portfolio returns with no audit trail.
EXTRACT() on partition columnPrevents partition pruning. Rewrite as a range predicate on the raw date column.

BigQuery Engineering Recommendations

Standing guidance for designing, writing, and operating BigQuery database objects. Apply these as defaults unless a specific workload has a documented reason to deviate.

AreaRecommendation
Partition + clusterPartition by DATE_TRUNC(date, MONTH), cluster by symbol. Enable require_partition_filter = TRUE on all production tables.
Monitor DML usageQuery INFORMATION_SCHEMA.JOBS to track daily DML count per table. Alert at 80% of the 1,500 limit.
Batch loadingLoad to staging table (batch, free) → validate → MERGE to target → truncate staging. Use Storage Write API for programmatic loads.
Demo schema patternCreate experimental objects in a demo dataset. Include a cleanup block using Python client to ensure idempotent re-runs.
Dry-run before executionUse bq query --dry_run or job_config.dry_run = True to preview bytes scanned before running unfamiliar queries.
Audit columnsEvery table should have _ingested_at (bronze), _scored_at (gold), is_filled, and is_current for lineage tracking.

BigQuery Engineering Troubleshooting

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

SymptomLikely causeFix
quotaExceeded error on MERGEExceeded 1,500 DML/day quota for the tableReduce MERGE frequency. Switch to Storage Write API for high-frequency writes. Monitor via INFORMATION_SCHEMA.JOBS.
View query costs more than expectedView re-scans base tables on every readCreate a materialized view or a scheduled query that writes results to a gold-layer table.
Partition pruning not workingWHERE clause uses EXTRACT() or DATE_TRUNC() on the partition columnRewrite as WHERE date >= '2025-01-01' AND date < '2026-01-01'. Verify pruning in query execution details.
Undeclared variable error in notebookjupysql sends each cell as a standalone query — DECLARE in one cell is invisible to the nextThis is a notebook limitation, not a BigQuery bug. Use BigQuery Console or bq query for multi-statement scripts.
Table function returns stale dataThe underlying table was updated but the function re-reads current data on each call (no caching)This is expected behavior — table functions are always fresh. If data appears stale, check the source table timestamps.

BigQuery Engineering Cross-References

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