All articlesData Infrastructure

How we backfill years of GA4 data into BigQuery without gaps

DataBackfill Team·Jun 24, 2026·6 min read
How we backfill years of GA4 data into BigQuery without gaps

When teams connect GA4 to BigQuery, they usually discover the same painful truth: the native export only starts from the day you turn it on. Everything before that lives inside GA4 under a rolling retention window, and it is not coming back on its own.

This surfaces at the worst possible moment. A finance team asks for a three-year revenue trend. A product team wants to compare this quarter's activation funnel against the same quarter last year. An analyst opens BigQuery, finds the table, and discovers the data starts six weeks ago. The rest is gone unless someone rebuilds it.

Why the gap happens

The GA4 to BigQuery export is forward-looking. It streams new events as they arrive, but it does not retroactively load the months or years of history you accumulated before the link existed. If your analysis depends on trends, cohorts, or year-over-year comparisons, that missing history is the entire game.

GA4's own retention settings compound the problem. Depending on configuration, raw event data inside the GA4 UI itself ages out after a matter of months. So the gap is not just in BigQuery, it is upstream too. Wait long enough and there is nowhere left to pull the data from at all, which is why teams that put this off usually regret it.

The GA4 Data API can return older data, but it returns aggregates shaped for reporting, not the event-level rows the BigQuery export produces. Bridging that difference, at scale and without drift, is the actual work. It sounds like a data-format problem. In practice it is a systems problem: rate limits, schema fidelity, and proof.

The pipeline in four steps

  • Pull historical data through the GA4 Data API, day by day, respecting per-property quota.
  • Normalize the API response into the same schema the native BigQuery export uses.
  • Load into date-partitioned tables so backfilled and live data query as one dataset.
  • Reconcile row and metric totals against GA4 reporting to prove completeness.

Going day by day matters. Quota is the constraint that quietly kills naive backfills, and a per-day loop with backoff lets a large range complete without tripping limits or silently dropping windows. Teams that try to pull a whole year in one request usually hit a wall, retry blindly, and end up with a dataset that looks complete but has silent holes in the middle of it.

Schema fidelity is the hard part

The GA4 Data API and the native BigQuery export do not speak the same shape by default. The export nests event parameters, user properties, and ecommerce items in a specific structure that downstream dashboards and queries already expect. If a backfill lands in a different shape, every existing query, every Looker Studio report, and every dbt model built against the native schema breaks the moment it touches historical rows.

So normalization is not a formatting nicety, it is the requirement that makes the backfill usable at all. We map every field the API returns into the export's nested structure, including the parts that are easy to skip: custom event parameters, item-level ecommerce data, and the user property history that changes over time. Skipping any of those means the backfilled data looks right in a row count but fails the moment someone filters on a custom dimension.

The reconciliation step is the one everyone skips and the one that actually matters. A backfill you cannot verify is a liability, not an asset.

Reconciliation is the deliverable

That last step is what separates a script from a system. For every day in the range, we compare event and session totals from the backfill against the numbers GA4 reports for the same day, and we surface any day that drifts beyond a tolerance so it can be re-pulled. A one-off script that dumps data into a table and calls it done has no way to tell you whether it actually worked.

The output is a reconciliation report you can hand to a stakeholder: here is the range we reconstructed, here is how each day checks out, here are the few days that needed a second pass and why. Discrepancies are normal, not a failure signal on their own. GA4's own reporting includes sampling and thresholding in some views, so the target is not a perfect byte-for-byte match, it is a documented, explainable variance that a data team can sign off on.

Handling the edge cases

Properties that migrated from Universal Analytics carry their own scars: inconsistent event naming, gaps from the migration window itself, and user identifiers that do not line up cleanly across the cutover. A backfill that ignores this produces a table that is technically complete and practically misleading, because a trend line that crosses the migration boundary will show a discontinuity that has nothing to do with real user behavior.

We flag migration boundaries explicitly in the output rather than pretending the join is seamless. Anyone building a long-range trend on top of the backfilled data needs to know where the seam is, the same way a geologist marks a fault line instead of drawing the rock as one continuous layer.

Rate limits are a design constraint, not an inconvenience

The GA4 Data API enforces quota per property, per project, and per time window, and those limits are not generous when you are pulling years of daily granularity across dozens of dimensions and metrics. Treating the limit as something to work around with brute force retries produces a job that runs for days, fails intermittently, and leaves the range in an unknown state when it does.

The more durable approach is to treat the quota as a budget to schedule against, not an obstacle to push through. That means batching requests to stay comfortably under the ceiling, backing off with real delay when the API signals it is close to the limit, and tracking exactly which days have completed so a restart resumes instead of starting over. A backfill job that can be paused, resumed, and safely rerun without duplicating rows is the difference between a tool you trust and a tool you babysit.

Why teams try this themselves first

Almost every team we talk to has already attempted a version of this internally, usually a python script someone wrote in an afternoon that pulls a date range and writes it to a table. It works, right up until the range gets large enough to hit quota, or a stakeholder asks a question that depends on a dimension the script never pulled, or someone needs to prove the numbers are right and there is no reconciliation step to point to.

None of those failure modes show up in a quick test against a week of data. They show up months later, against years of data, exactly when the business decision riding on the numbers is the most consequential. That gap between what works in a test and what holds up in production is the entire reason this is a harder problem than it looks.

What good looks like

A finished backfill should be indistinguishable from data that was always there: same schema, same partitions, same query patterns, with a verification trail attached. Someone querying the table six months from now should not need to know which rows came from the live export and which came from the backfill. That is the bar we hold every sync to, and it is why DataBackfill Sync has powered over 1,400 successful syncs at production uptime.

The measure of success is not that the backfill ran. It is that nobody downstream ever has to think about it again.

Start a Project