Funnel Analysis with CTEs
Funnel Analysis with CTEs
Funnel analysis answers: "Of all the users who started a process, how many completed each subsequent step?" It's fundamental in product analytics, sales, and marketing. CTEs make funnel queries readable and maintainable.
What Is a Funnel?
A funnel tracks users through a sequential series of ec_events. At each step, some users drop off. The shape narrows — like a funnel — because completion rates decrease.
Classic examples:
- E-commerce: page_view → add_to_cart → checkout_start → purchase
- SaaS: signup → email_verified → first_login → feature_used → subscription
- Finance: invoice_sent → invoice_viewed → payment_made
Ordered vs Unordered Funnels
Unordered funnel: Did the user do all these things (in any order)? Ordered funnel: Did the user do these things in this specific sequence?
We'll cover both, starting with the simpler ordered funnel.
Example 1: Basic Event Funnel (ShopMetrics)
WITH funnel_steps AS (
-- Count distinct users who performed each event type
SELECT
event_type,
COUNT(DISTINCT customer_id) AS users
FROM ec_events
WHERE event_type IN ('page_view', 'add_to_cart', 'purchase')
GROUP BY event_type
),
ordered_funnel AS (
SELECT
event_type,
users,
CASE event_type
WHEN 'page_view' THEN 1
WHEN 'add_to_cart' THEN 2
WHEN 'purchase' THEN 3
END AS step_num
FROM funnel_steps
),
with_rates AS (
SELECT
step_num,
event_type,
users,
FIRST_VALUE(users) OVER (ORDER BY step_num) AS top_of_funnel,
LAG(users) OVER (ORDER BY step_num) AS prev_step_users
FROM ordered_funnel
)
SELECT
step_num,
event_type AS step,
users,
ROUND(100.0 * users / top_of_funnel, 1) AS pct_of_top,
ROUND(100.0 * users / NULLIF(prev_step_users, 0), 1) AS step_conversion_pct
FROM with_rates
ORDER BY step_num;
This gives you:
- Absolute users at each step
- % of top-of-funnel (overall conversion rate)
- Step-to-step conversion rate (where is the biggest drop-off?)
Example 2: Ordered Funnel — Strict Sequence
The previous query counts users who ever did the event — but not necessarily in order. A stricter funnel requires each step to occur after the previous one:
WITH page_views AS (
SELECT DISTINCT customer_id, MIN(created_at) AS first_view
FROM ec_events WHERE event_type = 'page_view'
GROUP BY customer_id
),
add_to_carts AS (
SELECT DISTINCT e.customer_id, MIN(e.created_at) AS first_cart
FROM ec_events e
JOIN page_views pv ON pv.customer_id = e.customer_id
WHERE e.event_type = 'add_to_cart'
AND e.created_at > pv.first_view -- must happen AFTER view
GROUP BY e.customer_id
),
purchases AS (
SELECT DISTINCT e.customer_id, MIN(e.created_at) AS first_purchase
FROM ec_events e
JOIN add_to_carts atc ON atc.customer_id = e.customer_id
WHERE e.event_type = 'purchase'
AND e.created_at > atc.first_cart -- must happen AFTER cart
GROUP BY e.customer_id
),
funnel AS (
SELECT COUNT(DISTINCT customer_id) AS viewers FROM page_views
UNION ALL
SELECT COUNT(DISTINCT customer_id) FROM add_to_carts
UNION ALL
SELECT COUNT(DISTINCT customer_id) FROM purchases
),
numbered AS (
SELECT *, ROW_NUMBER() OVER () AS step FROM funnel
)
SELECT
step,
CASE step WHEN 1 THEN 'Page View' WHEN 2 THEN 'Add to Cart' WHEN 3 THEN 'Purchase' END AS step_name,
viewers AS users,
ROUND(100.0 * viewers / FIRST_VALUE(viewers) OVER (ORDER BY step), 1) AS overall_pct,
ROUND(100.0 * viewers / LAG(viewers) OVER (ORDER BY step), 1) AS step_pct
FROM numbered
ORDER BY step;
Example 3: Funnel by Cohort
Break the funnel down by when the customer joined (their signup month):
WITH customer_cohorts AS (
SELECT
customer_id,
DATE_TRUNC('month', created_at)::DATE AS cohort_month
FROM ec_customers
),
funnel_by_cohort AS (
SELECT
cc.cohort_month,
e.event_type,
COUNT(DISTINCT e.customer_id) AS users
FROM ec_events e
JOIN customer_cohorts cc ON cc.customer_id = e.customer_id
WHERE e.event_type IN ('page_view', 'add_to_cart', 'purchase')
GROUP BY cc.cohort_month, e.event_type
),
pivoted AS (
SELECT
cohort_month,
MAX(CASE WHEN event_type = 'page_view' THEN users END) AS viewers,
MAX(CASE WHEN event_type = 'add_to_cart' THEN users END) AS added_to_cart,
MAX(CASE WHEN event_type = 'purchase' THEN users END) AS purchasers
FROM funnel_by_cohort
GROUP BY cohort_month
)
SELECT
cohort_month,
viewers,
added_to_cart,
purchasers,
ROUND(100.0 * added_to_cart / NULLIF(viewers, 0), 1) AS view_to_cart_pct,
ROUND(100.0 * purchasers / NULLIF(added_to_cart, 0), 1) AS cart_to_purchase_pct,
ROUND(100.0 * purchasers / NULLIF(viewers, 0), 1) AS overall_conversion_pct
FROM pivoted
ORDER BY cohort_month;
Example 4: Invoice Payment Funnel (Finance Dataset)
Track invoices through their payment lifecycle:
WITH invoice_stages AS (
SELECT
i.invoice_id,
i.client_id,
i.issue_date,
i.due_date,
SUM(ii.quantity * ii.unit_price) AS invoice_total,
MAX(p.payment_date) AS payment_date,
SUM(p.amount) AS paid_amount
FROM invoices i
JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
LEFT JOIN payments p ON p.invoice_id = i.invoice_id
GROUP BY i.invoice_id, i.client_id, i.issue_date, i.due_date
),
classified AS (
SELECT
invoice_id,
client_id,
invoice_total,
CASE
WHEN payment_date IS NULL AND due_date < CURRENT_DATE THEN 'overdue'
WHEN payment_date IS NULL THEN 'pending'
WHEN paid_amount >= invoice_total THEN 'paid_full'
ELSE 'paid_partial'
END AS status
FROM invoice_stages
),
funnel AS (
SELECT
status,
COUNT(*) AS invoice_count,
SUM(invoice_total) AS total_value
FROM classified
GROUP BY status
)
SELECT
status,
invoice_count,
ROUND(total_value, 2) AS total_value,
ROUND(100.0 * invoice_count / SUM(invoice_count) OVER (), 1) AS pct_of_total
FROM funnel
ORDER BY invoice_count DESC;