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.

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.

idnamesigned_up
1Ada2026-01-05
2Ben2026-01-06
3Cy2026-01-12
4Dee2026-01-13
5Eli2026-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.

iduser_idhappened_atevent
112026-01-05 09:00:00view
212026-01-05 09:05:00run
312026-01-05 09:06:00run
412026-01-05 09:15:00enquiry
512026-01-05 09:45:00view
612026-01-05 10:20:00view
722026-01-06 10:00:00run
822026-01-06 10:05:00view

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;
Expected result · sample data3 rows
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;
Expected result · sample data9 rows
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;
Expected result · sample data1 row
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
Your target result6 rows
user_id session_no
1 2
1 3
1 4
2 1
2 2
5 1
Sample data is loaded for you.

Preparing the editor…

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.