Skip to main content
Bird & Bear
Menu
← All articles

The GA4 session_start Conundrum: How to Work With Impacted Data

The GA4 session_start Conundrum: How to Work With Impacted Data

Ever since Google Analytics 4 was first released, we have seen countless conversations and articles relating to session and attribution issues that are caused by problems with the session_start event.

Now, of course, the main priority is to fix this at source. However, if your data has already been impacted, we have created the below guide to help you fix inaccuracies in the short-term.

All of the queries below are available in a public GitHub repo, along with a README covering usage notes and caveats: google-sessionstart-issues.

The Impacted Session Audit

Purpose: Identify sessions impacted by inaccurate session_start events.

This script reconstructs GA4 sessions from raw event data using the 30-minute inactivity rule, since the native session ID can’t be trusted for this particular check. It flags any reconstructed session that never had a session_start event fire, and for each one, reports the URL of the first event that did arrive. We’re using user_pseudo_id here because that remains consistent across fragmented sessions. This then gives you a list of landing pages where the session_start hit is most likely being dropped, and enough information to manually re-attribute the session.

Why it’s useful: If a small number of landing pages account for most of the missing hits, that’s usually a sign the page itself is causing it, whether that’s from heavy third-party scripts delaying the tag, a consent banner blocking it, or a redirect firing before the hit can send, or any of the other myriad of issues that users have identified over the last few years. Because sessions missing a start event tend to fall back to “(direct)” or “(not set)” in GA4’s attribution, this also shows you how much of your traffic is quietly sitting in that attribution black hole.

/* Impacted Session Audit — finds sessions where GA4 never fired
   session_start, and pulls the URL of the first event we did capture
   so we can spot which pages are dropping the hit. */
WITH event_base AS (
  -- Pull the fields we need per event, plus the previous event's timestamp
  -- so we can measure the gap between hits later.
  SELECT
    user_pseudo_id,
    event_timestamp,
    event_name,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location' LIMIT 1) AS page_location,
    LAG(event_timestamp) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS prev_event_ts
  FROM
    `your-googlecloud-project.analytics_12345678.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN '20260812' AND '20260815'
),
session_definitions AS (
  -- A gap of more than 30 minutes (GA4's default timeout) starts a new session.
  SELECT
    user_pseudo_id,
    event_timestamp,
    page_location,
    IF(prev_event_ts IS NULL OR (event_timestamp - prev_event_ts) > 1800000000, 1, 0) AS is_new_session,
    IF(event_name = 'session_start', 1, 0) AS is_session_start
  FROM
    event_base
),
session_ids AS (
  -- Running total of new-session flags gives each reconstructed session a stable ID.
  SELECT
    *,
    SUM(is_new_session) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS reconstructed_session_id
  FROM
    session_definitions
),
session_summary AS (
  -- Collapse to one row per session: did session_start fire, and what URL
  -- was on the first event we actually received?
  SELECT
    reconstructed_session_id,
    user_pseudo_id,
    MIN(event_timestamp) AS session_start_time,
    MAX(is_session_start) AS has_session_start_event,
    MIN_BY(page_location, event_timestamp) AS first_session_url
  FROM
    session_ids
  GROUP BY
    reconstructed_session_id,
    user_pseudo_id
)
-- Sessions with no session_start at all — the ones GA4 dropped.
SELECT
  reconstructed_session_id,
  user_pseudo_id,
  TIMESTAMP_MICROS(session_start_time) AS session_start_time,
  first_session_url
FROM
  session_summary
WHERE
  has_session_start_event = 0
ORDER BY
  session_start_time DESC;

Example output:

reconstructed_session_id  user_pseudo_id                    session_start_time       first_session_url
4                          8f3a1c9d2b7e4f61a0c9d3e5b2f7a891  2026-08-15 18:42:07 UTC  https://www.example.com/checkout/basket?utm_source=affiliate&utm_campaign=spring_sale
2                          1e7b45c8a9f3d2016b8e4a7c9f1d3e56  2026-08-15 09:14:33 UTC  https://www.example.com/product/garden-furniture-set
1                          3c9f8a1d2e5b7f4906a1c8d3e9f2b715  2026-08-14 21:03:51 UTC  https://www.example.com/landing/bank-holiday-offers
7                          2a6d9c4b1f8e3705c2b9f4a1d8e6c390  2026-08-14 12:27:19 UTC  https://www.example.com/product/garden-furniture-set
5                          5d2f9a3c8b1e6704a9f2c5b8e1d4a672  2026-08-13 16:55:02 UTC  https://www.example.com/
3                          9b4e1c7a2f8d5306b1e4a9c7f2d8e153  2026-08-12 08:31:44 UTC  https://www.example.com/checkout/basket?utm_source=affiliate&utm_campaign=spring_sale

It’s one row per session, not per event, so a real session_start problem shows up as far fewer rows than raw event volume might suggest. The pattern to point at is repetition in first_session_url. In the above example, the affiliate checkout link and the furniture product page both appear twice here, which is the kind of evidence that lets you point a developer at a specific page instead of a vague “some hits are missing.”

The Session Fragmentation Audit

Purpose: Explain inflated session counts in the GA4 UI.

Where the first script looks for a missing session_start, this one looks for the opposite problem: session_start firing correctly, just too often. It compares GA4’s native ga_session_id against a time-based reconstruction of the same visit, and flags any reconstructed session that actually contains more than one native session ID — a “fragmented” journey, where GA4 split one continuous visit into two or more sessions.

Why it’s useful: When you or your stakeholders spot a jump in sessions that isn’t matched by a jump in users, this is usually where to look. GA4’s session ID can reset mid-visit for reasons that have nothing to do with real user behaviour; a cookie cleared by ITP, a subdomain handover losing session state, or a client-side re-initialisation on an SPA route change. Each reset gets counted as a brand new session in the UI, even though the person never left. Or it can simply be an implementation problem. Comparing the native ID against a time-based reconstruction puts a number on exactly how much of that inflation is happening, and the event-level detail gives you somewhere concrete to start troubleshooting.

/* Session Fragmentation Audit — compares GA4's native ga_session_id against
   a time-based reconstruction of the same visit, to find cases where GA4
   split one continuous visit into multiple sessions. */
WITH event_base AS (
  -- Pull the native session ID GA4 assigned, plus the previous event's
  -- timestamp so we can measure real elapsed time between hits.
  SELECT
    user_pseudo_id,
    event_timestamp,
    event_name,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id' LIMIT 1) AS ga_session_id,
    LAG(event_timestamp) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS prev_event_ts
  FROM
    `your-googlecloud-project.analytics_12345678.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN '20260812' AND '20260815'
),
session_definitions AS (
  -- Reconstruct sessions purely on elapsed time, using the same 30-minute
  -- rule GA4 itself uses — this is our "ground truth", independent of
  -- whatever ga_session_id says.
  SELECT
    user_pseudo_id,
    event_timestamp,
    ga_session_id,
    IF(prev_event_ts IS NULL OR (event_timestamp - prev_event_ts) > 1800000000, 1, 0) AS is_new_session
  FROM
    event_base
),
session_ids AS (
  SELECT
    *,
    SUM(is_new_session) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS reconstructed_session_id
  FROM
    session_definitions
),
fragmentation_check AS (
  -- For each reconstructed (time-based) session, how many different native
  -- ga_session_id values does it actually contain?
  SELECT
    user_pseudo_id,
    reconstructed_session_id,
    MIN(event_timestamp) AS session_start_time,
    MAX(event_timestamp) AS session_end_time,
    COUNT(*) AS event_count,
    COUNT(DISTINCT ga_session_id) AS native_session_id_count,
    ARRAY_AGG(DISTINCT ga_session_id ORDER BY ga_session_id) AS native_session_ids
  FROM
    session_ids
  GROUP BY
    user_pseudo_id,
    reconstructed_session_id
)
-- Fragmented sessions: GA4 split one continuous visit into more than one session.
SELECT
  user_pseudo_id,
  reconstructed_session_id,
  TIMESTAMP_MICROS(session_start_time) AS session_start_time,
  TIMESTAMP_MICROS(session_end_time) AS session_end_time,
  event_count,
  native_session_id_count,
  native_session_ids
FROM
  fragmentation_check
WHERE
  native_session_id_count > 1
ORDER BY
  native_session_id_count DESC,
  session_start_time DESC;

If you just want the single headline number for stakeholders (as in the “inflation factor” across the whole window) swap the final SELECT above for this, which reuses the same session_ids CTE:

SELECT
  ROUND(
    COUNT(DISTINCT ga_session_id) /
    COUNT(DISTINCT CONCAT(user_pseudo_id, '-', CAST(reconstructed_session_id AS STRING))),
  2) AS session_inflation_factor
FROM
  session_ids;

A factor of, say, 1.3 means GA4 is reporting roughly 30% more sessions than actually happened.

Example output:

user_pseudo_id                    reconstructed_session_id  session_start_time       session_end_time         event_count  native_session_id_count  native_session_ids
8f3a1c9d2b7e4f61a0c9d3e5b2f7a891  3                          2026-08-13 11:02:04 UTC  2026-08-13 11:19:47 UTC  14           3                         [1744538512, 1744538890, 1744539201]
1e7b45c8a9f3d2016b8e4a7c9f1d3e56  6                          2026-08-14 20:11:32 UTC  2026-08-14 20:24:58 UTC  9            2                         [1744661492, 1744661810]
2a6d9c4b1f8e3705c2b9f4a1d8e6c390  1                          2026-08-12 07:45:19 UTC  2026-08-12 08:01:03 UTC  11           2                         [1744443919, 1744444260]
9b4e1c7a2f8d5306b1e4a9c7f2d8e153  5                          2026-08-15 14:33:07 UTC  2026-08-15 14:47:52 UTC  6            2                         [1744727587, 1744727901]

The first row is the strongest example to lead with: 14 events inside a 17-minute window (well within the 30-minute timeout) split across three native session IDs. event_count is worth keeping in the output for exactly this reason: a two-event session with two native IDs is a much weaker example than one with 14.

Example output:

session_inflation_factor
1.34

A figure of 1.34 here means GA4 is reporting roughly 34% more sessions than the time-based reconstruction says actually happened.

Revenue Attribution & Health Audit

Purpose: Quantify the financial impact of tracking gaps.

This is the “so what” script. It isolates sessions that ended in a purchase, splits them into Normal and Impacted depending on whether session_start actually fired, and totals the revenue sitting in each bucket.

Why it’s useful: If impacted sessions turn out to be 20% of your revenue, the fix stops being a nice-to-have and becomes something everyone cares about. Because it uses the raw page_location rather than a cleaned URL, it also lets you check whether specific marketing parameters such as long affiliate strings, for example, show up more often in the broken sessions, which can point to a conflict between a marketing tag and core tracking.

/* Revenue Attribution & Session Health Audit — reconstructs sessions to
   attribute revenue back to the entry page, and flags which of those
   sessions were missing a session_start event, so broken tracking and
   its financial impact can be compared side by side. */
WITH event_base AS (
  -- Pull the URL and purchase value per event, plus the previous event's
  -- timestamp so we can measure the gap between hits later.
  SELECT
    user_pseudo_id,
    event_timestamp,
    event_name,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location' LIMIT 1) AS page_location,
    -- Revenue only exists on purchase events; NULL everywhere else.
    -- Note: this doesn't adjust for currency — if the property tracks
    -- multiple currencies, convert to a common one before trusting the sum.
    (SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value' LIMIT 1) AS purchase_revenue,
    LAG(event_timestamp) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS prev_event_ts
  FROM
    `your-googlecloud-project.analytics_12345678.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN '20260812' AND '20260815'
),
session_definitions AS (
  -- A gap of more than 30 minutes (GA4's default timeout) starts a new session.
  SELECT
    user_pseudo_id,
    event_timestamp,
    page_location,
    purchase_revenue,
    IF(prev_event_ts IS NULL OR (event_timestamp - prev_event_ts) > 1800000000, 1, 0) AS is_new_session,
    IF(event_name = 'session_start', 1, 0) AS is_session_start,
    IF(event_name = 'purchase', 1, 0) AS is_purchase
  FROM
    event_base
),
session_ids AS (
  -- Running total of new-session flags gives each reconstructed session a stable ID.
  SELECT
    *,
    SUM(is_new_session) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS reconstructed_session_id
  FROM
    session_definitions
),
session_summary AS (
  -- Collapse to one row per session: entry URL, total revenue, and
  -- whether session_start actually fired.
  SELECT
    reconstructed_session_id,
    user_pseudo_id,
    MIN(event_timestamp) AS session_start_time,
    MAX(is_session_start) AS has_session_start_event,
    MAX(is_purchase) AS session_had_purchase,
    SUM(IFNULL(purchase_revenue, 0)) AS total_revenue,
    MIN_BY(page_location, event_timestamp) AS first_session_url
  FROM
    session_ids
  GROUP BY
    reconstructed_session_id,
    user_pseudo_id
)
-- Every session that resulted in a purchase, flagged as Normal or Impacted.
SELECT
  reconstructed_session_id,
  user_pseudo_id,
  TIMESTAMP_MICROS(session_start_time) AS session_start_time,
  first_session_url,
  total_revenue,
  IF(has_session_start_event = 0, 'Impacted Session', 'Normal Session') AS session_status
FROM
  session_summary
WHERE
  session_had_purchase = 1
ORDER BY
  total_revenue DESC;

Example output:

reconstructed_session_id  user_pseudo_id                    session_start_time       first_session_url                                                                        total_revenue  session_status
12                         4d8f2a1c9b7e3506a2c9f4b1d8e3a765  2026-08-14 19:22:41 UTC  https://www.example.com/checkout/basket?utm_source=affiliate&utm_campaign=spring_sale  284.99         Impacted Session
8                          7c1e9a4b2f8d6503c9a1e4b7f2d9c360  2026-08-13 10:07:15 UTC  https://www.example.com/product/garden-furniture-set                                   199.50         Normal Session
15                         2b6d4c9a1f8e5307b4c1a9d6f2e8b451  2026-08-15 21:44:02 UTC  https://www.example.com/checkout/basket?utm_source=affiliate&utm_campaign=spring_sale  176.20         Impacted Session
3                          9f3a7c1d2e8b5604a3c8f1d5e9b2a716  2026-08-12 15:19:33 UTC  https://www.example.com/                                                                89.99          Normal Session
21                         1e5b8c4a9f2d6703a8b1c4f9d2e6a390  2026-08-14 08:52:47 UTC  https://www.example.com/product/garden-furniture-set                                   64.75          Impacted Session

This is the row-level view; a SUM(total_revenue) GROUP BY session_status on top of it is what actually gets quoted to a stakeholder. In this example, three of the five purchases are Impacted (£284.99 + £176.20 + £64.75 = £525.94), against £289.49 in Normal sessions — so roughly 64% of this sample’s revenue is sitting in sessions with broken attribution. The affiliate checkout URL showing up twice, both times Impacted, is also a concrete example worth naming in the write-up of the “marketing tag conflicting with core tracking” pattern.

Revenue Recovery by Channel

Purpose: Show which channels are under-reported today, and by how much.

The Revenue Audit above tells you the total cost of the problem. This one splits that cost by channel, because “your data is wrong” doesn’t move budget on its own. “The paid social channel is under-reporting revenue by 18%” does. Every impacted session currently shows up as Direct / (not set) in GA4, regardless of what actually brought that visitor to the site. This script parses the campaign parameters (or gclid) off first_session_url for every purchasing session, impacted or not, and compares the channel GA4 is showing today against the channel that revenue should be sitting in.

Why it’s useful: This is usually the version of the numbers that actually gets a fix prioritised. “We think we’re losing some revenue to tracking gaps” is easy to shrug off; “Paid Social’s reported revenue is understated by £3,400 this month because its landing page is dropping session_start” is a specific, ownable problem with a specific budget attached to it. A channel showing no current_revenue at all but a non-zero recovered_revenue is the strongest version of this story as it means that channel isn’t visibly generating any revenue in GA4 right now, when it actually is.

A caveat worth stating plainly here: the channel classification this script uses is a simplified stand-in for GA4’s own default channel grouping rules, not an exact match. It doesn’t cover every case GA4’s own logic does (organic search referrer matching, cross-channel campaign rules, and so on), so the numbers won’t tie out precisely if someone tries to reconcile them against GA4’s own channel report. Say so up front if you’re presenting this alongside the GA4 UI, and extend the CASE statement if closer parity matters for your use case, or you want to set up your own channel grouping here.

/* Revenue Recovery by Channel — compares the channel GA4 is currently
   crediting each purchase to against the channel it should be credited
   to, based on the campaign parameters on the entry URL. Impacted
   sessions always show as Direct in GA4 today, no matter what's in the
   URL, so this shows exactly how much revenue each channel is losing
   to that misattribution. */
WITH event_base AS (
  -- Pull the URL and purchase value per event, plus the previous event's
  -- timestamp so we can measure the gap between hits later.
  SELECT
    user_pseudo_id,
    event_timestamp,
    event_name,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location' LIMIT 1) AS page_location,
    -- Revenue only exists on purchase events; NULL everywhere else.
    -- Note: this doesn't adjust for currency — convert to a common one
    -- first if the property tracks more than one.
    (SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value' LIMIT 1) AS purchase_revenue,
    LAG(event_timestamp) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS prev_event_ts
  FROM
    `your-googlecloud-project.analytics_12345678.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN '20260812' AND '20260815'
),
session_definitions AS (
  -- A gap of more than 30 minutes (GA4's default timeout) starts a new session.
  SELECT
    user_pseudo_id,
    event_timestamp,
    page_location,
    purchase_revenue,
    IF(prev_event_ts IS NULL OR (event_timestamp - prev_event_ts) > 1800000000, 1, 0) AS is_new_session,
    IF(event_name = 'session_start', 1, 0) AS is_session_start,
    IF(event_name = 'purchase', 1, 0) AS is_purchase
  FROM
    event_base
),
session_ids AS (
  -- Running total of new-session flags gives each reconstructed session a stable ID.
  SELECT
    *,
    SUM(is_new_session) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS reconstructed_session_id
  FROM
    session_definitions
),
session_summary AS (
  -- Collapse to one row per session: did it purchase, how much, and did
  -- session_start actually fire?
  SELECT
    reconstructed_session_id,
    user_pseudo_id,
    MAX(is_session_start) AS has_session_start_event,
    MAX(is_purchase) AS session_had_purchase,
    SUM(IFNULL(purchase_revenue, 0)) AS total_revenue,
    MIN_BY(page_location, event_timestamp) AS first_session_url
  FROM
    session_ids
  GROUP BY
    reconstructed_session_id,
    user_pseudo_id
),
channel_attribution AS (
  -- Recover the likely channel from the entry URL's campaign parameters —
  -- a simplified stand-in for GA4's default channel grouping. Expand this
  -- CASE statement if you need closer parity with GA4's own rules.
  SELECT
    *,
    CASE
      WHEN REGEXP_CONTAINS(first_session_url, r'[?&]gclid=') THEN 'Paid Search'
      WHEN LOWER(IFNULL(REGEXP_EXTRACT(first_session_url, r'[?&]utm_medium=([^&]+)'), '')) IN ('cpc', 'ppc', 'paidsearch') THEN 'Paid Search'
      WHEN LOWER(IFNULL(REGEXP_EXTRACT(first_session_url, r'[?&]utm_medium=([^&]+)'), '')) LIKE '%social%' THEN 'Paid Social'
      WHEN REGEXP_EXTRACT(first_session_url, r'[?&]utm_source=([^&]+)') IS NOT NULL THEN 'Other Campaign'
      ELSE 'Direct / (not set)'
    END AS recovered_channel
  FROM
    session_summary
  WHERE
    session_had_purchase = 1
),
channel_split AS (
  -- What GA4 shows today vs. what the entry URL says it should show.
  -- Impacted sessions always default to Direct in GA4 regardless of the
  -- URL; sessions with a working session_start are assumed to already be
  -- correctly attributed, so current and recovered match for those.
  SELECT
    IF(has_session_start_event = 0, 'Direct / (not set)', recovered_channel) AS current_channel,
    recovered_channel,
    total_revenue
  FROM
    channel_attribution
),
current_revenue_by_channel AS (
  SELECT current_channel AS channel, SUM(total_revenue) AS current_revenue
  FROM channel_split
  GROUP BY current_channel
),
recovered_revenue_by_channel AS (
  SELECT recovered_channel AS channel, SUM(total_revenue) AS recovered_revenue
  FROM channel_split
  GROUP BY recovered_channel
)
-- Current vs. recovered revenue per channel, and the size of the gap.
SELECT
  COALESCE(c.channel, r.channel) AS channel,
  ROUND(IFNULL(c.current_revenue, 0), 2) AS current_revenue,
  ROUND(IFNULL(r.recovered_revenue, 0), 2) AS recovered_revenue,
  ROUND(IFNULL(r.recovered_revenue, 0) - IFNULL(c.current_revenue, 0), 2) AS revenue_increase,
  ROUND(SAFE_DIVIDE(IFNULL(r.recovered_revenue, 0) - IFNULL(c.current_revenue, 0), NULLIF(c.current_revenue, 0)) * 100, 1) AS pct_increase
FROM
  current_revenue_by_channel c
FULL OUTER JOIN
  recovered_revenue_by_channel r
ON
  c.channel = r.channel
ORDER BY
  revenue_increase DESC;

Example output:

channel              current_revenue  recovered_revenue  revenue_increase  pct_increase
Direct / (not set)   612.40           86.45               -525.95           -85.9
Paid Search           145.00           320.75               175.75           121.2
Paid Social            0.00           176.20               176.20           (null)
Other Campaign         58.03           232.03               174.00           299.8

Two things worth pointing out explicitly when you write this up. First, revenue_increase nets to zero across the channels (-525.95 + 175.75 + 176.20 + 174.00 = 0) — it’s a reallocation of revenue that already exists, not new money, and it’s worth saying so plainly so nobody reads it as “the business made more revenue.” Second, the null pct_increase for Paid Social is the strongest row in the table: that channel shows £0 in GA4 today despite actually driving £176.20, which is exactly the “invisible channel” scenario flagged above. SAFE_DIVIDE returns NULL rather than a meaningless percentage when the current base is zero. (This example also reconciles with the £815.43 total from the Revenue Audit example above; it’s the same illustrative purchase set, just split by channel instead of by session_status.)

Summary: Your Workflow

Used together, these four queries take you from “the data looks wrong” to a precise diagnosis:

  • The Impacted Session Audit shows the scale of the problem; how many sessions are affected, and which landing pages are most implicated.
  • The Fragmentation Audit explains the inflated session count stakeholders are seeing in the UI.
  • The Revenue Audit puts a total figure on what it’s costing.
  • The Channel Revenue Recovery breaks that figure down by channel, turning “this is costing us money” into “this is costing the paid social budget specifically”, which is usually what actually gets a fix prioritised.

Reconstructing the session logic yourself in BigQuery means you’re not relying on GA4’s black-box session counting, you’re working from the raw event stream, so the numbers are as close to ground truth as you’re going to get.

Hopefully you found the above useful - do not hesitate to reach out with any questions.

Newsletter

Occasional notes on data, tracking & AI-readiness

No spam, no growth-hacking. Just an email when there's something worth reading.