How-to
Advanced Data Transformation And CleaningDeep dive

Build a Funnel Conversion Table from Event Data

Turn a raw event log exported to Excel into a conversion funnel with pandas: one timestamp per user and step, an ordered funnel that only counts steps reached after the previous one within a time window, step-to-step and overall conversion, drop-off counts, a breakdown by channel, median time between steps, and an Excel sheet with data bars.

A product team exports an event log — every visit, sign-up, trial start and purchase, one row per event — and asks the obvious question: of the people who came, how many signed up, how many started a trial, how many bought, and where do we lose them? That is a funnel. Counting distinct users per event type looks like the answer and is not: someone who bought without ever starting a trial, or who signed up a year after their visit, inflates the later steps, and a person with five visits counts five times unless you are careful. A proper funnel follows each user through the steps in order, within a time window, and counts each user once per step. This page builds that with pandas, adds conversion and drop-off, breaks the funnel down by acquisition channel, measures how long each step takes, and writes the table to Excel. It belongs to Creating Pivot Tables from Excel Data.

Prerequisites

Bash
pip install pandas openpyxl

events.xlsx is an event log of about 10,000 rows for 4,000 users in August and September 2026: user ID, event name (visit, signup, trial, purchase), timestamp and acquisition channel. Like real logs it is unsorted, has repeated visits per user, and contains a few out-of-order events — purchases without a trial, and sign-ups recorded before the first visit.

Step 1 — The naive count, and why it misleads

Counting distinct users per event is quick:

Python
import pandas as pd

events = pd.read_excel("events.xlsx")
STEPS = ["visit", "signup", "trial", "purchase"]
naive = events.groupby("Event")["User"].nunique().reindex(STEPS)
print(naive.to_string())

These numbers count anyone who ever had each event, regardless of order. A user who bought directly through a salesperson appears under "purchase" without having started a trial, and a user whose sign-up was logged before their first visit appears under "signup" without having come through the front door first. Each such case makes the later steps look better than the journey actually performs. The differences are small here, but in real logs — with imports from other systems, re-used accounts and clock problems — they can be large, and they are invisible in a naive count.

Step 2 — First time each user reached each step

Reduce the log to one timestamp per user and step — the first time it happened — and spread the steps into columns:

Python
first = (events.groupby(["User", "Event"])["Timestamp"].min()
               .unstack("Event").reindex(columns=STEPS))
channel = events.groupby("User")["Channel"].first()
print(first.head(4))
print("users:", len(first))

Each row is now one user and each column the first time they reached a step, or NaT if never. Repeated visits collapse to the earliest, so heavy browsers count once. The channel is taken per user (here every event of a user shares a channel; in real data you would choose the first-touch channel deliberately). This wide shape makes the ordered checks in the next step one comparison per column.

An event log becomes one row per user A user's three visits, a signup and a trial in the log become one row with the first visit time, the signup time, the trial time and no purchase time. A B C 1 User Event Time 2 U00042 visit 02 Aug 10:15 3 U00042 visit 03 Aug 18:02 4 U00042 signup 02 Aug 21:40 5 U00042 trial 04 Aug 09:12 visit signup trial purchase 02 Aug 10:15 02 Aug 21:40 04 Aug 09:12 — event log per user groupby min + unstack

Step 3 — An ordered funnel with a conversion window

A user counts for a step only if they also reached the previous step, after it, and within a window — here 30 days from their first visit:

Python
WINDOW = pd.Timedelta(days=30)
reached = pd.DataFrame(index=first.index)
reached["visit"] = first["visit"].notna()
prev_time = first["visit"]
for step in STEPS[1:]:
    ok = reached[STEPS[STEPS.index(step) - 1]] & first[step].notna() & (first[step] >= prev_time) \
         & (first[step] <= first["visit"] + WINDOW)
    reached[step] = ok
    prev_time = first[step].where(ok)

funnel = pd.DataFrame({"Users": reached.sum()})
funnel["Naive count"] = naive
funnel["Step conversion %"] = (funnel["Users"] / funnel["Users"].shift(1) * 100).round(1)
funnel["Overall %"] = (funnel["Users"] / funnel["Users"].iloc[0] * 100).round(1)
funnel["Lost at this step"] = (funnel["Users"].shift(1) - funnel["Users"]).fillna(0).astype(int)
print(funnel.to_string())

Each step requires the previous step to be reached, the event to happen no earlier than the previous one, and within 30 days of the first visit. The ordered counts are lower than the naive ones at every step after the first — 1,085 sign-ups instead of 1,118, and 227 purchases instead of 266. The sign-ups logged before a visit, the purchases without a trial and the purchases that came more than 30 days after the first visit no longer count. The purchase step shrinks by about 15%, which is exactly the kind of overstatement a naive funnel hands to whoever is setting conversion targets. Step conversion divides each step by the one before; overall conversion divides by the number of visitors. The "lost" column is what product teams act on — the largest loss is the visit-to-signup step, which is typical: most visitors leave without signing up. The window is a business choice; a longer window counts slow converters, a shorter one gives an earlier, comparable read on recent cohorts.

Users reaching each step, in order, within 30 days All 4,000 users visited. Roughly a quarter signed up, about half of those started a trial, and around two fifths of trial users purchased. The biggest loss is between visit and signup. visit 4,000 · 100% signup 1,085 · 27.1% trial 582 · 14.6% purchase 227 · 5.7%

Step 4 — The funnel by channel, and time between steps

The same reached-flags grouped by channel show where each channel loses people, and the timestamps give the typical time between steps:

Python
by_channel = reached.groupby(channel).sum()[STEPS]
rates = by_channel.div(by_channel["visit"], axis=0).mul(100).round(1)
rates.columns = [f"{s} %" for s in STEPS]
print(pd.concat([by_channel, rates.iloc[:, 1:]], axis=1).to_string())

def median_gap(a, b):
    mask = reached[b]
    return (first.loc[mask, b] - first.loc[mask, a]).median()

for a, b in zip(STEPS, STEPS[1:]):
    print(f"{a} → {b}: median {median_gap(a, b)}")

Email visitors convert best at every step — 12.2% of them end up purchasing — and Social worst at 2.1% — the kind of difference that changes where marketing money goes. The median time from visit to sign-up is under a day, from sign-up to trial a day or two, and from trial to purchase about a week, which suggests when follow-up emails should be sent. Medians are used because conversion times are heavily skewed: a few users who come back weeks later would drag a mean far from the typical journey.

Step 5 — Write the funnel to Excel

Python
from openpyxl.formatting.rule import DataBarRule
from openpyxl.styles import Font

with pd.ExcelWriter("funnel.xlsx", engine="openpyxl") as xw:
    funnel.reset_index(names="Step").to_excel(xw, sheet_name="Funnel", index=False)
    pd.concat([by_channel, rates.iloc[:, 1:]], axis=1).to_excel(xw, sheet_name="By channel")
    ws = xw.sheets["Funnel"]
    ws.conditional_formatting.add(f"B2:B{len(funnel) + 1}", DataBarRule(start_type="num", start_value=0,
                                  end_type="max", color="4338CA", showValue=True))
    for c in ws[1]:
        c.font = Font(bold=True)
    for col, w in zip("ABCDEF", (10, 12, 12, 18, 11, 16)):
        ws.column_dimensions[col].width = w
print("written: Funnel and By channel sheets")

Data bars on the user counts turn the Funnel sheet into a funnel picture without a chart, and the By channel sheet holds the per-channel counts and rates for anyone who wants to dig further. Data bars and other visual conditional formats are covered in Add Data Bars and Colour Scales with openpyxl, and a chart version in Create a Funnel Chart in Excel with Python.

Naive counts versus an ordered funnel Counting distinct users per event includes people who skipped steps or whose events are out of order, overstating later steps. An ordered funnel counts a user at a step only after the previous step and within the window, so each step is a subset of the one before. distinct users per event purchases without a trial count signups before a visit count steps are not subsets ordered funnel, 30-day window previous step required in time order, within window each step ⊆ the one before

Funnels over time

A single funnel answers "how do we convert overall"; teams usually also want to know whether conversion is improving. Group the per-user flags by the week or month of the first visit — a cohort — instead of by channel, and each row becomes the funnel for the visitors who arrived in that period. Read the most recent cohorts with care: visitors from the last few days have not yet had the full 30-day window to sign up and buy, so their conversion looks artificially low. Either leave cohorts out until their window has closed, or show them greyed out with a note. The retention version of this idea is covered in Build a Cohort Retention Table from Excel Data.

Common pitfalls

  • Counting events instead of users. Repeat visits inflate the top of the funnel; take the first per user.
  • Ignoring order. Users who skip or reorder steps make later steps look better.
  • No conversion window. Old visitors keep trickling into later steps; fix a window.
  • Comparing recent cohorts too early. Users who visited yesterday have not had time to convert.
  • Means for conversion times. Use medians; the distribution is skewed.

Conclusion

A conversion funnel from an event log takes three steps: reduce the log to each user's first timestamp per step, require each step to follow the previous one within a conversion window, and count users per step. From those flags come step and overall conversion, drop-off at each step, a channel breakdown and median times between steps. Write the table with data bars for a quick visual, and remember that recent visitors need time before their conversion rates are comparable.

Frequently asked questions

How do I calculate funnel conversion rates in pandas? Take each user's first timestamp for every step, mark a step as reached only if the previous step was reached earlier and within a time window, sum the flags per step, and divide each step by the previous one and by the first step.

Why is my funnel larger than expected at later steps? Counting distinct users per event includes people who skipped earlier steps or whose events are out of order. Require each step to follow the previous one.

What conversion window should I use? One that matches your sales cycle, for example 7 days for a checkout and 30 or 90 days for a subscription trial. Apply it consistently so periods can be compared.

How do I compare funnels between channels? Group the per-user reached flags by channel, sum them, and divide by each channel's number of visitors.

More on pivots & summaries

More on pivots & summaries →