A checkout funnel answers the most basic e-commerce question: where are we losing people? GA4 tracks the standard e-commerce flow through a sequence of events, and building a funnel from BigQuery data requires combining the composite session key with COUNT(DISTINCT CASE WHEN ...) aggregation.
This pattern uses session-level funnel analysis — counting how many sessions reached each stage, not how many events fired. The distinction matters because a user might view the same product five times in one session; that’s one session that viewed a product, not five funnel entries.
The Full Checkout Funnel Query
WITH funnel_events AS ( SELECT CONCAT( user_pseudo_id, '-', (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') ) AS session_id, event_name, event_timestamp FROM `project.dataset.events_*` WHERE event_name IN ( 'view_item', 'add_to_cart', 'begin_checkout', 'add_shipping_info', 'add_payment_info', 'purchase' ) AND _TABLE_SUFFIX BETWEEN '20240101' AND '20240131')
SELECT COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN session_id END) AS sessions_viewed_item, COUNT(DISTINCT CASE WHEN event_name = 'add_to_cart' THEN session_id END) AS sessions_added_to_cart, COUNT(DISTINCT CASE WHEN event_name = 'begin_checkout' THEN session_id END) AS sessions_began_checkout, COUNT(DISTINCT CASE WHEN event_name = 'add_shipping_info' THEN session_id END) AS sessions_added_shipping, COUNT(DISTINCT CASE WHEN event_name = 'add_payment_info' THEN session_id END) AS sessions_added_payment, COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN session_id END) AS sessions_purchased, SAFE_DIVIDE( COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN session_id END), COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN session_id END) ) AS overall_conversion_rateFROM funnel_eventsHow the Pattern Works
The CTE extracts only the funnel-relevant events, keeping the query efficient by filtering early. The CONCAT builds the composite session key — user_pseudo_id alone isn’t granular enough (one user has many sessions), and ga_session_id alone isn’t unique across users.
The main query uses COUNT(DISTINCT CASE WHEN event_name = '...' THEN session_id END) for each stage. The CASE WHEN returns the session ID only for matching events; COUNT(DISTINCT ...) then counts unique sessions at that stage. Sessions that didn’t reach a stage return NULL from the CASE, which COUNT(DISTINCT) ignores.
SAFE_DIVIDE prevents division-by-zero errors when the top of the funnel has zero sessions (possible in narrow date ranges or heavily filtered queries).
Reading the Results
A healthy checkout funnel shows gradual step-down rates. Typical patterns:
- view_item to add_to_cart: 5-15% is normal for most e-commerce. Below 5% suggests pricing, product page, or trust issues.
- add_to_cart to begin_checkout: 30-50%. Drop-off here often points to cart abandonment — shipping cost surprise, account creation requirements, or unclear next steps.
- begin_checkout to purchase: 50-70%. Significant drop-off here typically indicates payment friction, unexpected costs revealed at checkout, or technical failures in the payment flow.
When a particular step shows outsized drop-off compared to benchmarks, investigate the page-level experience at that stage. The funnel tells you where people leave; figuring out why requires qualitative analysis or A/B testing.
Adding Product-Level Funnel Analysis
The basic funnel shows overall flow. To see which products have the best or worst conversion, add the items UNNEST:
SELECT item.item_name, item.item_category, COUNTIF(event_name = 'view_item') AS product_views, COUNTIF(event_name = 'add_to_cart') AS add_to_carts, COUNTIF(event_name = 'purchase') AS purchases, SAFE_DIVIDE( COUNTIF(event_name = 'add_to_cart'), COUNTIF(event_name = 'view_item') ) AS view_to_cart_rate, SAFE_DIVIDE( COUNTIF(event_name = 'purchase'), COUNTIF(event_name = 'add_to_cart') ) AS cart_to_purchase_rateFROM `project.dataset.events_*`, UNNEST(items) AS itemWHERE event_name IN ('view_item', 'add_to_cart', 'purchase') AND _TABLE_SUFFIX BETWEEN '20240101' AND '20240131'GROUP BY item.item_name, item.item_categoryHAVING product_views > 100ORDER BY product_views DESCNotice this uses COUNTIF rather than COUNT(DISTINCT CASE WHEN ...). At the product level, event counts (not session counts) are more natural — you want to know how many times a product was viewed, added to cart, and purchased. The CROSS JOIN on items is safe here because we filter to ecommerce events that always have items populated.
The HAVING product_views > 100 filter eliminates products with too few views for meaningful conversion rates. A product with 3 views and 1 purchase has a 33% view-to-cart rate, but that number is meaningless.
Temporal Funnel Analysis
To track funnel performance over time, add a date dimension:
WITH funnel_events AS ( SELECT PARSE_DATE('%Y%m%d', event_date) AS event_date, CONCAT( user_pseudo_id, '-', (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') ) AS session_id, event_name FROM `project.dataset.events_*` WHERE event_name IN ('view_item', 'add_to_cart', 'begin_checkout', 'purchase') AND _TABLE_SUFFIX BETWEEN '20240101' AND '20240131')
SELECT event_date, COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN session_id END) AS sessions_viewed, COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN session_id END) AS sessions_purchased, SAFE_DIVIDE( COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN session_id END), COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN session_id END) ) AS conversion_rateFROM funnel_eventsGROUP BY event_dateORDER BY event_dateThis gives you a daily conversion rate trend line. Spikes and dips correlate with promotions, site changes, or tracking issues. If conversion rate drops suddenly without a known cause, check whether any funnel events stopped firing — a broken checkout tag is a common culprit.
Important Caveats
This is a loose funnel, not a strict sequence. The query counts sessions that have each event, regardless of order. A session where someone purchased and then viewed another item still counts as having both view_item and purchase. For strict sequential funnel analysis (where step N must occur after step N-1), you need event timestamp ordering and window functions — a significantly more complex query.
Revenue should come from the ecommerce RECORD, not items. If you add revenue to this funnel, use ecommerce.purchase_revenue on the purchase event, not SUM(item.item_revenue) after unnesting items. The latter requires careful handling to avoid double-counting when a transaction has multiple items. See GA4 E-commerce Schema in BigQuery for the revenue calculation patterns.
Sessions can span midnight. A session that starts at 11:55 PM and purchases at 12:05 AM has events in two different event_date values. The temporal funnel above counts by event date, not session date. For session-date attribution, join against a session table that assigns a single date to each session.