Ben has a page view, an example run, and an enquiry. He still should not count as a completed tutorial funnel. His example run happened before the page view.
A funnel is a sequence with rules, not a shopping list of event names. We will build one where readers can inspect the evidence behind every count.
Explore the idea
Having the events is only half the story
Inspect a visitor’s first session against view → run → enquiry, within 30 minutes of entry.
Yes
completed the ordered funnel
Repeated runs still describe one conversion
Ada views at 09:00, runs at 09:05 and 09:06, and enquires at 09:15. Select the earliest valid run; count the session once.
No
run happened before the view
Presence-only counting admits the wrong sequence
Ben runs at 10:00, views at 10:05, and enquires at 10:08. There is no qualifying run after his entry view.
No
enquiry belongs to a later session
The right order is not sufficient either
Cy views at 09:00 and runs at 09:02. The 10:00 enquiry is beyond the 30-minute conversion window and follows a 58-minute inactivity gap.
Yes
completed the ordered funnel
Three steps, in sequence, inside the window
Dee views at 11:00, runs at 11:04, and enquires at 11:12. This is the second qualifying session in the fixture.
Define the question before counting
We use the fictional website from the session tutorial. The full setup is included here too. A new session begins after more than 30 minutes of inactivity.
Our funnel starts at the first view in each session. A qualifying run must be strictly later than that view. An enquiry must be strictly later than the qualifying run. Both later steps must fall within 30 minutes of the first view, including the deadline. All three events must share the same user and session.
visitors5 rows
Meet the fictional visitors
Five stable user IDs; signup dates also anchor the retention lesson.
| id | name | signed_up |
|---|---|---|
| 1 | Ada | 2026-01-05 |
| 2 | Ben | 2026-01-06 |
| 3 | Cy | 2026-01-12 |
| 4 | Dee | 2026-01-13 |
| 5 | Eli | 2026-01-19 |
events19 rows
A tiny website event log
All 19 synthetic events are included in the CSV and setup. Times use one shared UTC clock.
| id | user_id | happened_at | event |
|---|---|---|---|
| 1 | 1 | 2026-01-05 09:00:00 | view |
| 2 | 1 | 2026-01-05 09:05:00 | run |
| 3 | 1 | 2026-01-05 09:06:00 | run |
| 4 | 1 | 2026-01-05 09:15:00 | enquiry |
| 5 | 1 | 2026-01-05 09:45:00 | view |
| 6 | 1 | 2026-01-05 10:20:00 | view |
| 7 | 2 | 2026-01-06 10:00:00 | run |
| 8 | 2 | 2026-01-06 10:05:00 | view |
This is a session conversion rate. A returning person can contribute more than one entry. Events with equal timestamps do not establish a strict sequence here; a production event sequence field could support a different tie policy.
See how presence-only counting goes wrong
This query checks only whether each kind of event exists:
A presence-only check counts Ben as a conversion
SELECT user_id, session_no FROM session_events
GROUP BY user_id, session_no
HAVING bool_or(event = 'view') AND bool_or(event = 'run')
AND bool_or(event = 'enquiry')
ORDER BY user_id, session_no;
| user_id | session_no |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 4 | 1 |
It returns three sessions: Ada’s, Ben’s, and Dee’s. Ben ran the example at 10:00, viewed at 10:05, and enquired at 10:08. Every event name exists, but the required journey does not.
Cy illustrates another boundary: the enquiry arrives 58 minutes after the run, in a new session. It cannot complete the earlier session’s funnel.
Find the first valid next step
Build the sequence in stages. First find the entry timestamp. Then find the earliest eligible run after it. Finally find the earliest eligible enquiry after that run.
Choose the first valid event after each preceding step
WITH entries AS (
SELECT user_id, session_no,
min(happened_at) FILTER (WHERE event = 'view') AS viewed_at
FROM session_events GROUP BY user_id, session_no
), runs AS (
SELECT v.*, min(e.happened_at) AS ran_at
FROM entries v LEFT JOIN session_events e
ON e.user_id = v.user_id AND e.session_no = v.session_no
AND e.event = 'run' AND e.happened_at > v.viewed_at
AND e.happened_at <= v.viewed_at + INTERVAL 30 MINUTE
WHERE v.viewed_at IS NOT NULL
GROUP BY v.user_id, v.session_no, v.viewed_at
)
SELECT r.*, min(e.happened_at) AS enquired_at
FROM runs r LEFT JOIN session_events e
ON e.user_id = r.user_id AND e.session_no = r.session_no
AND e.event = 'enquiry' AND e.happened_at > r.ran_at
AND e.happened_at <= r.viewed_at + INTERVAL 30 MINUTE
GROUP BY r.user_id, r.session_no, r.viewed_at, r.ran_at
ORDER BY r.user_id, r.session_no;
| user_id | session_no | viewed_at | ran_at | enquired_at |
|---|---|---|---|---|
| 1 | 1 | 2026-01-05 09:00:00 | 2026-01-05 09:05:00 | 2026-01-05 09:15:00 |
| 1 | 2 | 2026-01-05 10:20:00 | NULL | NULL |
| 1 | 3 | 2026-01-14 12:00:00 | NULL | NULL |
| 1 | 4 | 2026-01-20 14:00:00 | NULL | NULL |
| 2 | 1 | 2026-01-06 10:05:00 | NULL | NULL |
| 2 | 2 | 2026-01-20 12:00:00 | NULL | NULL |
| 3 | 1 | 2026-01-12 09:00:00 | 2026-01-12 09:02:00 | NULL |
| 4 | 1 | 2026-01-13 11:00:00 | 2026-01-13 11:04:00 | 2026-01-13 11:12:00 |
| 5 | 1 | 2026-01-19 09:00:00 | NULL | NULL |
Scroll for more columns →
Notice where the event and time conditions sit: inside each LEFT JOIN condition. That preserves an entry when no next step exists, leaving its timestamp NULL. Moving those conditions into a later WHERE would remove the dropouts we need to measure.
min chooses one qualifying timestamp per stage, so Ada’s repeated run does not duplicate her session. Choosing the earliest eligible run also leaves the greatest possible time for the enquiry under this policy. A later view does not restart this funnel’s fixed clock.
The setup saves these stages as funnel_steps; every browser example can run independently.
Count the journeys that remain
Count qualifying sessions, not event rows
SELECT count(*) AS entered,
count(ran_at) AS ran_example,
count(enquired_at) AS sent_enquiry,
round(100.0 * count(enquired_at) / nullif(count(*), 0), 2) AS conversion_pct
FROM funnel_steps;
| entered | ran_example | sent_enquiry | conversion_pct |
|---|---|---|---|
| 9 | 3 | 2 | 22.22 |
Scroll for more columns →
Nine sessions contain an entry view. Three proceed to a valid run. Two finish with an enquiry: Ada’s first session and Dee’s session. The entry-to-enquiry rate is 2 / 9 = 22.22%.
count(*) counts all entries. count(ran_at) and count(enquired_at) ignore NULL timestamps, counting only reached steps. nullif avoids division by zero when a filtered dataset has no entries.
Your turn · check your answer
Find where the funnel stops before the example
Return user_id and session_no for sessions with a view but no qualifying run.
See the expected result
| user_id | session_no |
|---|---|
| 1 | 2 |
| 1 | 3 |
| 1 | 4 |
| 2 | 1 |
| 2 | 2 |
| 5 | 1 |
Preparing the editor…
Your result
Need a hint?
funnel_steps already contains only sessions with a view. Test ran_at, not enquired_at.
Show a solution
SELECT user_id, session_no FROM funnel_steps WHERE ran_at IS NULL ORDER BY user_id, session_no;
Match the metric to the decision
This result diagnoses recorded behavior, not why someone stopped. Missing events, blocked tracking, and a broken form can resemble abandonment. Check instrumentation before drawing a product conclusion from a changing funnel.
To compare acquisition channels, attach the entry session’s channel under a clear attribution rule, then group this result. To ask whether people return later, move to retention cohorts. Keeping those questions separate makes both metrics easier to explain.