BigQuery Reference¶
Google Cloud's serverless data warehouse — architecture, loading, partitioning, cost control, and security.
Last reviewed · Download PDF
Prerequisites: SQL · Cloud Storage
Related: Snowflake · Amazon Redshift · Data Modeling · Cost Optimization · Glossary
Overview¶
Challenge: Running a warehouse usually means sizing clusters, tuning indexes, and paying for idle capacity. Teams want to query terabytes with SQL without managing infrastructure.
Solution: BigQuery is a serverless warehouse: storage and compute are separate, there are no clusters to manage, and each query is executed on a shared pool of compute units called slots. Compute is billed either per byte scanned (on-demand) or per slot capacity (editions with autoscaling), and storage is billed separately.
SQL (console · bq CLI · client libraries · BI tools)
│
Query engine (Dremel) — work split across thousands of slots
│
Distributed shuffle ──── Colossus storage (columnar, replicated)
│
Native tables · external tables (Cloud Storage, BigLake / Iceberg) · streaming ingestion
Relevance to data engineering: BigQuery's cost and performance depend almost entirely on how much data each query reads, so partitioning, clustering, and query discipline matter more than any server tuning.
flowchart LR
Q["SQL query"] --> DR["Dremel execution<br/>thousands of slots"]
DR <-->|"Jupiter network"| CS[("Colossus<br/>columnar storage")]
L["Load, streaming,<br/>external tables"] --> CS
DR --> R["Result<br/>billed by bytes scanned<br/>or slot time"]
On this page
Basic - Resource Hierarchy - Querying - Nested and Repeated Data
Intermediate - Loading Data - Partitioning and Clustering - DML and MERGE - Python Client
Advanced - Pricing Models and Cost Control - Performance - Time Travel, Snapshots, and Clones - Security and Governance
Reference - Common Pitfalls - Cheat Sheet - Interview Questions - Further Reading
Resource Hierarchy¶
Organization
└── Project (billing, quotas, IAM)
└── Dataset (a location: US, EU, or a region; default table settings)
└── Tables · views · materialized views · routines (UDFs, procedures) · models
- Fully qualified name:
`project.dataset.table` - A dataset's location is fixed at creation; queries can only join datasets in the same location
- Access is granted with IAM on the project, dataset, or table
CREATE SCHEMA IF NOT EXISTS analytics
OPTIONS (location = "EU", default_table_expiration_days = NULL);
Querying¶
BigQuery uses GoogleSQL (standard SQL). Most of the SQL guide applies directly.
-- Latest record per key with QUALIFY
SELECT *
FROM `my-project.analytics.orders`
WHERE order_date >= "2024-03-01"
QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) = 1;
-- Useful functions
SELECT
DATE_TRUNC(order_date, MONTH) AS month,
SAFE_DIVIDE(SUM(refunds), SUM(revenue)) AS refund_rate, -- NULL instead of divide-by-zero
APPROX_COUNT_DISTINCT(customer_id) AS approx_customers, -- fast, ~1% error
COUNTIF(status = "cancelled") AS cancelled
FROM `my-project.analytics.orders`
GROUP BY month;
-- Parameterized query (from client libraries)
SELECT * FROM `my-project.analytics.orders` WHERE order_date = @run_date;
Before running an expensive query: check the estimated bytes in the console, or run bq query --dry_run.
Nested and Repeated Data¶
BigQuery stores nested (STRUCT) and repeated (ARRAY) fields natively — often better than joining child tables.
CREATE TABLE analytics.orders_nested (
order_id STRING,
customer STRUCT<id STRING, country STRING>,
line_items ARRAY<STRUCT<sku STRING, quantity INT64, price NUMERIC>>,
order_ts TIMESTAMP
);
-- Explode line items into rows
SELECT o.order_id, li.sku, li.quantity * li.price AS line_total
FROM analytics.orders_nested AS o,
UNNEST(o.line_items) AS li
WHERE o.customer.country = "DE";
-- Aggregate back into an array
SELECT customer.id, ARRAY_AGG(order_id ORDER BY order_ts DESC LIMIT 5) AS last_orders
FROM analytics.orders_nested
GROUP BY customer.id;
-- Semi-structured JSON column
SELECT JSON_VALUE(properties, "$.campaign") AS campaign
FROM analytics.events;
Loading Data¶
| Method | Use for | Notes |
|---|---|---|
Batch load jobs (bq load, LOAD DATA) |
Files in Cloud Storage | Free to load (you pay for storage) |
| Storage Write API | Streaming and high-throughput programmatic writes | Exactly-once semantics with committed streams |
| External tables / BigLake | Query files in place (Parquet, ORC, Avro, CSV, JSON, Iceberg) | No load step; performance depends on file layout |
| BigQuery Data Transfer Service | Scheduled imports from SaaS and other clouds | Managed connectors |
| Datastream | CDC from operational databases | Near-real-time replication |
-- Load Parquet files from Cloud Storage
LOAD DATA INTO analytics.orders
FROM FILES (
format = "PARQUET",
uris = ["gs://my-lake/orders/order_date=2024-03-15/*.parquet"]
);
-- External table over files in Cloud Storage
CREATE EXTERNAL TABLE raw.clickstream
OPTIONS (format = "PARQUET", uris = ["gs://my-lake/clickstream/*.parquet"]);
bq load --source_format=PARQUET analytics.orders "gs://my-lake/orders/order_date=2024-03-15/*.parquet"
bq load --source_format=CSV --skip_leading_rows=1 --autodetect raw.customers gs://my-lake/customers.csv
Partitioning and Clustering¶
CREATE TABLE analytics.events (
event_id STRING NOT NULL,
user_id STRING,
event_type STRING,
event_ts TIMESTAMP,
properties JSON
)
PARTITION BY DATE(event_ts) -- one partition per day
CLUSTER BY user_id, event_type -- up to 4 columns, sorted within each partition
OPTIONS (
partition_expiration_days = 730, -- drop partitions older than 2 years
require_partition_filter = TRUE -- reject queries without a partition filter
);
| Partitioning type | Syntax | Use for |
|---|---|---|
| Time-unit column | PARTITION BY DATE(ts) / TIMESTAMP_TRUNC(ts, HOUR) |
Event and transaction data (most common) |
| Ingestion time | PARTITION BY _PARTITIONDATE |
Data without a reliable event timestamp |
| Integer range | PARTITION BY RANGE_BUCKET(customer_id, GENERATE_ARRAY(0, 1000000, 10000)) |
Tables filtered by numeric ranges |
- Partitioning prunes whole partitions and makes bytes-scanned estimates accurate before a query runs
- Clustering sorts data within partitions so filters and aggregations on clustered columns read fewer blocks; BigQuery re-clusters automatically at no charge
- Keep partitions reasonably large (avoid hourly partitions on small tables); there is a per-table partition limit
DML and MERGE¶
MERGE analytics.customers AS t
USING staging.customers_changes AS s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.is_deleted THEN DELETE
WHEN MATCHED THEN UPDATE SET email = s.email, country = s.country, updated_at = s.updated_at
WHEN NOT MATCHED AND NOT s.is_deleted THEN
INSERT (customer_id, email, country, updated_at)
VALUES (s.customer_id, s.email, s.country, s.updated_at);
- DML is billed by bytes scanned (on-demand) like queries — filter on the partition column in the
ON/WHEREclause - Prefer batching changes into periodic
MERGEs over many small single-row updates - Multi-statement transactions are supported:
BEGIN TRANSACTION; ... COMMIT TRANSACTION;
Python Client¶
from google.cloud import bigquery
client = bigquery.Client(project="my-project") # uses Application Default Credentials
job_config = bigquery.QueryJobConfig(
query_parameters=[bigquery.ScalarQueryParameter("run_date", "DATE", "2024-03-15")],
maximum_bytes_billed=50 * 1024**3, # fail instead of scanning more than 50 GiB
)
sql = """
SELECT customer_id, SUM(amount) AS revenue
FROM `my-project.analytics.orders`
WHERE order_date = @run_date
GROUP BY customer_id
"""
df = client.query(sql, job_config=job_config).to_dataframe()
# Load a DataFrame into a table (replace the day's partition)
load_config = bigquery.LoadJobConfig(write_disposition="WRITE_TRUNCATE")
client.load_table_from_dataframe(
df, "my-project.analytics.daily_revenue$20240315", job_config=load_config
).result()
The $YYYYMMDD partition decorator with WRITE_TRUNCATE replaces a single partition — an idempotent daily load.
Pricing Models and Cost Control¶
| Model | Billed on | Good for |
|---|---|---|
| On-demand | Bytes processed per query (first TiB per month free) | Unpredictable or light workloads |
| Capacity (editions: Standard, Enterprise, Enterprise Plus) | Slot-hours, with autoscaling and optional commitments | Steady, heavy workloads; predictable spend |
| Storage | Active vs long-term (not modified for 90 days, cheaper); logical or physical (compressed) billing | Every table |
Prices vary by region and change over time — check the current pricing page.
Cost controls
- require_partition_filter = TRUE on large partitioned tables
- maximum_bytes_billed on queries and jobs; custom quotas per user or project
- Select only needed columns — SELECT * reads every column
- Materialized views or summary tables for dashboards
- Monitor spend with INFORMATION_SCHEMA.JOBS:
SELECT user_email,
COUNT(*) AS queries,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 2) AS tib_billed
FROM `region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
AND job_type = "QUERY"
GROUP BY user_email
ORDER BY tib_billed DESC;
Performance¶
- Read less: filter on partition and clustering columns; select only needed columns
- Filter and aggregate early, before joins; put the largest table first in joins so smaller tables can be broadcast
- Avoid
ORDER BYwithoutLIMITon large results, self-joins that could be window functions, and JavaScript UDFs in hot paths - Pre-compute with materialized views (incrementally maintained) or scheduled summary tables
- BI Engine caches data in memory for sub-second dashboard queries
- Search indexes speed up needle-in-haystack lookups on text/log columns
- Inspect the query execution graph (stages, slot time, bytes shuffled, spilled) to find bottlenecks
Time Travel, Snapshots, and Clones¶
-- Query a table as it was an hour ago (time travel window: 2–7 days, default 7)
SELECT * FROM analytics.orders
FOR SYSTEM_TIME AS OF TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR);
-- Restore after a bad write
CREATE OR REPLACE TABLE analytics.orders AS
SELECT * FROM analytics.orders
FOR SYSTEM_TIME AS OF TIMESTAMP("2024-03-15 06:00:00+00");
-- Read-only snapshot (pay only for changed data)
CREATE SNAPSHOT TABLE backups.orders_20240315 CLONE analytics.orders;
-- Writable, zero-copy clone for development
CREATE TABLE dev.orders CLONE analytics.orders;
After time travel expires, a 7-day fail-safe period allows recovery only through Google Cloud support.
Security and Governance¶
| Control | Mechanism |
|---|---|
| Dataset / table access | IAM roles (roles/bigquery.dataViewer, dataEditor, jobUser, ...) |
| Share query results, not base tables | Authorized views and authorized datasets |
| Row-level security | Row access policies |
| Column-level security | Policy tags (Data Catalog taxonomies) + dynamic data masking |
| Encryption | Google-managed by default; customer-managed keys (CMEK) available |
| Network perimeter | VPC Service Controls |
| Audit | Cloud Audit Logs (admin activity and data access) |
CREATE ROW ACCESS POLICY emea_only
ON analytics.orders
GRANT TO ("group:emea-analysts@example.com")
FILTER USING (region = "EMEA");
Common Pitfalls¶
| Pitfall | Symptom | Fix |
|---|---|---|
SELECT * on wide tables |
Large bills for small questions | Select only needed columns; LIMIT does not reduce bytes scanned |
| Queries without a partition filter | Full-table scans | require_partition_filter; filter directly on the partition column |
| Wrapping the partition column in a function | No pruning | Filter the raw column (event_ts >= ...) or partition by the expression you filter on |
| Many single-row DML statements | Slow pipelines, quota errors, high cost | Batch changes and MERGE periodically |
| Datasets in different locations | Joins fail | Choose locations deliberately; keep related datasets together |
| Legacy streaming inserts for high-volume ingestion | Higher cost, weaker guarantees | Storage Write API |
| Over-partitioning small tables | Partition limits and overhead | Partition by day/month only when tables are large; cluster instead |
| No cost guardrails | One exploratory query costs more than a month of pipelines | maximum_bytes_billed, quotas, dry runs, spend monitoring |
Sharded tables (events_20240315, ...) |
Slow wildcard queries, schema drift | One partitioned table |
Cheat Sheet¶
| Task | Command / SQL |
|---|---|
| Estimate cost | bq query --use_legacy_sql=false --dry_run 'SELECT ...' |
| Run a query | bq query --use_legacy_sql=false 'SELECT ...' |
| Show schema | bq show --schema --format=prettyjson project:dataset.table |
| Load files | bq load --source_format=PARQUET dataset.table "gs://bucket/path/*.parquet" |
| Replace one partition | Write to table$YYYYMMDD with WRITE_TRUNCATE |
| Partition + cluster | PARTITION BY DATE(ts) CLUSTER BY a, b |
| Explode array | FROM t, UNNEST(t.items) AS item |
| Safe math | SAFE_DIVIDE(a, b) · SAFE_CAST(x AS INT64) |
| Latest per key | QUALIFY ROW_NUMBER() OVER (PARTITION BY k ORDER BY ts DESC) = 1 |
| Time travel | FOR SYSTEM_TIME AS OF TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR) |
| Dev copy | CREATE TABLE dev.t CLONE prod.t |
| Spend by user | `region-xx`.INFORMATION_SCHEMA.JOBS_BY_PROJECT → total_bytes_billed |
Transformation tools that target BigQuery: SQL in scheduled queries, Dataform (native), dbt, SQLMesh
Interview Questions¶
Q: How is BigQuery's architecture different from a traditional warehouse? A: It is serverless and separates storage from compute. Data lives in Colossus in a columnar format; queries run on Dremel, which splits work across many slots (units of compute) connected by a fast network and a distributed shuffle. There are no clusters to size or indexes to tune — capacity comes from a shared pool (on-demand) or from reserved, autoscaling slots (editions).
Q: What is the difference between partitioning and clustering in BigQuery? A: Partitioning splits a table into segments (usually by day) so queries filtering on the partition column skip whole partitions, and the bytes to be scanned are known before the query runs. Clustering sorts data within each partition by up to four columns, so filters and aggregations on those columns read fewer storage blocks; its savings are only known after execution. Use both: partition by date, cluster by the most common secondary filters.
Q: How would you control BigQuery costs for a large analytics team?
A: Enforce partition filters on large tables, set maximum_bytes_billed and per-user or per-project quotas, and teach column selection (no SELECT *; LIMIT doesn't reduce cost). Pre-aggregate for dashboards with materialized views or BI Engine. Monitor INFORMATION_SCHEMA.JOBS for the most expensive users and queries. For steady heavy usage, compare on-demand with capacity pricing (editions with autoscaling and commitments), and separate workloads with reservations.
Q: How do you load data into BigQuery idempotently?
A: Load each run into a specific partition with WRITE_TRUNCATE (using the table$YYYYMMDD decorator or a MERGE scoped to that partition), so reruns replace rather than duplicate data. For upserts, stage the batch and MERGE on keys. For streaming, use the Storage Write API with committed streams and offsets for exactly-once writes, or deduplicate by event ID downstream.
Q: When would you use nested and repeated fields instead of separate tables?
A: When child records are always accessed with their parent — like order line items or event properties. Storing them as ARRAY<STRUCT<...>> keeps them co-located, avoids large joins, and reduces shuffle. Use separate tables when the child data is large, queried independently, or updated on its own.
Further Reading¶
- BigQuery documentation
- Partitioned tables and clustered tables
- Optimize query computation
- BigQuery pricing
- Google BigQuery: The Definitive Guide — Valliappa Lakshmanan & Jordan Tigani (O'Reilly)
Previous: Snowflake · Next: Amazon Redshift · Back to: Index