GA4, BigQuery and your ad platform all disagree. Here's what's worth reconciling.

Google doesn't expect the two surfaces to reconcile on the parts it adds. Run the one comparison that can, name the gaps that can't, and close the ticket.

Last updated

Somebody asked you why the numbers differ, and the job has no finish line

It starts with two figures on the same screen. Last month’s users in the GA4 interface, last month’s users out of the BigQuery export, and a gap between them that nobody can explain. Someone senior asks which one goes in the deck. So you go looking for the bug.

There is no bug, and Google says so in its own developer documentation: “for a large number of use cases, the standard reporting surfaces and the BigQuery export data aren’t expected to be reconcilable when it comes to these value addition parts.” The scope on that sentence is the whole finding. Google isn’t saying the interface and the export never agree. It’s saying they don’t agree on what GA4 adds on top of the raw events: sampling, estimation, Google signals, modeling. And it doesn’t leave you there. If both are internally consistent and match what you’re collecting, “you should be good to go.”

The write-ups that quote it go on to list the reasons the numbers differ and stop there, so you finish the article holding two numbers and a pile of explanations, which is one more thing to carry than you started with.

What’s missing is a stopping condition. A reconciliation without one runs until somebody gets bored, and that is the whole reason this job swallows a week and produces a shrug. The version worth running has a defined end: one comparison that can actually succeed, a tolerance to accept on it, and a short list of differences to name and stop investigating.

Two of the three numbers you’re comparing are estimates

One fact decides whether your reconciliation can terminate, and it’s arithmetic rather than anything you can configure.

Events are counted in both systems. A purchase event is a row in BigQuery and an increment in GA4, and both are doing plain counting. Users and sessions are handled differently. GA4 estimates them using an algorithm called HyperLogLog++, which trades a little accuracy for the ability to answer distinct-count questions fast across enormous datasets. Google documents the trade openly: HLL++ estimates cardinality for “common metrics including Active Users and Sessions,” and at a 95% confidence interval the precision on session count “might be” ±1.63%. The hedge is Google’s own, and it belongs in any number you repeat.

Sit with what that means for the task you were handed. Suppose your property is perfectly configured, your export is complete, your filters match on both sides and your timezones agree. GA4 and BigQuery will still disagree slightly on sessions, because one of them is estimating and the other is counting. No setting closes that gap. A reconciliation aimed at users or sessions is a search for a defect in a place where a difference is guaranteed by design.

So the first decision is a choice of metric, made before you write a single query. Reconcile on an event count. Treat any user or session comparison as a sanity check with a percentage-point tolerance attached, never as a pass-fail test.

Check the export has no holes in it

An export with missing days produces a discrepancy that looks like a configuration fault and isn’t one, so this comes before any SQL. Three things can go wrong on the export side, and all three are quick to check.

The daily cap comes first. Standard properties have a daily BigQuery export limit of one million events, and Google is direct about the consequence of crossing it: “if your property consistently exceeds the export limit, the daily BigQuery export will be paused and previous days’ exports will not be reprocessed.” That gap is permanent. A mid-size retailer running a Ramadan campaign can cross a million events on a day it never comes close to in a normal week, and the export can pause before anyone reads the warning email. List the daily tables in your dataset and look for missing dates.

Switching to streaming trades the cap for a different weakness. Streaming export carries no volume limit, which is why it’s the usual escape hatch, and Google describes it as “a best-effort operation” that “may not include all data” because of late events and failed uploads. Reconciling the interface against a streaming export means reconciling against something with a documented tolerance for incompleteness.

And nothing exists before the link. Google documents that data starts flowing “within 24 hours” of linking and that each daily file carries the previous day’s data; what the export begins with is the day you connected it. If a property isn’t linked yet, link it today even if nobody will query it for a year. The export costs nothing to sit there, and it cannot be applied to the past.

The comparison worth running, and the four conditions that make it fair

Pick one event that matters to the business. A purchase, a generate_lead, a named form submission. Not page_view, which runs at a volume where a small relative gap reads as an alarming absolute one, and not a custom event you introduced partway through the period you’re testing.

Then make the comparison fair on four axes, in this order.

  1. Close the date range, and end it three days back. Two windows have to close before a day settles. GA4’s processing “can take 24-48 hours,” and “data in reports and API queries may change after the daily data becomes available.” The export side runs longer: Google updates a daily table “for up to 2 calendar days, plus today beyond the date of the table.” Compare yesterday against yesterday and you’re comparing two moving targets. Stop at two days back and the export may still be writing to one of them.
  2. Query the daily tables. The events_intraday_ table is a best-effort stream of the current day and it gets deleted once the daily table for that date completes. Anything you calculate from it is provisional.
  3. Make the timezones agree. BigQuery table dates follow the reporting timezone registered on the property. A query stamping UTC onto a property registered at UTC+4 has shifted the window by four hours, so every day in the range is missing a slice at one end.
  4. Apply the same filters on both sides, then check the GA4 side isn’t sampled. If the report has a country, channel or audience filter, your SQL needs the equivalent. Audience membership is the awkward one. It isn’t in the event tables at all; it lives in the user-data export, as audiences.name plus the timestamps for when the user joined and when they age out, and only on a property set up to export user data. Then read the data quality icon on the report, which states the percentage of data used to produce the number.

Run the count on both sides. If they land within a percentage point or so, the pipeline between your site and your warehouse is sound, and that is the finding. Write it down and stop. Every remaining difference between the two surfaces is definitional, and a definitional difference gets named and closed, not chased.

If the counts diverge by a wide margin and the export has no missing days, the fault sits upstream of both tools: a container sending the event inconsistently, or a property configured to filter, redefine or deduplicate it. The export is a transcript. When the transcript is wrong, look at the microphone.

Active users, and the one-line filter that makes the two comparable

There’s one false alarm worth naming ahead of all the others, because the fix is a single line of SQL.

GA4’s interface reports Active Users by default. A BigQuery query that counts distinct user_pseudo_id values returns Total Users, which is a larger number by definition, since it includes everyone who fired an event without qualifying as active. Two different questions, two different answers, and the gap between them arrives in a meeting as evidence that one of the tools is broken.

The export carries the answer as a field. is_active_user is documented as “whether the user was active (True) or inactive (False) at any point in the calendar day,” and filtering on it is what makes the two surfaces comparable.

A trap sits immediately behind that fix, and it’s worth knowing before it costs you an afternoon. Google’s schema documentation states that is_active_user “is only populated in the daily tables.” The column is there in an intraday table and it holds nothing, so the filter matches no rows and what comes back is an empty result with no error to explain it. traffic_source behaves the same way, which means any channel breakdown built on today’s data is describing a field that isn’t there.

The rule that follows is small and it holds. Filter on is_active_user whenever you’re comparing against the interface, name which user metric you’re reporting every time you report one, and build neither from intraday tables.

Four gaps that will never close, and one correction worth having

Some differences between the two surfaces are permanent, because the two datasets don’t hold the same things. Recognising these is what lets you stop.

Start with Google signals, which isn’t in the export at all. Google’s thresholding documentation states it plainly, that “Analytics doesn’t export data from Google signals to BigQuery.” Age, gender and interest reporting exists in the interface and does not exist in your warehouse. It can’t be reconciled, rebuilt, or queried around.

Thresholding produces the opposite shape of problem. When a segment is small enough that showing it might identify someone, GA4 withholds it. The thresholds are “system defined” and, in Google’s words, “you can’t adjust them.” So your totals stop adding up in the interface and add up fine in BigQuery, and the interface is the one behaving correctly.

Then there’s (other), a reporting artefact with no export equivalent. When a table exceeds its row limit, GA4 collapses the tail into a single (other) bucket. Google’s guidance is that any dimension carrying more than 500 values should be treated as high-cardinality, and it’s careful to call that guidance rather than a limit. BigQuery has no row limit and no (other), so a high-cardinality dimension looks complete there and truncated in reports.

Modeling is the fourth, and it only runs on some properties. Where consent signals never arrive, GA4 can model the missing behaviour, but only under advanced consent mode and only once the property qualifies: at least 1,000 events a day with analytics_storage denied for at least seven days, and at least 1,000 consenting users a day on at least seven of the previous 28 days. Even then the modeled numbers appear only under the Blended reporting identity. A property that doesn’t qualify has no modeled data to show, so the denied visit is absent, and the two surfaces agree by both being short. Where modeling does run, none of it reaches the warehouse: “none of the modeled data is available in the BigQuery event export.” Which is why what’s really firing on your pages moves your numbers more than any query you’ll write.

Sampling is the one worth correcting. It gets described as an Explore-only problem, and Google’s own sampling documentation doesn’t support that. It says sampling “may occur when the number of events used to create a report, exploration, or request exceeds the quota limit,” puts the quota at 10 million events for standard properties, and offers this example: “Filtering large datasets by country may result in data sampling in standard Google Analytics reports and Explore, even when using expanded data requests.” A GCC team looking at one market at a time applies a country filter on nearly every report it opens, which is precisely the case Google chose to illustrate.

Attribution is a different problem, and no reconciliation fixes it

Two faithful measurements of the same traffic inside Google’s own stack is one kind of disagreement. The gap between GA4 and your ad platforms is a different animal: different attribution windows, conversions credited to an impression nobody clicked, and platform-side modeling built by a vendor whose job is to justify your spend. Meta’s view-through conversions never reach GA4. YouTube’s engaged views do reach it, in a form of their own: an engaged view counts in data-driven attribution, and Google’s bar for engaged is thirty seconds of watching or a click on a card, banner or end screen. No query reconciles any of it, and treating it as a data-quality defect sends good people hunting a fault that was never there.

What you can do is make your own attribution defensible, and that comes down to four things being written down.

  1. Which model is set. Open Attribution Settings and read it rather than assuming. The menu shrank in November 2023, when first click, linear, time decay and position-based were all removed, so a mental model formed before then no longer describes the options.
  2. When it last changed. Google documents that “changing the reporting attribution model applies to historical and future data,” so a switch made in June rewrites June’s channel numbers. No report is marked when that happens. The property change history in Admin holds two years of settings changes, so the record exists, but you have to go and ask for it. Record the date of any change alongside the reports it affects.
  3. The lookback window in force. Acquisition key events default to a 30-day window, with 7 days as the alternative. Every other key event defaults to 90 days, with 30 and 60 available. At 90 days a key event can be credited to a touchpoint from three months back, and the number belongs in any report quoting channel performance.
  4. The two periods you set against each other. A year-on-year comparison spanning Ramadan compares two different seasons and reads the difference as performance. Pick closed, comparable periods deliberately, then say which ones you picked.

None of that makes attribution accurate. A sale can run for weeks and pass through several channels, a WhatsApp thread and an in-person meeting, and every model compresses that path into a story it can tell in one channel. What you’re after is a number whose construction you can defend, which is both a different thing from a number that’s right and a considerably more achievable one.

Once the pipeline is proven and the model is documented, the remaining risk sits in how the numbers get presented, and a dashboard built to reassure rather than inform undoes careful work upstream. If you’re not sure which layer your problem lives in, the seven systems behind your reporting are worth walking in order.

Where the click comes from. Pick one or type your own.

How it gets there. Every suggestion maps to a named GA4 channel.

The campaign this link belongs to: ramadan-2026, q1-launch.

Optional. The ID from your ad platform, for joining spend data later.

Optional. The paid keyword, for search campaigns.

Optional. Which creative or link position, when two point to the same page.

Source and medium are free text. The suggestion lists are a shortcut for the values GA4 files correctly, not a constraint, so type your own convention if you have one.

Your tagged link

Still needed: landing page URL, campaign source, campaign medium, campaign name.

Standing offer

The hard part is deciding what to do first

Bring us a URL, an audit report, or a proposal you're not sure about. We'll tell you which problems deserve budget this quarter, and you'll leave with an order of operations you can hand to whoever does the work, whether that's us or not.

Book twenty minutes