LW IT Solutions
« Blog Overview /Digital Analytics / Reading the Per-Event Consent State From the...
Read this article in other languages:

Reading the Per-Event Consent State From the GA4 BigQuery Export

Reading the Per-Event Consent State From the GA4 BigQuery Export
Contents
  1. Where it sits
  2. The trap
  3. What this makes possible
  4. Two things worth knowing about the row
  5. Questions and answers
  6. Sources

Consent shows up in GA4 reports as an aggregate, if at all. In the BigQuery export it shows up per event, in a nested record written alongside every row, holding the state that applied at the moment that particular event was recorded.

That is a considerably better object to work with, and it comes with one trap that produces a wrong number rather than an error.

An indented schema tree of the GA4 events table with the privacy_info record highlighted and its three string fields shown, beside a panel listing the three possible values including null and a panel comparing two SQL filters
Three possible values in a field most queries filter as though it had two.

Where it sits

The record is privacy_info, and it holds the storage states as strings rather than booleans. Being nested, it needs dotted access; being strings, it needs quoted comparison; and being nullable, it needs a decision about what an absent value means before any rate is computed.

SELECT
  event_date,
  privacy_info.analytics_storage AS analytics,
  privacy_info.ads_storage       AS ads,
  COUNT(*)                       AS ereignisse
FROM `projekt.analytics_XXXXXX.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260801' AND '20260831'
GROUP BY 1, 2, 3
ORDER BY 1, 4 DESC

-- run this before writing any filter. The point is to see
-- how many rows fall into the third bucket, because that
-- number decides whether the trap below matters at all

Running the grouping first rather than the filter is the whole discipline here. It takes one query, it costs almost nothing on a single month, and it answers the question that every subsequent query depends on.

The trap

Two filters that read as opposites are not opposites. analytics_storage = 'No' selects events that carry an explicit refusal. analytics_storage != 'Yes' selects refusals plus everything with no recorded state, because in SQL a comparison against null yields null rather than true – so the first filter silently drops the null rows and the second keeps them.

A consent rate built on the first will be higher than one built on the second, on the same data, for the same day. Neither is obviously wrong from the outside; both return a plausible percentage. Which one is correct depends on what the nulls actually are, and that is a question about the implementation rather than about SQL.

Nulls generally mean no consent signal reached the tag when the event fired – a hit before the banner resolved, a page where the consent platform failed to load, a server-side event constructed without the state being passed through. Those are three different situations with three different meanings, and lumping them in with explicit refusals overstates refusal.

What this makes possible

Because the state is per event rather than per property, it can be crossed with anything else on the row.

by page          which pages get refused most often
                 usually the ones where the banner competes
                 with content for the same screen area

by device        mobile and desktop rates differ enough that
                 an overall figure describes neither

by source        traffic arriving from a link inside an app
                 behaves differently from a direct visit

by hour          a deployment that breaks the banner shows up
                 as a step change at a particular hour, which
                 a daily figure smooths away entirely

The per-page cut is the one that repays the effort fastest, because it turns a number nobody can act on into a list of specific pages.

Two things worth knowing about the row

A single visit can contain events in more than one state. Someone who accepts a banner halfway through a session leaves a trail of denied events followed by granted ones under the same pseudo identifier, which is correct and which quietly breaks any query assuming one state per user.

And the export schema grows. Fields have been added to the events table before and will be again, which is the argument against SELECT * in anything downstream: a new column arriving unannounced is harmless to a query that names its columns and is a broken load job for one that does not.

Questions and answers

Why does an export sometimes not contain a single refusal?

Usually because Consent Mode runs in its basic form. There the tag only loads after consent; anyone who refuses sends nothing, and the export holds only events with consent granted or with no recorded state. A consent rate cannot be built from such an export; it has to come from the records of the consent platform instead.

How can the nulls be assigned to the three situations the article names?

Through the other fields on the same row, because each of the three situations leaves its own pattern:

  • Hits before the banner resolved cluster at the start of a session. With the events of each session ordered by event_timestamp, the nulls come first, followed by events with a recorded state.
  • A page where the consent platform failed to load shows up in the cut by page or by hour: nulls concentrated on particular pages or page templates, or a step change from the time of a deployment.
  • Server-side events built without the state being passed through can be recognised by their event name if only the server sends them; they are then null throughout, regardless of page and time of day.

For the query itself it helps to keep the third bucket visible rather than leaving it to the comparison: COALESCE(privacy_info.analytics_storage, 'empty') turns the null into a value of its own that appears explicitly in every grouping and every filter. The decision about what “empty” means is then made once, in one place, rather than silently in every WHERE clause.

Why does the query restrict by _TABLE_SUFFIX rather than by event_date alone?

For two reasons. First, BigQuery bills on-demand queries by the data they read, and only a filter on _TABLE_SUFFIX determines which daily tables are read at all; a filter on event_date alone first reads every table the wildcard matches and discards rows only afterwards.

Second, events_* also matches the streaming export tables, events_intraday_…, whose suffix begins with intraday_. A range of date values such as ‘20260801’ to ‘20260831’ excludes them. If a period reaches up to the current day, a filter on event_date alone reads their provisional rows as well.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in Touch

Briefly describe your project or inquiry for a tailored response. This site is protected by reCAPTCHA.

Write a comment

Differing figures from other accounts and questions about the setup are welcome here.

The email address is not published. Required fields are marked with an asterisk.

Articles & categories

CCTV

Follow this category by RSS

Cloud & AI

All 11 articles in this category Follow this category by RSS

Data Privacy

All 18 articles in this category Follow this category by RSS

Digital Analytics

All 58 articles in this category Follow this category by RSS

Digital Marketing

All 37 articles in this category Follow this category by RSS

IT & Networks

All 17 articles in this category Follow this category by RSS

Music Production

All 16 articles in this category Follow this category by RSS

Raspberry PI

Follow this category by RSS

SaaS & Internet Earning

Articles coming soon

Smart Home

All 18 articles in this category Follow this category by RSS

Web Development

All 11 articles in this category Follow this category by RSS

WordPress Plugins & Tricks

All 13 articles in this category Follow this category by RSS