ENGINEERING 6 min read

GA4 Returns Your Two Date Ranges Interleaved, and It Cost Us a Day

LastClarity

The short answer

When a GA4 Data API report requests two date ranges, GA4 appends a synthetic dateRange dimension and returns one row per dimension value per range. Rows are not ordered current-then-previous — any metric sort interleaves them. Read the dateRange dimension value on each row instead of trusting row position.

We spent most of a day convinced the GA4 Data API was returning wrong numbers. It was not. It was returning exactly what we asked for, in an order we had assumed rather than checked.

If you are building anything against runReport with a comparison period, this is the note we wish we had found.

What actually happens

Request two date ranges:

{
  "dimensions": [{ "name": "pagePath" }],
  "metrics": [{ "name": "screenPageViews" }],
  "dateRanges": [
    { "startDate": "6daysAgo",  "endDate": "today" },
    { "startDate": "13daysAgo", "endDate": "7daysAgo" }
  ],
  "orderBys": [{ "metric": { "metricName": "screenPageViews" }, "desc": true }]
}

GA4 does something it does not document loudly: it appends a dateRange dimension you did not ask for. Your response comes back with two dimension headers, not one:

dimensionHeaders: [ { name: "pagePath" }, { name: "dateRange" } ]

Every row now carries a value of date_range_0 or date_range_1, matching the order you listed the ranges in. And because you sorted by a metric, the rows come back ordered by that metric across both ranges at once:

pagePathdateRangescreenPageViews
/pricingdate_range_04,812
/pricingdate_range_14,455
/blogdate_range_13,901
/blogdate_range_02,204

Look at the last two rows. For /blog, the previous period sorts above the current one, because it had more views. Any code that reads “first row is current, second is previous” now reports a page that grew when it actually shrank by 43%.

The fix is three lines

Stop reading position. Read the dimension.

// Which column holds the synthetic dimension? GA4 appends it, so its index
// depends on how many dimensions you requested.
const column = data.dimensionHeaders
  .findIndex((h) => h.name === 'dateRange');

// 0 = the first range you requested, 1 = the second.
const rangeIndex = (row) => {
  if (column < 0) return 0;            // single-range report
  const raw = row.dimensionValues?.[column]?.value || '';
  return Number(/(\d+)$/.exec(raw)?.[1] ?? 0);
};

Parse the trailing integer rather than string-matching date_range_0. If you ever request a third range the parse keeps working, and it degrades safely to 0 on a single-range report where the dimension is absent.

The second trap: row limits eat the counterpart

Fixing the ordering fixes totals. It does not fix per-page comparisons, and this one is quieter.

A two-range report with "limit": 25 returns 25 rows total, not 25 pages across both ranges. Sorted by a metric, the limit cuts wherever it lands. A page whose current period ranks 20th and whose previous period ranks 30th comes back with a current row and no previous row — so it looks like a brand new page that appeared from nowhere.

There is no ordering or limit that makes this safe, because the two periods have genuinely different rankings. The only correct approach is to stop asking for one report:

  1. Fetch period A, sorted and limited however you like.
  2. Take the exact set of dimension values that came back.
  3. Fetch period B filtered to that exact set, so nothing can fall off the end.
  4. Join on the dimension value.

Two round trips instead of one. It is the price of a comparison that does not silently lie.

The third trap: “N days” is off by one

startDate and endDate are both inclusive. So a seven-day window is:

startDate: "6daysAgo",  endDate: "today"     ← 7 days
startDate: "7daysAgo",  endDate: "today"     ← 8 days

Harmless on its own. Poisonous in a comparison, because if the current period is 7 days and the previous is 8, every percentage change is skewed by a full day of traffic and the direction can flip on close calls. Derive both windows from one length variable so they cannot drift apart.

The fourth trap: zero is not absent

GA4 does not return a row with 0. It returns nothing at all.

This means rows.find(r => r.path === '/checkout') returning undefined is ambiguous — the page might have had no traffic, or it might have fallen outside your row limit, or it might not exist. Code that renders “no data” for a missing row will show nothing at all for the exact scenario you most need to see: a page that had traffic last period and zero this period.

Default missing rows to zero after the join, not before, and only for dimension values you know were in the requested set.

Why this is worth writing down

None of these four behaviours are bugs. Each one is a defensible API design decision, and each one is documented somewhere. What they share is that the wrong assumption produces plausible numbers rather than an error — no exception, no null, just a dashboard that is confidently off.

That is the expensive kind of wrong.

All four are handled in GA4 Simple View, which is what forced us to learn them. If you are writing your own integration, the three lines above will save you the day we lost.

Questions people also ask

Each answer stands on its own, so it still makes sense quoted somewhere else.

Why does my GA4 comparison report show the wrong previous-period numbers?

Almost always because the code assumes row order. When you request two date ranges the GA4 Data API returns one row per dimension value per range, and applying a metric sort interleaves the two ranges rather than keeping them in blocks. Read the synthetic dateRange dimension on each row, where date_range_0 is the first range you requested and date_range_1 the second.

What is the dateRange dimension in a GA4 Data API response?

It is a dimension GA4 adds automatically whenever a runReport request contains more than one dateRanges entry. It does not appear in your request. Its values are date_range_0, date_range_1 and so on, matching the order of the ranges you asked for, and it is appended to dimensionHeaders after the dimensions you requested.

Should I use one two-range report or two separate reports for a comparison?

For totals, one two-range report is fine and cheaper. For per-dimension comparisons with a row limit, use two separate reports and join on the dimension value. A metric-sorted, row-limited two-range report gives no guarantee that both periods of the same page survive the limit, so rows lose their counterparts silently.

Are GA4 date ranges inclusive of both endpoints?

Yes. startDate and endDate are both included, so a seven-day window is 6daysAgo to today, not 7daysAgo to today. Using 7daysAgo returns eight days. If you make that mistake on only one side of a comparison, every percentage change is inflated by roughly one day of traffic.

Does GA4 return a row when a dimension value has zero traffic?

No. GA4 omits the row entirely rather than returning zero. Code that treats a missing row as no data rather than as zero will miss exactly the cases you most want to catch, such as a page that got traffic last week and none this week.

#GA4 Data API#Comparison#Debugging#Attribution

This is what we built instead

GA4 Simple View puts the numbers in this post on one screen, in one click, without building a report first.

Get the extension