DIAGNOSE

Why Firebase Analytics and Its BigQuery Export Disagree: And Which One Is Right

Builtshotlingo.com
Shipped3 iOS apps: QuickBill, Travelized, Breaker
Published6 open datasets
Updated2026-08-04

Published 2026-08-04 · Updated 2026-08-04 · Cluster: analytics-reconciliation

This page is a decision tree, not a single answer. Five sections follow, each a specific, documented mechanic behind a gap between what the Firebase console reports and what its own BigQuery export contains for the same app, the same metric and the same day. Work through them in order against your own numbers: each section either explains the gap or rules itself out, and you move to the next.

The short version of the answer in the title, stated up front so you can stop reading if it is all you needed: the export is the ground truth for what was collected, and the console is the ground truth for what Google reports. Those are different questions, and for several of the causes below the two are not reconcilable by design. The useful work is knowing which of your gap is definitional, which is estimation error, and which is a real collection problem you have to fix.

Every mechanic below is checked against current Google documentation, cited inline with the date checked. Where a discrepancy depends on how your team configured something, that is stated plainly, and where I could not confirm something, that is stated too.

Analytics in a Firebase project is Google Analytics 4 — the Firebase console renders a subset of the same property the GA4 interface reads, and Google documents the export behaviour under Analytics rather than under Firebase. The citations below are therefore GA4 documentation, and they apply to an app-only property.

The two numbers#

There is no invented example under this heading. A fabricated app with fabricated daily actives would be exactly the unverifiable claim this site is built to avoid. Instead, here is what each source is actually built to hold, and what it leaves out. Once you know that, you can pull your own two numbers and know which parts of the gap are structural.

SourceWhat it holdsWhat it leaves out
Firebase / GA4 consoleGoogle's reported figures: an HLL++ estimate of user and session cardinality, plus value additions — behavioural and key event modelling for denied consent, Google Signals cross-device identity joins, and sampling in explorations. Rows beyond a report's limit collapse into (other). Thresholding can suppress rows entirely.Anything suppressed by thresholding. The exact, individual rows behind any estimate.
BigQuery event exportThe raw collected event and user rows: one row per event, user_pseudo_id as collected, exact counts available via COUNT(DISTINCT). No row limit, no (other), no thresholding.Every value addition above: modelled data, Google Signals identity resolution, and the console's own estimates. Nothing that was never collected in the first place.

(Google Analytics Help, "[GA4] BigQuery Export schema", and Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", both checked 2026-08-04.)

Google's own framing of this is worth quoting rather than paraphrasing, because it is more direct than most vendors are about their own tooling: the export "provides access to raw event and user-level data, excluding any value additions that Google Analytics makes to the data found in standard reports and explorations" (Google Analytics Help, "[GA4] BigQuery Export schema", checked 2026-08-04). The gap is not a bug. Most of it is the value additions, itemised below.

Not sure which of the five your gap is? send me the two numbers that disagree and I'll tell you which section to start with.

The Active Users definition: you are almost certainly counting a different thing#

Start here, because it is the most common cause and the cheapest to rule out.

The headline user number in the console is Active Users, not total users. Google states it plainly: "The primary user metric used for reporting in GA4 is Active Users" (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04).

The export contains both, and they are not the same column. Counting every distinct user_pseudo_id in events_YYYYMMDD gives you total users — every device that sent an event that day, including ones that never engaged enough to qualify as active. To reproduce the console's number you have to filter on the export's own active flag, is_active_user = true, before counting (same source, checked 2026-08-04).

This one mechanic explains a great many "BigQuery is higher than Firebase" reports on its own, and it explains them in a specific direction: the unfiltered export count is always greater than or equal to the console's active-user figure, never less. If your export number is lower than the console's, this is not your cause and you can move on: skip to modelling in section three, which is the main mechanic that pushes the console number above what was actually collected.

What to check: run the count both ways, with and without is_active_user = true, against the same events_YYYYMMDD table. If the filtered count lands within a couple of percent of the console, you are done — and the residual couple of percent is section two, below.

HyperLogLog++: the console is estimating, and it says so#

Assume you have matched the definition and there is still a small, stubborn gap that will not close. This is where it comes from, and it is not something you can fix — only something you can size.

Google Analytics does not count unique users and sessions exactly. It uses a cardinality-estimation algorithm: "Google Analytics uses HyperLogLog++ (HLL++) algorithm to estimate cardinality for common metrics including Active Users and Sessions" (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04). BigQuery, reading the same underlying rows, gives you exact counts via COUNT(DISTINCT) (Google for Developers, "Unique count approximation in Google Analytics", checked 2026-08-04).

So the two disagree because one is an estimate and the other is a count. Google publishes the precision parameters it uses per metric:

MetricPrecisionSparse precision
Sessions1217
Active Users1425
Total Users1425

(Google for Developers, "Unique count approximation in Google Analytics", checked 2026-08-04.)

Two consequences worth being precise about. First, sessions are estimated at a lower precision than users (12 against 14), so the session gap should generally be the wider of the two — if your user counts reconcile but sessions do not, that ordering is expected rather than evidence of a second problem. Second, Google puts the expected variation from this cause at roughly ±1.63% at a 95% confidence interval (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04).

That number is the practical test. A gap of a percent or two, in either direction, with no pattern across days, is this and nothing else — stop investigating it. A gap of fifteen percent is not HLL++, no matter how much you would like it to be, and one of the other four sections is your answer.

If you want to reproduce the console's estimate rather than the true count, BigQuery exposes HLL++ functions directly, but Google flags a caveat that matters at small scale: "There will be a small difference in user count for cardinalities up to approximately 12,200" because GA4's sparse precision and BigQuery's default differ (Google for Developers, "Unique count approximation in Google Analytics", checked 2026-08-04). For an app below roughly twelve thousand daily uniques — which is most indie apps — matching the estimate exactly is not straightforward even when you use the same algorithm.

Data the export never receives: modelling, Signals, thresholding#

This is the section that explains a console number higher than the export, and it is the one that is not reconcilable. Three separate mechanics, all pointing the same direction.

Modelled data from denied consent. When a user declines consent, Google does not simply drop them from reporting. "When visitors deny consent, GA4 fills the data collection gaps with key event modeling and behavioral modeling. None of the modeled data is available in the BigQuery event export" (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04). The console shows collected plus modelled; the export holds collected only. On an app with a consent prompt in a jurisdiction where a meaningful share of users decline, this gap is structural and permanent.

Google Signals identity joins. Signals resolves the same signed-in person across devices into one user. Google's own worked example: with Signals on and the person signed into one Google Account across three browsers, "Google Analytics attributes that activity to one user," while "BigQuery will still show three separate user_pseudo_ids because Google Signals information is not available in the BigQuery export" (same source, checked 2026-08-04). This one pushes the export higher on user counts, not the console — worth noting because it runs opposite to modelling, and on a property with both enabled the two partially cancel, which is how a large real gap can hide behind a small net one.

Thresholding. Standard reporting surfaces suppress rows that could identify individuals: "Certain reports in standard reporting surfaces might have thresholding applied and not return certain data. Most information that can be subject to thresholding usually is not available in the BigQuery export" (same source, checked 2026-08-04).

What to check: whether Google Signals is enabled on the property, and whether a consent mechanism is live in the app. Both are yes/no facts about configuration, and between them they set a floor on how closely these two numbers can ever agree. If both are on, stop trying to reconcile the totals exactly — reconcile the trend instead, and use the export for anything that has to be exact.

The (other) row and sampling: reporting-surface artefacts#

Two causes that look like data problems and are actually presentation limits.

(other). Reports have row limits, and when a dimension exceeds one, "less frequent values get grouped together and labeled as (other)" (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04). This affects the reporting modules and the Data API; it does not affect the export, which has no such limit. The trap is specific: a per-screen or per-event-parameter breakdown in the console can be missing a long tail that is fully present in BigQuery, so the total agrees while every individual row is understated. Google's guidance on what counts as risky here is that "any dimension with more than 500 values should be considered a high-cardinality dimension," and it gives one concrete published limit, a 100k row limit on the table behind the Pages and screens report (Google Analytics Help, "[GA4] High-cardinality dimensions", checked 2026-08-04). Google does not publish a full table of per-report limits, and I could not find one — so treat any high-cardinality breakdown as suspect rather than assuming a threshold you have not hit.

Sampling. Explorations can sample; standard reports generally do not. Google's stated first step before any comparison is to verify the report you are reading is not sampled (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04). It is the cheapest check on this page: the console tells you, and if it says the report is sampled then the comparison you are making is not a valid one to begin with.

Timing: the 72-hour window, intraday tables and the property's time zone#

Leave this section for last, because it is the one that most often turns out to be the whole answer after four sections of real investigation — and the one most easily avoided by not comparing recent days.

The 72-hour window. A daily table is not final when it first appears. "The daily tables can get updated up to 72 hours beyond the date of the table with events that are time-stamped with the date of the table," and Google's explicit advice is to compare only data older than that window (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04). Mobile is the worst case for this: a device that is offline, backgrounded or force-quit holds its events and sends them on the next launch, so a meaningful share of an app's daily events legitimately arrive a day or more late. If you reconciled yesterday and found a gap, you did not find a bug.

Beyond that window there is a further, rarer case: "Analytics may update the daily tables anytime after the 2-calendar-day window… under circumstances that require Analytics to reprocess historical data, such as a bug fix" (Google Analytics Help, "[GA4] BigQuery Export schema", checked 2026-08-04). A previously reconciled number changing under you is documented behaviour, not a hallucination.

Intraday tables. With streaming export on, today's data lands in events_intraday_YYYYMMDD, an "internal staging table" that runs "from 12:00:00 am until 11:59:59 pm in the property's time zone" and "is deleted when events_YYYYMMDD is complete" (Google Analytics Help, "[GA4] BigQuery Export schema", checked 2026-08-04). Querying the intraday table and comparing it to a console report is comparing a partial staging table to a reported figure. Separately, the streaming export omits attribution the daily export carries: new user and new session traffic source data is excluded from it, and some user attribution can be delayed by up to 24 hours (same source, checked 2026-08-04).

Time zone. The date suffix on a daily table is not UTC. It corresponds to "the time zone of the Analytics property from which data is being exported" (Google Analytics Help, "[GA4] BigQuery Export schema", checked 2026-08-04). If your query buckets by a UTC timestamp while the table itself is bucketed by a property time zone several hours off UTC, you are comparing two different days that share a name. For a property set to a non-UTC zone this shifts a slice of every day's events across the boundary in both directions — small on a flat traffic curve, large on a peaky one.

What to check, in order: that you are reading events_YYYYMMDD and not events_intraday_, that the date is at least four days old, and that any timestamp arithmetic in your query converts to the property's time zone rather than assuming UTC.

So which one is right#

For anything that has to be exact — a finance figure, a cohort definition, an experiment readout, anything someone will act on — the export is the answer, because it is the collected rows themselves rather than an estimate over them, and because it carries no row limit, no thresholding and no (other).

For anything that has to match what Google reports, or that depends on cross-device identity or on users who declined consent, the console is the answer, and the export cannot reproduce it. Google itself does not claim these are reconcilable: for value-added metrics the discrepancies are not expected to reconcile (Google for Developers, "Bridge the gap between the Google Analytics UI and BigQuery export", checked 2026-08-04).

The failure mode worth naming is picking per question. A team that reads the console when the console is higher and BigQuery when BigQuery is higher has no source of truth at all, just a habit. Decide once, per question, and write it down: which number goes in the board deck, and which number a product decision is read against. Those can be two different numbers, and on a property with Signals and consent modelling live, they usually should be. Stage one of the teardown rubric scores exactly this — whether someone on the team can state, without checking, which number is authoritative for which question.

If the numbers that disagree are revenue rather than users and sessions, that is a different set of five causes with different mechanics behind them, and it has its own decision tree: why your RevenueCat, Mixpanel and App Store Connect revenue numbers don't match.

One boundary worth being straight about: the paid engagement named on this page is scoped to revenue-reporting discrepancies, not to user-count reconciliation. If what you have is the latter, say so when you write and I will tell you which of the two you actually need — that is the same answer I would give on a call, and it is cheaper for both of us before money changes hands than after.

[ 01 ] Get the next one

One article when the next one ships. No spam, unsubscribe anytime.

[ 02 ] Get this done

Send the product URL and what looks wrong. A real reply, not an autoresponder.