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_date matching 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_events and converted to track specific funnel stages (e.g., product_view → add_to_cart → checkout).

Summary

  • Deduplicate first: Use GROUP BY on all event columns in deduped_events to eliminate logging duplicates that inflate counts.
  • Clean before joining: Filter null user_id values and extract dates in clean_events to ensure accurate user attribution.
  • Self-join with constraints: Match user_id and event_date while enforcing event_time > to find legitimate downstream conversions.
  • Filter for significance: Apply HAVING conversion_rate > 0 AND total_events > 100 to focus on meaningful funnel steps.
  • Source files: Reference funnel_analysis.sql for the complete implementation and events.sql for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →