How to Implement Funnel Analysis SQL Patterns: A Step-by-Step Guide
Funnel analysis SQL patterns measure user progression through sequential steps by deduplicating events, self-joining tables on user identifiers and timestamps, and calculating conversion rates to identify drop-off points.
Funnel analysis is essential for tracking how users move from initial engagement to final conversion in event-driven systems. The DataExpert-io/data-engineer-handbook repository provides a production-ready SQL implementation that transforms raw clickstream data into actionable conversion metrics without requiring external analytics tools. This guide breaks down the exact pattern used in intermediate-bootcamp/materials/4-applying-analytical-patterns/lecture-lab/funnel_analysis.sql to help you build robust funnel queries for any data warehouse.
Understanding the Funnel Analysis SQL Architecture
The pattern implemented in the DataExpert-io/data-engineer-handbook uses a three-stage Common Table Expression (CTE) pipeline. This approach handles data quality issues first, then identifies conversion paths through self-joins, and finally aggregates results into digestible metrics.
Deduplicating Raw Events
Duplicate events from client-side logging or network retries can skew conversion rates. The first CTE eliminates these by grouping on all event columns:
WITH deduped_events AS (
SELECT url, host, user_id, event_time
FROM events
GROUP BY url, host, user_id, event_time
)
This GROUP BY operation collapses identical rows while preserving all distinct user interactions.
Cleaning and Structuring Event Data
The second CTE adds temporal granularity and filters invalid records:
clean_events AS (
SELECT *, DATE(event_time) AS event_date
FROM deduped_events
WHERE user_id IS NOT NULL
ORDER BY user_id, event_time
)
Extracting event_date enables day-level windowing, while removing null user_id values ensures every event can be attributed to a specific user journey.
Self-Joining for Conversion Detection
The core logic uses a self-join to find downstream conversions. For each event (ce1), the query looks for later events (ce2) from the same user on the same day:
converted AS (
SELECT
ce1.user_id,
ce1.event_time,
ce1.url,
COUNT(DISTINCT CASE WHEN ce2.url = '/api/v1/user' THEN ce2.url END) AS converted
FROM clean_events ce1
JOIN clean_events ce2
ON ce2.user_id = ce1.user_id
AND ce2.event_date = ce1.event_date
AND ce2.event_time > ce1.event_time
GROUP BY ce1.user_id, ce1.event_time, ce1.url
)
The join conditions ensure temporal ordering (ce2.event_time > ce1.event_time) and same-day scope (ce2.event_date = ce1.event_date), while the CASE statement identifies specific conversion URLs.
Aggregating Conversion Metrics
The final aggregation calculates totals and rates:
SELECT
url,
COUNT(1) AS total_events,
CAST(SUM(converted) AS REAL) / COUNT(1) AS conversion_rate
FROM converted
GROUP BY url
HAVING conversion_rate > 0 AND total_events > 100;
The HAVING clause filters out statistically insignificant steps, focusing analysis only on high-volume funnel stages with measurable conversions.
Complete Funnel Analysis SQL Implementation
Here is the full production query from the DataExpert-io/data-engineer-handbook repository. This pattern references the events table defined in intermediate-bootcamp/materials/2-fact-data-modeling/tables/events.sql:
WITH deduped_events AS (
SELECT url, host, user_id, event_time
FROM events
GROUP BY url, host, user_id, event_time
),
clean_events AS (
SELECT *, DATE(event_time) AS event_date
FROM deduped_events
WHERE user_id IS NOT NULL
ORDER BY user_id, event_time
),
converted AS (
SELECT
ce1.user_id,
ce1.event_time,
ce1.url,
COUNT(DISTINCT CASE WHEN ce2.url = '/api/v1/user' THEN ce2.url END) AS converted
FROM clean_events ce1
JOIN clean_events ce2
ON ce2.user_id = ce1.user_id
AND ce2.event_date = ce1.event_date
AND ce2.event_time > ce1.event_time
GROUP BY ce1.user_id, ce1.event_time, ce1.url
)
SELECT
url,
COUNT(1) AS total_events,
CAST(SUM(converted) AS REAL) / COUNT(1) AS conversion_rate
FROM converted
GROUP BY url
HAVING conversion_rate > 0 AND total_events > 100;
Customizing Funnel Analysis SQL for Your Schema
Adapt this pattern by modifying three key components:
- Conversion criteria: Replace
'/api/v1/user'with your specific conversion URL, page path, or event type. - Temporal windows: Adjust the
event_datematching to span multiple days for longer consideration periods, or remove the date constraint for cross-day funnel analysis. - Step granularity: Add intermediate CTEs between
clean_eventsandconvertedto track specific funnel stages (e.g., product_view → add_to_cart → checkout).
Summary
- Deduplicate first: Use
GROUP BYon all event columns indeduped_eventsto eliminate logging duplicates that inflate counts. - Clean before joining: Filter null
user_idvalues and extract dates inclean_eventsto ensure accurate user attribution. - Self-join with constraints: Match
user_idandevent_datewhile enforcingevent_time >to find legitimate downstream conversions. - Filter for significance: Apply
HAVING conversion_rate > 0 AND total_events > 100to focus on meaningful funnel steps. - Source files: Reference
funnel_analysis.sqlfor the complete implementation andevents.sqlfor the underlying table schema in the DataExpert-io/data-engineer-handbook repository.
Frequently Asked Questions
How do I handle multi-day funnel analysis in SQL?
Remove the AND ce2.event_date = ce1.event_date condition from the self-join to allow conversions across calendar days. For specific lookback windows (e.g., 7-day conversion), replace with AND ce2.event_date <= ce1.event_date + INTERVAL '7 days'.
Can this pattern track multiple funnel steps instead of just one conversion?
Yes. Instead of a binary converted flag, use conditional aggregation with multiple CASE statements checking for different target URLs or event types. Alternatively, chain additional CTEs where each step joins to the previous one, filtering for the specific event sequence at each stage.
Why use a self-join instead of window functions like LEAD()?
Self-joins provide flexibility when conversion events might not immediately follow the initial event, allowing you to search across all subsequent events in a time window. Window functions work well for strictly sequential steps, but self-joins handle non-contiguous conversion paths and multiple potential conversion targets more effectively.
How do I optimize funnel analysis SQL performance on large datasets?
Ensure composite indexes exist on (user_id, event_date, event_time) and (url) columns. Consider partitioning the events table by event_date to enable partition pruning. For billions of rows, materialize the deduped_events CTE as a temporary table or use approximate distinct counting algorithms if exact precision is not critical.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →