Approach: for each user and calendar date (UTC) mark whether they performed each funnel step that day, then aggregate unique users per step per day and compute conversion rates and step drop-offs.sql
WITH user_day AS (
-- For each user and date, mark whether they did each event
SELECT
CAST(event_time AT TIME ZONE 'UTC' AS DATE) AS dt,
user_id,
MAX(CASE WHEN event_name = 'view_product' THEN 1 ELSE 0 END) AS viewed,
MAX(CASE WHEN event_name = 'add_to_cart' THEN 1 ELSE 0 END) AS added,
MAX(CASE WHEN event_name = 'begin_checkout' THEN 1 ELSE 0 END) AS began,
MAX(CASE WHEN event_name = 'purchase' THEN 1 ELSE 0 END) AS purchased
FROM events
WHERE event_time >= (CURRENT_DATE AT TIME ZONE 'UTC') - INTERVAL '13 days'
AND event_time < (CURRENT_DATE AT TIME ZONE 'UTC') + INTERVAL '1 day'
AND event_name IN ('view_product','add_to_cart','begin_checkout','purchase')
GROUP BY 1, user_id
),
daily_counts AS (
-- Aggregate unique users per step per day
SELECT
dt,
SUM(viewed) AS unique_users_view,
SUM(added) AS unique_users_add,
SUM(began) AS unique_users_begin_checkout,
SUM(purchased) AS unique_users_purchase
FROM user_day
GROUP BY dt
)
SELECT
dt AS date,
unique_users_view,
unique_users_add,
unique_users_begin_checkout,
unique_users_purchase,
-- conversion rate view -> purchase (null-safe)
CASE WHEN unique_users_view = 0 THEN 0.0
ELSE ROUND(100.0 * unique_users_purchase::numeric / unique_users_view, 2)
END AS conversion_rate_view_to_purchase_pct,
-- step drop-offs as percent dropped from previous step
CASE WHEN unique_users_view = 0 THEN 0.0
ELSE ROUND(100.0 * (1 - unique_users_add::numeric / unique_users_view), 2)
END AS drop_view_to_add_pct,
CASE WHEN unique_users_add = 0 THEN 0.0
ELSE ROUND(100.0 * (1 - unique_users_begin_checkout::numeric / unique_users_add), 2)
END AS drop_add_to_begin_pct,
CASE WHEN unique_users_begin_checkout = 0 THEN 0.0
ELSE ROUND(100.0 * (1 - unique_users_purchase::numeric / unique_users_begin_checkout), 2)
END AS drop_begin_to_purchase_pct
FROM daily_counts
ORDER BY dt DESC
LIMIT 14;
Key points:- user_day ensures uniqueness per user/day (prevents double-counting multiple events).- Uses UTC date truncation to get calendar day.- Conversion and drop-offs are percent values (0 when divisor is 0 to avoid NULL/div-by-zero).Edge cases:- Users performing steps out of order on same day are still counted for each step independently (typical daily funnel view).- If you require strict ordered funnels (view → add → begin → purchase in sequence), you'd need per-user per-day event_time ordering and check monotonic progression (more complex).Alternatives:- For strict sequence, use row_number()/min timestamps per step per user-day and require timestamps to be increasing.- For large tables, pre-aggregate or use partitioned/date-sharded tables for performance.