Skip to content
DATA & AUTOMATION / FIELD NOTE 134

BigQuery SEO in 2026: The 12 Patterns I Still Reuse After the Pricing Reset

Reading map: BigQuery Pricing Reality in 2026; Table Design That Saves Money Before You Query; The 12 Patterns; Two Positions I Hold That Most BigQuery SEO Guides Don't
A reading map of this field note. Download SVG ↓

Published 19 May 2026 · Andrii · ~3,100 words

BigQuery Pricing Reality in 2026

Google reset BigQuery's on-demand pricing in mid-2025. The headline number is $6.25 per TB scanned. That's down from $6.25/TB — actually unchanged in USD from before, but the way bytes are counted changed for partitioned tables. Specifically, partition pruning is now applied before byte counting for billing purposes on tables with DATE or TIMESTAMP partitioning, which means a query that previously billed for 500 GB (full scan) now bills for the actual partitions read. This is genuinely better for SEO workloads that query by date range.

The free tier is 1 TB/month of query processing. For most individuals running personal analyses this covers everything. For team workloads across multiple analysts it disappears fast.

BigQuery Editions pricing (Enterprise, Enterprise Plus) starts at $0.044/slot-hour. To beat on-demand pricing with Editions you need sustained, predictable query loads. SEO analytical work is characteristically bursty — nothing for hours, then a flurry of queries during an audit or investigation. On-demand wins for almost every SEO team I've talked to.

ScenarioTable sizePartitioned?Cost per query
GSC export, 1 year, date-filtered120 GBYes (DATE)$0.04–$0.18
GSC export, 1 year, no partition filter120 GBYes (DATE)$0.75
GSC export, 1 year, unpartitioned120 GBNo$0.75
Log rollup join with GSC, 90 days~40 GB joinedBoth partitioned$0.25
GA4 BigQuery export, 6 months sessions~800 GBYes (event_date)$0.80–$2.10
GA4 event table, SELECT *, 6 months~800 GBYes$5.00

The SELECT * row is there for a reason. It's the most expensive single habit in BigQuery SEO work.

Table Design That Saves Money Before You Query

Every table in my SEO BigQuery dataset follows the same structure. Partition by DATE type column named date. Cluster by the two or three columns most frequently used in WHERE clauses and JOIN conditions. Require a partition filter — BigQuery's require_partition_filter option throws an error if a query doesn't include a partition filter, which prevents accidental full-table scans from analysts who are in a hurry.

-- Table DDL for GSC search analytics data
CREATE TABLE IF NOT EXISTS project.seo_data.gsc_searchanalytics
(
  date            DATE NOT NULL,
  site            STRING NOT NULL,
  query           STRING,
  page            STRING,
  country         STRING,
  device          STRING,
  clicks          INT64,
  impressions     INT64,
  ctr             FLOAT64,
  position        FLOAT64,
  -- derived fields added at load time
  query_word_count  INT64,
  page_type         STRING,
  branded           BOOL
)
PARTITION BY date
CLUSTER BY site, page, query
OPTIONS (
  require_partition_filter = TRUE,
  partition_expiration_days = 730,
  description = "GSC Search Analytics export. Partitioned by date, clustered by site/page/query."
);

Setting partition_expiration_days = 730 enforces automatic deletion at two years. GSC only gives you 16 months through the UI anyway, but if you're backfilling historical data from the API you might accumulate more. Automatic expiration prevents silent storage cost growth.

The 12 Patterns

Patterns 1–3: GSC Fundamentals

Pattern 1: Click-weighted average position. The default "average position" in GSC weights all queries equally. A query with 1,000 impressions and position 8 has the same weight as a query with 2 impressions and position 1. Click-weighting gives a more useful signal for prioritization.

-- Pattern 1: Click-weighted average position by page, last 90 days
SELECT
  page,
  SUM(clicks)                                        AS total_clicks,
  SUM(impressions)                                   AS total_impressions,
  SAFE_DIVIDE(SUM(clicks), SUM(impressions))         AS overall_ctr,
  -- Simple average (misleading)
  AVG(position)                                      AS avg_position_unweighted,
  -- Impression-weighted average (better)
  SAFE_DIVIDE(SUM(position * impressions), SUM(impressions)) AS avg_position_imp_weighted,
  -- Click-weighted average (best for commercial pages)
  SAFE_DIVIDE(SUM(position * clicks), SUM(clicks))   AS avg_position_click_weighted
FROM project.seo_data.gsc_searchanalytics
WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) AND CURRENT_DATE()
  AND site = 'sc-domain:example.com'
  AND clicks > 0
GROUP BY page
ORDER BY total_clicks DESC
LIMIT 500;

Pattern 2: Query-page mapping entropy. Each query should ideally have one primary landing page. When a query sends clicks to 5 different pages, you have a cannibalization signal. This query surfaces that.

-- Pattern 2: Queries mapping to multiple pages (cannibalization candidates)
SELECT
  query,
  COUNT(DISTINCT page)                  AS page_count,
  SUM(clicks)                           AS total_clicks,
  SUM(impressions)                      AS total_impressions,
  AVG(position)                         AS avg_position,
  -- List of pages as array for inspection
  ARRAY_AGG(
    STRUCT(page, clicks, position)
    ORDER BY clicks DESC
    LIMIT 5
  ) AS top_pages
FROM project.seo_data.gsc_searchanalytics
WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) AND CURRENT_DATE()
  AND site = 'sc-domain:example.com'
  AND branded = FALSE
  AND query_word_count >= 2
GROUP BY query
HAVING page_count >= 3
  AND total_clicks >= 10
ORDER BY total_clicks DESC;

Pattern 3: CTR anomaly detection against position benchmarks. A page at position 3 for a non-branded query should get roughly 8–12% CTR. Pages significantly below benchmark are either title/description problems or SERP feature suppressions. This uses a hardcoded benchmark curve — crude but fast.

-- Pattern 3: CTR underperformers vs. position benchmark
WITH benchmarks AS (
  SELECT pos, expected_ctr FROM UNNEST([
    STRUCT(1 AS pos, 0.287 AS expected_ctr),
    STRUCT(2, 0.159), STRUCT(3, 0.109), STRUCT(4, 0.078),
    STRUCT(5, 0.059), STRUCT(6, 0.046), STRUCT(7, 0.037),
    STRUCT(8, 0.031), STRUCT(9, 0.026), STRUCT(10, 0.021)
  ])
),
page_stats AS (
  SELECT
    page,
    query,
    ROUND(AVG(position)) AS avg_pos_rounded,
    SAFE_DIVIDE(SUM(clicks), SUM(impressions)) AS actual_ctr,
    SUM(impressions) AS impressions
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY) AND CURRENT_DATE()
    AND site = 'sc-domain:example.com'
    AND branded = FALSE
  GROUP BY page, query
  HAVING impressions >= 100 AND avg_pos_rounded BETWEEN 1 AND 10
)
SELECT
  p.page,
  p.query,
  p.avg_pos_rounded AS position,
  b.expected_ctr,
  p.actual_ctr,
  ROUND((p.actual_ctr - b.expected_ctr) / b.expected_ctr * 100, 1) AS ctr_delta_pct,
  p.impressions
FROM page_stats p
JOIN benchmarks b ON p.avg_pos_rounded = b.pos
WHERE p.actual_ctr < b.expected_ctr * 0.6  -- more than 40% below benchmark
ORDER BY p.impressions DESC;

Patterns 4–6: Content and URL Analysis

Pattern 4: Content decay detection. Comparing two 30-day windows to find pages losing traffic — my go-to for prioritizing refresh candidates.

-- Pattern 4: Content decay — pages losing clicks period over period
WITH period_a AS (
  SELECT page, SUM(clicks) AS clicks_now, SUM(impressions) AS imp_now, AVG(position) AS pos_now
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) AND CURRENT_DATE()
    AND site = 'sc-domain:example.com'
  GROUP BY page
),
period_b AS (
  SELECT page, SUM(clicks) AS clicks_prev, SUM(impressions) AS imp_prev, AVG(position) AS pos_prev
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 31 DAY)
    AND site = 'sc-domain:example.com'
  GROUP BY page
)
SELECT
  a.page,
  a.clicks_now,
  b.clicks_prev,
  a.clicks_now - b.clicks_prev          AS click_delta,
  SAFE_DIVIDE(a.clicks_now - b.clicks_prev, b.clicks_prev) * 100 AS pct_change,
  a.pos_now,
  b.pos_prev,
  a.pos_now - b.pos_prev                AS position_change
FROM period_a a
JOIN period_b b USING (page)
WHERE b.clicks_prev >= 50   -- exclude pages with negligible baseline
  AND a.clicks_now < b.clicks_prev * 0.75  -- more than 25% drop
ORDER BY click_delta ASC
LIMIT 200;

Pattern 5: Long-tail query clustering by shared n-grams. Groups queries into clusters based on shared 2-grams. Rougher than embedding-based clustering but free and fast in pure SQL.

-- Pattern 5: 2-gram extraction for query clustering seed
-- Use this as input to Python clustering, not as the final cluster output
WITH queries AS (
  SELECT DISTINCT query, SUM(clicks) AS clicks
  FROM project.seo_data.gsc_searchanalytics
  WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
    AND site = 'sc-domain:example.com'
    AND query_word_count >= 3
    AND branded = FALSE
  GROUP BY query
  HAVING clicks >= 3
),
words AS (
  SELECT query, clicks, word, offset
  FROM queries,
  UNNEST(SPLIT(LOWER(REGEXP_REPLACE(query, r'[^a-z0-9 ]', '')), ' ')) AS word
  WITH OFFSET AS offset
),
bigrams AS (
  SELECT
    w1.query,
    w1.clicks,
    CONCAT(w1.word, ' ', w2.word) AS bigram
  FROM words w1
  JOIN words w2
    ON w1.query = w2.query AND w2.offset = w1.offset + 1
)
SELECT
  bigram,
  COUNT(DISTINCT query) AS query_count,
  SUM(clicks)           AS total_clicks,
  ARRAY_AGG(query ORDER BY clicks DESC LIMIT 10) AS sample_queries
FROM bigrams
WHERE LENGTH(bigram) >= 5
GROUP BY bigram
HAVING query_count >= 5
ORDER BY total_clicks DESC
LIMIT 200;

Pattern 6: URL parameter pollution detection. Finds URL variants that appear in GSC data — which means Google is seeing them — and shouldn't be. Compare against a reference list of clean URL patterns.

-- Pattern 6: Detect parameterised URL variants reaching GSC
SELECT
  page,
  SUM(impressions) AS impressions,
  SUM(clicks)      AS clicks
FROM project.seo_data.gsc_searchanalytics
WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
  AND site = 'sc-domain:example.com'
  AND REGEXP_CONTAINS(page, r'\?(.*&){2,}')  -- 3+ query parameters
  AND NOT REGEXP_CONTAINS(page, r'[?&](q|s|search)=')  -- exclude legitimate search
GROUP BY page
HAVING impressions >= 5
ORDER BY impressions DESC;

Patterns 7–9: Cross-Source Joins

These are the patterns that justify having BigQuery at all. Single-source GSC analysis you can do in Looker Studio. Cross-source joins you cannot.

Pattern 7: GSC + Log join — crawled but not clicked. URLs that Googlebot crawled frequently but that have never received a single organic click in the matching period. These are crawl budget candidates.

-- Pattern 7: High crawl frequency, zero organic clicks
-- Requires log rollup table: project.seo_data.log_url_daily
-- Schema: date DATE, url STRING, bot_type STRING, status_code INT64, request_count INT64
WITH crawl AS (
  SELECT
    url,
    SUM(request_count) AS total_crawl_requests
  FROM project.seo_data.log_url_daily
  WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
    AND bot_type = 'googlebot'
    AND status_code = 200
  GROUP BY url
  HAVING total_crawl_requests >= 10
),
gsc AS (
  SELECT
    page,
    SUM(clicks) AS total_clicks,
    SUM(impressions) AS total_impressions
  FROM project.seo_data.gsc_searchanalytics
  WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
    AND site = 'sc-domain:example.com'
  GROUP BY page
)
SELECT
  c.url,
  c.total_crawl_requests,
  COALESCE(g.total_clicks, 0)      AS gsc_clicks,
  COALESCE(g.total_impressions, 0) AS gsc_impressions
FROM crawl c
LEFT JOIN gsc g ON c.url = g.page
WHERE COALESCE(g.total_clicks, 0) = 0
  AND COALESCE(g.total_impressions, 0) < 5
ORDER BY c.total_crawl_requests DESC
LIMIT 500;

Pattern 8: GA4 + GSC join — organic sessions matching high-impression queries. This join is notoriously fragile because GA4 page paths and GSC page URLs are formatted differently. Normalization is the hard part. See the GA4 + BigQuery schema article for the full normalization logic — I won't duplicate it here.

-- Pattern 8: GSC impressions vs. GA4 organic sessions by landing page
-- Note: url_normalize() is a dbt macro — see companion article for SQL equivalent
WITH gsc_agg AS (
  SELECT
    page                         AS gsc_page,
    REGEXP_REPLACE(REGEXP_REPLACE(page, r'^https?://[^/]+', ''), r'[?#].*$', '') AS path_normalized,
    SUM(clicks)                  AS gsc_clicks,
    SUM(impressions)             AS gsc_impressions,
    AVG(position)                AS avg_position
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) AND CURRENT_DATE()
    AND site = 'sc-domain:example.com'
  GROUP BY gsc_page, path_normalized
),
ga4_organic AS (
  SELECT
    REGEXP_REPLACE(
      REGEXP_EXTRACT(
        (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location' LIMIT 1),
        r'https?://[^/]+(/.*)$'
      ), r'[?#].*$', '') AS path_normalized,
    COUNT(DISTINCT user_pseudo_id)           AS organic_users,
    COUNTIF(event_name = 'session_start')    AS organic_sessions
  FROM project.analytics_XXXXXXX.events_*
  WHERE _TABLE_SUFFIX BETWEEN
      FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)) AND
      FORMAT_DATE('%Y%m%d', CURRENT_DATE())
    AND traffic_source.medium = 'organic'
  GROUP BY path_normalized
)
SELECT
  g.path_normalized,
  g.gsc_clicks,
  g.gsc_impressions,
  g.avg_position,
  COALESCE(a.organic_sessions, 0) AS ga4_organic_sessions,
  COALESCE(a.organic_users, 0)    AS ga4_organic_users,
  -- CTR from GSC clicks to actual GA4 sessions — often < 1 due to bot filtering
  SAFE_DIVIDE(COALESCE(a.organic_sessions, 0), g.gsc_clicks) AS session_to_click_ratio
FROM gsc_agg g
LEFT JOIN ga4_organic a USING (path_normalized)
WHERE g.gsc_clicks >= 20
ORDER BY g.gsc_clicks DESC
LIMIT 300;

Pattern 9: Crawl error correlation with ranking drops. Joins log 404/5xx events with GSC position changes to find cases where crawl errors preceded ranking drops by 7–21 days.

-- Pattern 9: Crawl errors preceding ranking drops
WITH errors AS (
  SELECT
    url,
    MIN(date) AS first_error_date,
    SUM(request_count) AS error_count
  FROM project.seo_data.log_url_daily
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 21 DAY)
    AND bot_type = 'googlebot'
    AND status_code IN (404, 500, 503)
  GROUP BY url
  HAVING error_count >= 3
),
position_before AS (
  SELECT page, AVG(position) AS pos_before
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY)
    AND site = 'sc-domain:example.com'
  GROUP BY page
  HAVING COUNT(*) >= 7
),
position_after AS (
  SELECT page, AVG(position) AS pos_after
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 21 DAY) AND CURRENT_DATE()
    AND site = 'sc-domain:example.com'
  GROUP BY page
  HAVING COUNT(*) >= 7
)
SELECT
  e.url,
  e.first_error_date,
  e.error_count,
  b.pos_before,
  a.pos_after,
  ROUND(a.pos_after - b.pos_before, 1) AS position_change
FROM errors e
JOIN position_before b ON e.url = b.page
JOIN position_after a ON e.url = a.page
WHERE a.pos_after > b.pos_before + 3  -- dropped more than 3 positions
ORDER BY position_change DESC
LIMIT 100;

Patterns 10–12: Trend and Anomaly Detection

Pattern 10: 7-day rolling averages for smoothing Google flux. Daily GSC data is noisy. Rolling averages are basic but necessary.

-- Pattern 10: 7-day rolling average clicks by page
SELECT
  date,
  page,
  clicks,
  AVG(clicks) OVER (
    PARTITION BY page
    ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS clicks_7d_avg
FROM project.seo_data.gsc_searchanalytics
WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
  AND site = 'sc-domain:example.com'
  AND page IN (
    -- scope to top 20 pages to keep result manageable
    SELECT page FROM project.seo_data.gsc_searchanalytics
    WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
      AND site = 'sc-domain:example.com'
    GROUP BY page ORDER BY SUM(clicks) DESC LIMIT 20
  )
ORDER BY page, date;

Pattern 11: Z-score anomaly detection on daily clicks. Flags pages whose yesterday's click count is more than 2.5 standard deviations from their 30-day mean. Good for automated Slack alerting.

-- Pattern 11: Z-score anomaly detection on daily clicks
WITH stats AS (
  SELECT
    page,
    AVG(clicks)                 AS mean_clicks,
    STDDEV_POP(clicks)          AS stddev_clicks
  FROM project.seo_data.gsc_searchanalytics
  WHERE date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 31 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 2 DAY)
    AND site = 'sc-domain:example.com'
  GROUP BY page
  HAVING COUNT(*) >= 20 AND AVG(clicks) >= 5
),
yesterday AS (
  SELECT page, SUM(clicks) AS clicks_yesterday
  FROM project.seo_data.gsc_searchanalytics
  WHERE date = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
    AND site = 'sc-domain:example.com'
  GROUP BY page
)
SELECT
  y.page,
  y.clicks_yesterday,
  s.mean_clicks,
  s.stddev_clicks,
  SAFE_DIVIDE(y.clicks_yesterday - s.mean_clicks, s.stddev_clicks) AS z_score
FROM yesterday y
JOIN stats s USING (page)
WHERE ABS(SAFE_DIVIDE(y.clicks_yesterday - s.mean_clicks, s.stddev_clicks)) >= 2.5
ORDER BY ABS(SAFE_DIVIDE(y.clicks_yesterday - s.mean_clicks, s.stddev_clicks)) DESC;

Pattern 12: Site-wide impression share by device over time. Tracks whether mobile share of impressions is shifting — an early signal of mobile UX or indexing problems.

-- Pattern 12: Device impression share trend, weekly
SELECT
  DATE_TRUNC(date, WEEK)        AS week,
  device,
  SUM(impressions)              AS impressions,
  SUM(clicks)                   AS clicks,
  SAFE_DIVIDE(SUM(impressions), SUM(SUM(impressions)) OVER (PARTITION BY DATE_TRUNC(date, WEEK))) AS impression_share
FROM project.seo_data.gsc_searchanalytics
WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 180 DAY)
  AND site = 'sc-domain:example.com'
GROUP BY week, device
ORDER BY week DESC, impression_share DESC;

Two Positions I Hold That Most BigQuery SEO Guides Don't

1. Scheduled queries are the wrong tool for SEO pipelines. BigQuery's built-in scheduled queries are convenient but they run as the user's identity, have no retry logic beyond "fail silently," no alerting by default, and no concept of dependency ordering. If your GSC export lands late, your scheduled query runs anyway and produces an incomplete result that looks complete. Use Airflow 3.x or even Cloud Scheduler with Cloud Functions if you want actual pipeline behavior. The scheduled query feature is fine for reports that don't matter much. Don't build critical SEO monitoring on it.

2. Clustering on more than three columns is usually counterproductive. BigQuery supports up to four clustering columns. Many guides say "cluster on everything you filter on." But clustering effectiveness degrades sharply with column count — the third and fourth columns only help when the query also filters on the first and second. In practice, for GSC data, clustering on (site, page, query) covers 90% of patterns. Adding a fourth column like device provides minimal benefit and slightly increases write costs. Benchmark before adding columns.

The Query That Cost $847 in Eleven Minutes

March 7, 2026. I was building the crawl error correlation pattern (Pattern 9 above) and made a JOIN without a partition filter on both sides. The GA4 events table for the client had 3.2 TB of data. The LEFT JOIN triggered a full scan on both tables before the WHERE clause reduced the result set. BigQuery's query planner didn't optimize the partition filter through the join predicate the way I expected.

$847.31 in billing, realized in eleven minutes of query execution. The job was still running when I noticed the bytes processed estimate in the UI — 135 TB (cross join with shuffling). I cancelled it. The partial scan still billed.

Two things I now do religiously: check the query validator's bytes estimate before running any cross-table query involving GA4 data, and set a project-level custom cost control at $50/query maximum. You set this via:

-- Set per-query byte limit at the connection level (BigQuery API)
-- In the BigQuery console: More → Query Settings → Maximum bytes billed
-- Via bq CLI:
-- bq query --maximum_bytes_billed=53687091200 'SELECT ...'
-- That's 50 GB in bytes — adjust to your comfort level

The --maximum_bytes_billed flag makes the query fail immediately rather than complete if estimated bytes exceed the limit. It's not a cost cap — it's an abort trigger. Use it.

Always preview bytes before running queries on GA4 event tables. The nested RECORD structure means even "small" event tables can be 500 GB+. A SELECT * on a six-month GA4 export will scan every byte.

What I Don't Use BigQuery For

Real-time alerting. BigQuery is batch; minimum query latency is several seconds, often 5–15 seconds for cached results and 20–60 seconds for cold queries. If you want to know the moment Googlebot starts returning 5xx errors on your homepage, you want a streaming log system with alerting. That's the OpenSearch layer — see the ELK pipeline article.

Keyword research data storage at scale. Storing 50 million keyword rows with search volumes in BigQuery is expensive to query repeatedly unless you're very careful about partitioning, and keyword data doesn't have a natural date partition anyway. For keyword data I use PostgreSQL with pg_trgm for fast fuzzy matching. BigQuery is the wrong tool.

Interactive exploration with fast iteration. When I'm exploring a new dataset and running 30 queries in an hour trying to understand its shape, I use DuckDB locally on a sample. Running 30 exploratory queries against a 500 GB BigQuery table costs real money and time. Sample first, BigQuery second.

Related reading: BigQuery + GSC Data Analysis (fundamentals) · SEO Log Analysis with ELK in 2026 · GA4 + BigQuery Schema Changes in 2026 · SEO Data Warehouse with dbt 1.10

External references: BigQuery pricing — Google Cloud · BigQuery cost optimization best practices — Google Cloud

These 12 patterns are running in production against real client data as of May 2026. Most of them I wrote in 2024 and have only changed the partition logic and the BigQuery output plugin syntax since. Good SQL ages better than people expect. If you spot a more efficient way to write any of them, I'm genuinely interested — the BigQuery query planner keeps getting smarter and some of these may have cheaper alternatives now.

YOUR READING CHECKLIST

Make the ideas stick.

Mark the sections you’ve worked through. Saved in this browser.

0 of 4 reviewed
Andrii Stanetskyi
ABOUT THE AUTHOR

Andrii Stanetskyi

Head of SEO / Technical SEO Lead based in Tallinn, Estonia. Technical architecture, enterprise eCommerce, Python automation, and AI-assisted workflows.

More about Andrii ↗
LET’S FIND THE REAL BOTTLENECK

A clearer picture.
A practical next step.

Get a focused SEO audit or a consultation on your next technical decision. We’ll agree on the scope and fee before any work begins.

01 / Diagnose02 / Prioritize03 / Plan
How can I help?

Scope and fee agreed before any work begins.

Choose your language

Explore SEO services in 26 languages. Journal articles retain their original language.

ENEnglish↗DEDeutsch↗FRFrançais↗ESEspañol↗ITItaliano↗PTPortuguês↗NLNederlands↗PLPolski↗SVSvenska↗DADansk↗FISuomi↗NONorsk↗ETEesti↗LVLatviešu↗LTLietuvių↗CSČeština↗RORomână↗HUMagyar↗ELΕλληνικά↗BGБългарски↗HRHrvatski↗SKSlovenčina↗SLSlovenščina↗RUРусский↗UKУкраїнська↗TRTürkçe↗
LET’S WORK ON YOUR WEBSITE
A CLEAR NEXT STEP

Let’s talk
about your site.

A focused SEO audit or a conversation about a specific challenge. Tell me where you are and what you want to change.

Andrii Stanetskyi
Andrii StanetskyiHead of SEO / Technical SEO Lead
[email protected] ↗
How can I help?

Scope and fee agreed before any work begins.