Build a B2B Funnel Simulator

Simulate a B2B sales funnel in Python and export realistic CRM data.

Introduction

30 Second Summary

In B2B sales, someone fills out a form today and your team might not close that deal for another four months. That time gap between action and outcome makes B2B data fundamentally different from anything in B2C.

In this project, you will build a Python script that simulates a complete B2B sales funnel. You will generate hundreds of leads, push them through five stages with realistic conversion probabilities and time delays, and produce a CSV dataset that looks like a real CRM export.

What You'll Build

Run your script once and watch 500 leads flow through a five-stage pipeline, some closing in 60 days, others taking 200, and some never making it past the first qualification gate.

By the end of this project, you'll have:

  • A Python simulator that generates 500 realistic B2B leads with conversion probabilities and time lag between stages in under a second.
  • A CSV dataset that mirrors a real CRM export, with timestamps showing when each lead reached or dropped out of each funnel stage.
  • The ability to spot the lag problem: leads entering in January are still converting in April, proving why you cannot read a B2B funnel in real-time.
  • Secret Mission: Add real-world CRM messiness to your simulator, including stage-skipping, missing timestamps, and multiple leads from the same account.

Are there any prerequisites?

No prior Python experience is required. Familiarity with marketing or sales funnels is helpful but not necessary. You will learn the Python fundamentals you need as you build.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This project produces a dataset that behaves like a real B2B sales pipeline, where outcomes arrive months after the initial action.

Set Up Your Project

With Python ready to go, you need a dedicated place to write your simulator code. A project folder keeps your script and its output files together in one findable spot on your Desktop.

In this step, you'll create a project folder, set up your Python file, and confirm everything runs cleanly before you start building the funnel logic.

In this step, get ready to:
  • Create a b2b-funnel-sim project folder on your Desktop.
  • Add your first Python import and confirm the file runs without errors.
Create your project folder and file

Your simulator will produce a CSV file alongside your script. Keeping them in a named folder makes both easy to find later.

  • Move to your Desktop by running this command in your VS Code terminal:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir b2b-funnel-sim
cd b2b-funnel-sim
  • Create your Python file by running:
touch funnel_sim.py
  • Confirm the file exists by running:
ls

You should see funnel_sim.py listed in the output.

Don't see funnel_sim.py?

Make sure you ran cd b2b-funnel-sim before touch funnel_sim.py. If you created the file in the wrong directory, delete it and re-run the commands from the mkdir step.

Help me find where my file ended up

  • In VS Code, click **File** in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the b2b-funnel-sim folder, and click Open.

Why open the folder?

Opening the folder (not just the file) tells VS Code to treat b2b-funnel-sim as your workspace. The integrated terminal will default to this folder, so every command you run targets the right place.

Add your first import and verify

The random module is Python's built-in tool for generating random numbers. Your simulator will use it to decide whether each lead converts at every stage.

  • In the VS Code Explorer sidebar on the left, click funnel_sim.py to open it in the editor.
  • Type this line into the file:
import random
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

Before you run this, what do you expect to see in the terminal? Will Python print anything, or will it stay silent?

  • Run your file by entering this in the terminal:
python3 funnel_sim.py

You should see nothing. The terminal returns to your prompt with no output and no errors. That silence means Python successfully imported the random module and your file is ready to build on.

Seeing an error?

  • If you see a SyntaxError, check that the line reads exactly import random with no extra characters or typos.
  • If you see No such file or directory, your terminal is not in the b2b-funnel-sim folder. Open a new terminal in VS Code with Ctrl+`. It should default to the folder you opened.

Help me debug this error when running my Python file

✔️ Awesome, I've got everything!

Great. Double check you've saved funnel_sim.py and you're ready to move on.

ⓧ I'd like to double check the full code

Here is the complete funnel_sim.py at this point:

import random

Your project is set up and Python is running your file. Next up, you'll define what a B2B funnel actually looks like as data and generate your first batch of simulated leads.

Define the Funnel and Generate Leads

Your environment is set up and funnel_sim.py is open with its first import ready to go. Now it's time to encode what a B2B funnel actually looks like as code.

Before you can simulate anything, you need to define the pipeline itself as data. That means stage names, conversion probabilities at each transition, and realistic timing for how long each transition takes.

In this step, get ready to:
  • Define funnel stages, conversion rates, and lag ranges as Python data structures.
  • Write a function that generates leads with random entry dates.
  • Run the script and confirm leads print with different dates across 2025.
Define the funnel parameters

A B2B funnel has defined stages that every lead must pass through. Each transition between stages has two properties: a conversion probability (what percentage of leads advance?) and a time lag (how many days does it take?).

  • In funnel_sim.py, select all the existing code (the single import random line) and replace it with the following imports and configuration:
import random
import csv
from datetime import datetime, timedelta

# Funnel configuration
STAGES = ["form_fill", "mql", "sql", "opportunity", "closed_won"]

CONVERSION_RATES = {
    "form_fill_to_mql": 0.30,
    "mql_to_sql": 0.20,
    "sql_to_opportunity": 0.55,
    "opportunity_to_closed_won": 0.25,
}

LAG_RANGES_DAYS = {
    "form_fill_to_mql": (1, 14),
    "mql_to_sql": (7, 45),
    "sql_to_opportunity": (14, 60),
    "opportunity_to_closed_won": (30, 120),
}

What does this code do?

  • STAGES lists the five stages a lead passes through, from first form fill all the way to a closed deal.
  • CONVERSION_RATES is a dictionary mapping each transition to its probability. For example, only 30% of form fills become MQLs, and only 25% of opportunities close.
  • LAG_RANGES_DAYS stores tuples of (min, max) days each transition takes. Early stages resolve quickly (1-14 days) while closing a deal takes 30-120 days.
  • The csv and datetime imports are for later code in this step that generates dates and saves output.
  • Save funnel_sim.py.

Why these specific conversion rates?

These numbers are based on mid-market B2B SaaS benchmarks. The end-to-end math works out to roughly 0.8% of form fills becoming closed deals (500 leads producing about 4 wins). That matches published pipeline conversion benchmarks.

The lag ranges produce total sales cycles of 52-239 days, centering around 90-120 days. This matches real mid-market B2B where deals take months to close.

Write the generate_leads function

Each lead enters the funnel on a random day within your date range. This function creates a batch of leads, each with a unique ID and a random creation date.

  • Below the LAG_RANGES_DAYS dictionary, add two blank lines and then this function:
def generate_leads(num_leads, start_date, end_date):
    """Generate leads with random creation dates within a date range."""
    leads = []
    date_range = (end_date - start_date).days

    for i in range(num_leads):
        creation_date = start_date + timedelta(days=random.randint(0, date_range))
        lead = {
            "lead_id": i + 1,
            "form_fill_date": creation_date,
        }
        leads.append(lead)

    return leads

What does this function do?

  • date_range calculates how many days span your start and end dates (365 for a full year).
  • random.randint(0, date_range) picks a random day offset, and timedelta(days=...) adds that many days to the start date to get the lead's creation date.
  • Each lead is stored as a dictionary with a lead_id and a form_fill_date. Later steps will add more fields as leads advance through the funnel.
  • Save funnel_sim.py.
Generate 500 leads and verify the output

Time to call your function and see real output. You'll generate 500 leads spread across all of 2025 and print a sample to confirm everything works.

  • Below the generate_leads function, add two blank lines and then this code to run the simulation:
# Run
random.seed(42)
start_date = datetime(2025, 1, 1)
end_date = datetime(2025, 12, 31)

leads = generate_leads(500, start_date, end_date)
for lead in leads[:10]:
    print(lead)

What does this code do?

  • random.seed(42) locks the random number generator so you get the same leads every time you run the script. This makes your results reproducible.
  • generate_leads(500, start_date, end_date) creates 500 leads with random creation dates between January 1 and December 31, 2025.
  • leads[:10] prints just the first 10 leads so you can spot-check the output without flooding the terminal.
  • Save funnel_sim.py.

Before you run this, what do you expect to see printed? Think about the structure of each lead dictionary and what the dates should look like.

  • Run your script by entering this command in the terminal:
python3 funnel_sim.py

You should see 10 dictionaries printed, each with a lead_id and a form_fill_date showing different dates spread across 2025. Each lead has a unique ID (1 through 10) and a random date like 2025-03-14 or 2025-07-22.

Seeing a NameError or SyntaxError?

  • If you see NameError: name 'datetime' is not defined, check that your imports at the top include from datetime import datetime, timedelta.
  • If you see a SyntaxError, check for missing commas inside the CONVERSION_RATES or LAG_RANGES_DAYS dictionaries. Every key-value pair except the last needs a trailing comma.
  • Make sure the def generate_leads line has no extra indentation. It should start at the very left edge of the file.

Why isn't my funnel_sim.py script running?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Scroll through your funnel_sim.py and confirm these sections appear in order:

  • Imports: import random, import csv, and from datetime import datetime, timedelta.
  • Funnel configuration: STAGES list, CONVERSION_RATES dictionary, and LAG_RANGES_DAYS dictionary.
  • Function: def generate_leads(num_leads, start_date, end_date) that returns a list of lead dictionaries.
  • Run block: random.seed(42), date setup, generate_leads(500, start_date, end_date), and a loop printing the first 10 leads.

You have 500 leads sitting at the top of your funnel, each with a random entry date. Next up, you'll write the engine that pushes each lead through the stages and decides who advances and who drops out.

Simulate Stage Transitions

Your 500 leads are generated and waiting in memory. Now you need to decide what happens to each one as it hits the funnel.

In this step, you will write the core simulation engine. Each lead faces a random dice roll at every stage transition. If it passes, it advances. If not, it drops out permanently.

In this step, get ready to:
  • Write a simulate_funnel function that pushes leads through each stage.
  • Print a summary showing how many leads reach each stage.
  • Observe that all stage dates are identical, revealing why this version cannot support time-based analysis.
Write the simulate_funnel function

The simulation logic is a nested loop. The outer loop iterates over every lead. The inner loop iterates over each stage transition, rolling a random number to decide whether the lead advances or drops.

  • In funnel_sim.py, find the closing line of your generate_leads function (return leads).
  • Add the following function below it, leaving two blank lines between the functions:
def simulate_funnel(leads):
    """Push each lead through the funnel with conversion probabilities (no lag yet)."""
    for lead in leads:
        current_date = lead["form_fill_date"]

        for j in range(len(STAGES) - 1):
            from_stage = STAGES[j]
            to_stage = STAGES[j + 1]
            transition_key = f"{from_stage}_to_{to_stage}"

            if random.random() < CONVERSION_RATES[transition_key]:
                lead[f"{to_stage}_date"] = current_date
            else:
                lead["dropped_at"] = from_stage
                break
        else:
            lead["dropped_at"] = None

    return leads

What does this code do?

  • current_date = lead["form_fill_date"] sets the starting point for each lead's journey through the funnel.
  • random.random() generates a number between 0 and 1. If it falls below the conversion rate for that transition, the lead advances.
  • When a lead fails a transition, break stops the inner loop. That lead goes no further.
  • The else on the for loop runs only if the loop completed without hitting break. It marks leads that made it all the way through with dropped_at = None.
  • Notice that lead[f"{to_stage}_date"] = current_date sets every stage date to the same value. There is no lag yet.
Replace the run section to test the simulation

The existing run section just prints sample leads. You need to replace it so it calls your new function and prints a summary of how many leads made it to each stage.

  • Find the # Run comment near the bottom of your file.
  • Select everything from that comment to the end of the file and replace it with the following code:
# Run
random.seed(42)
start_date = datetime(2025, 1, 1)
end_date = datetime(2025, 12, 31)

leads = generate_leads(500, start_date, end_date)
leads = simulate_funnel(leads)

total = len(leads)
reached_mql = sum(1 for lead in leads if "mql_date" in lead)
reached_sql = sum(1 for lead in leads if "sql_date" in lead)
reached_opp = sum(1 for lead in leads if "opportunity_date" in lead)
closed_won = sum(1 for lead in leads if "closed_won_date" in lead)

print(f"Total leads: {total}")
print(f"Reached MQL: {reached_mql} ({reached_mql/total:.1%})")
print(f"Reached SQL: {reached_sql} ({reached_sql/total:.1%})")
print(f"Reached Opportunity: {reached_opp} ({reached_opp/total:.1%})")
print(f"Closed Won: {closed_won} ({closed_won/total:.1%})")

What does this code do?

  • random.seed(42) locks the random number sequence so you get the same results every time you run the script. This makes your output reproducible.
  • Each sum(1 for lead in leads if ...) line counts how many leads have a date field for that stage. If the field exists, the lead reached that stage.
  • The f-string {reached_mql/total:.1%} formats the count as a percentage with one decimal place.
  • Save funnel_sim.py.

Before you run this, think about what dates you expect to see for each stage. If a lead fills out a form on March 14th, when would you expect them to reach MQL? SQL? Closed-won?

  • Run the script by entering this command in your terminal:
python3 funnel_sim.py

You should see a summary like this:

Total leads: 500
Reached MQL: 149 (29.8%)
Reached SQL: 30 (6.0%)
Reached Opportunity: 17 (3.4%)
Closed Won: 4 (0.8%)

The numbers look right. Roughly 30% of 500 leads reach MQL, then 20% of those reach SQL, and so on. The probability logic is working.

Notice the problem with your dates

If you print any individual lead that made it to closed-won, you will see that every stage date is identical to the form_fill_date. A lead that filled out a form on March 14th apparently also became an MQL, SQL, opportunity, and closed deal all on March 14th.

Nobody closes a B2B deal on the same day they fill out a form. This instant-conversion data cannot produce meaningful time-based analysis. Step 4 fixes this by adding realistic time lag between stages.

Seeing a syntax error or unexpected numbers?

  • Check that the else keyword at the bottom of your function is aligned with the for j in range loop, not the if statement. Python's for-else is indent-sensitive.
  • If all 500 leads show as reaching every stage, check that random.random() < CONVERSION_RATES[transition_key] uses < (less than), not > (greater than).
  • Make sure random.seed(42) appears before generate_leads is called. If the seed is in the wrong place, your numbers will differ.

Why is my simulate_funnel function not working correctly?

✔️ Awesome, I've got everything!

Great. Double check you have saved funnel_sim.py and that your terminal output shows the stage counts.

ⓧ I'd like to double check the full code

Scroll through your file and confirm these sections appear in order:

  • Imports (random, csv, datetime, timedelta).
  • Funnel configuration (STAGES, CONVERSION_RATES, LAG_RANGES_DAYS).
  • The generate_leads function.
  • The simulate_funnel function (with the for-else pattern and dropped_at logic).
  • The # Run section that calls both functions and prints the summary with percentages.

Your probability engine works. You can see leads dropping at every stage just like a real funnel. But the timing is completely wrong. Next up, you will add realistic time lag between stages so a January lead can still be converting in April.

Add Time Lag and Save to CSV

In the last step, you saw something obviously wrong: every lead's conversion dates were identical to their form_fill_date. Nobody closes a B2B deal the same day they fill out a form.

Real B2B deals take weeks to months at each stage. Adding realistic time lag transforms your simulator from a toy probability exercise into something that produces data indistinguishable from a real CRM export. Once lag is in, you will see the critical B2B property: leads that entered in January are still closing in April and May.

In this step, get ready to:
  • Add random time lag between each funnel stage so transitions take days or weeks.
  • Write a function that saves your lead data to a CSV file that looks like a real CRM export.
  • Run the complete simulation and confirm realistic date spreads in the output.
Add time lag to simulate_funnel

You already defined the LAG_RANGES_DAYS dictionary back in Step 2. Now you will use it. Each time a lead successfully advances to the next stage, you will roll a random number of days within that transition's lag range and add it to the current date.

  • In funnel_sim.py, find the simulate_funnel function's docstring. It currently says:
"""Push each lead through the funnel with conversion probabilities (no lag yet)."""
  • Update the docstring to reflect that lag is now included:
"""Push each lead through the funnel with conversion probabilities and lag."""
  • Now find this line inside the if random.random() < CONVERSION_RATES[transition_key]: block:
            if random.random() < CONVERSION_RATES[transition_key]:
                lead[f"{to_stage}_date"] = current_date
  • Replace that single assignment line with the following four lines, so the entire if block now looks like this:
            if random.random() < CONVERSION_RATES[transition_key]:
                min_lag, max_lag = LAG_RANGES_DAYS[transition_key]
                lag_days = random.randint(min_lag, max_lag)
                current_date = current_date + timedelta(days=lag_days)
                lead[f"{to_stage}_date"] = current_date

What does this code do?

  • min_lag, max_lag = LAG_RANGES_DAYS[transition_key] looks up the minimum and maximum days for this specific transition (e.g., 7 to 45 days for MQL to SQL).
  • random.randint(min_lag, max_lag) picks a random number of days within that range.
  • current_date = current_date + timedelta(days=lag_days) pushes the clock forward. The next stage's date builds on this updated clock, so lag accumulates through the funnel.

Before you run this, think: if a lead fills a form on January 15 and each transition adds weeks or months, what month might a closed-won date land in?

  • Save funnel_sim.py and run it to see the difference:
python3 funnel_sim.py

You should see the same summary as before (similar counts for MQL, SQL, Opportunity, Closed Won). The stage counts confirm your probability logic still works. The difference is invisible in the summary alone, but it is there in the underlying data. You will see it clearly once you write the CSV.

Seeing a NameError or TypeError?

  • If you see NameError: name 'LAG_RANGES_DAYS' is not defined, make sure the LAG_RANGES_DAYS dictionary is still defined at the top of your file, above the simulate_funnel function.
  • Check your indentation. The four new lines must be indented inside the if block (same level as the old lead[f"{to_stage}_date"] line was).

Why isn't my time lag code working?

Write the save_to_csv function

Your leads now have realistic dates spread across months. To actually see this and use the data in future projects, you need to write it to a file. A CSV is the simplest format that any spreadsheet or data tool can open.

  • In funnel_sim.py, add the following function below simulate_funnel and above the # Run comment:
def save_to_csv(leads, filename):
    """Save simulated leads to a CSV file."""
    fieldnames = [
        "lead_id",
        "form_fill_date",
        "mql_date",
        "sql_date",
        "opportunity_date",
        "closed_won_date",
        "dropped_at",
    ]

    with open(filename, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for lead in leads:
            row = {}
            for field in fieldnames:
                value = lead.get(field, "")
                if isinstance(value, datetime):
                    value = value.strftime("%Y-%m-%d")
                row[field] = value
            writer.writerow(row)

What does this code do?

  • fieldnames defines the column headers for the CSV. These match the keys you stored on each lead dictionary.
  • csv.DictWriter is Python's built-in tool for writing dictionaries as CSV rows. It automatically maps dictionary keys to the correct columns.
  • lead.get(field, "") returns an empty string if a lead never reached that stage. This produces blank cells in the CSV for stages the lead dropped before reaching.
  • strftime("%Y-%m-%d") formats datetime objects as clean date strings like 2025-03-14.
Run the full simulation and verify the CSV

The save_to_csv function exists but nothing calls it yet. You need to wire it into your run section and add a confirmation message.

  • Scroll down to the # Run comment near the bottom of your file. Update the comment to say:
# Run the simulation
  • Find this line:
leads = simulate_funnel(leads)
  • Add this line directly below it to call your new function:
save_to_csv(leads, "funnel_data.csv")
  • Add a # Print summary comment above the total = len(leads) line.
  • At the very end of the file, after the last print statement, add this line:
print(f"\nSaved to funnel_data.csv")

Before you run, predict: for a lead that filled a form on January 15, 2025 and made it all the way to closed-won, what is the earliest month you would expect to see as the closed_won_date?

  • Save funnel_sim.py and run the full simulation:
python3 funnel_sim.py

You should see the funnel summary printed (similar stage counts as before) plus the message Saved to funnel_data.csv at the end.

  • Confirm the CSV file was created by running:
ls

You should see funnel_data.csv and funnel_sim.py listed in the output.

  • Now peek inside the CSV to confirm the dates are spread across months:
head -20 funnel_data.csv

You should see the header row followed by lead data. Look for a lead whose form_fill_date is early in 2025 (January or February). If that lead reached closed_won_date, it should land months later (April, May, or June). Blank cells mark stages the lead never reached.

Why does this matter for B2B analytics?

This date spread is the fundamental reason B2B funnel analysis is harder than B2C. A marketing campaign in January does not show its full results until April or May. If you check results in February, you will drastically undercount conversions because most leads have not had time to close yet.

Your simulator now produces data with exactly this property. You have a controlled lab to study it.

Don't see funnel_data.csv?

  • Make sure you added the save_to_csv(leads, "funnel_data.csv") call after leads = simulate_funnel(leads). The function definition alone does not produce the file.
  • Check that your terminal is inside the b2b-funnel-sim folder. Run pwd to confirm.
  • If the script ran without errors but produced no CSV, make sure the save_to_csv function is defined before the # Run the simulation section (Python reads top to bottom).

Why isn't my CSV file being created?

✔️ Awesome, I've got everything!

Great. Double check you have saved funnel_sim.py and that funnel_data.csv exists in your b2b-funnel-sim folder.

ⓧ I'd like to double check the full code

Scroll through your funnel_sim.py and confirm these sections are present, in order:

  • Imports: import random, import csv, from datetime import datetime, timedelta.
  • Funnel configuration: STAGES list, CONVERSION_RATES dictionary, LAG_RANGES_DAYS dictionary.
  • generate_leads function (unchanged from Step 2).
  • simulate_funnel function with lag logic (the if block should have 4 lines: min_lag, max_lag, lag_days, current_date + timedelta, and the date assignment).
  • save_to_csv function with DictWriter and date formatting.
  • Run section: random.seed(42), lead generation, simulation, save_to_csv call, then summary prints ending with Saved to funnel_data.csv.

Your simulator now produces data that looks like a real CRM export. Leads enter in one month and close in another. This is the dataset you will build on in future projects to compute metrics and run experiments. Next up, the Secret Mission adds the messiness that makes real CRM data so tricky to work with.

Secret mission

Add Real-World CRM Messiness

Your simulator produces clean, orderly data. Real CRM exports are never this tidy. Sales reps skip stages, timestamps go missing, and multiple leads map to the same company. Can you make your simulator messy enough to trip up naive analysis?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for use in the next project, or delete them entirely.

Resources you used:

  • The b2b-funnel-sim folder on your Desktop (contains funnel_sim.py and funnel_data.csv).

Keep everything running

No action needed. Choose this if you plan to use your simulator and CSV output in the next project (metric computation on the funnel data you just generated).

  • Your b2b-funnel-sim folder stays on your Desktop, ready for the next session.
  • You can re-run python3 funnel_sim.py any time to regenerate fresh data.

Pause - I'll come back to this later

Since this project is local files only, there is nothing to pause. Your files sit on disk and consume no memory or CPU when the script is not running.

  • Close the funnel_sim.py tab in VS Code if you want a clean workspace.
  • Your files will be waiting in ~/Desktop/b2b-funnel-sim whenever you return.

Delete - I don't want to use this again

Remove your project folder and all generated files. This is permanent.

  • On macOS or Linux, open your terminal and run the command below to delete the project folder.
  • On Windows, open PowerShell and run the Windows command below instead.
rm -rf ~/Desktop/b2b-funnel-sim
Remove-Item -Recurse -Force ~\Desktop\b2b-funnel-sim
  • Confirm deletion by running ls ~/Desktop (macOS/Linux) or ls ~\Desktop (Windows). You should no longer see b2b-funnel-sim in the output.

Nice Work!

Nice Work!

You just built a complete B2B funnel simulator from scratch using Python. You now have a tool that generates realistic pipeline data on demand, ready to power every metric and experiment you build next.

You've learned how to:

  • Build a Python script from scratch that simulates a five-stage B2B sales funnel with realistic conversion probabilities and time lag between stages.
  • Discover why instant-conversion models cannot represent B2B reality: outcomes take weeks to months after the initial action, making real-time reporting misleading.
  • Produce a reusable CSV dataset that looks like a real CRM export, ready for metric computation in the next project.
  • (Secret Mission) Added real-world CRM messiness to your simulator: stage-skipping, missing timestamps, and account-level lead duplication.

Ready to quiz yourself?