A lead list is a table of companies and people a sales team might contact. Real lead lists are rarely clean: the same person is entered twice, countries are spelled three ways, and some rows hold a shared address such as info@ instead of a person. This file has all of these problems on purpose, so learners can practise finding and fixing them before anyone sends a single email.
Everything in it is invented. Company names reuse well-known fictional sample names (Contoso, Fabrikam, Northwind and others) with an industry word, so no row can be mistaken for a real firm. Websites and email addresses end in .example, a domain that cannot exist. Phone numbers are made up: do not dial them.
What is in the file
fictional-b2b-leads.csv (in the Downloads on this page): 156 rows, 17 columns, comma-separated, UTF-8. One row is one contact at one company, as a sales team might have collected them between November 2025 and October 2026.
Column | Type | Meaning | Example | Problems you will meet |
|---|---|---|---|---|
lead_id | text | Row number given by the system | L001 | None, every row has its own |
company | text | Company name with legal form | Tailspin Dental GmbH | One company appears with and without its legal form |
industry | text | One of 12 industries | Healthcare | None |
country | text | Country of the company | Germany | 27 different spellings for 12 countries |
city | text | City, English spelling | Munich | None |
employees | number | Number of employees | 25 | 5 rows are empty |
founded | number | Year the company was founded | 1976 | None |
website | text | Company domain | tailspin-dental.example | One row starts with www. |
contact_first_name | text | First name of the contact | Aylin | Empty where the row is a role address |
contact_last_name | text | Last name of the contact | Becker | Empty where the row is a role address |
role | text | Job title of the contact | Head of Operations | Empty where the row is a role address |
text | Email address | aylin.becker@tailspin-dental.example | 13 empty, 12 role addresses, mixed capitals | |
phone | text | Phone number, invented | +49 89 5550 5785 | One copy is written without spaces |
source | text | Where the lead came from | purchased list | None, 8 values |
lead_status | text | new, contacted, replied, meeting, customer or disqualified | contacted | None |
last_contacted | date | Date of the last contact, YYYY-MM-DD | 2026-01-10 | Empty when the status is new |
opted_out | text | yes if the person asked not to be contacted again | no | Two copies of one person disagree |
A role address is a shared mailbox such as info@, office@ or sales@ that belongs to a function, not to one person. An opt-out is a person's request to receive no more marketing messages.
How to use it
Spreadsheet: open the file in any spreadsheet program. If the program asks, choose comma as the separator and UTF-8 as the character set, so that names like Österreich survive. The formulas below use the column letters of the file: A is lead_id, D is country, L is email, Q is opted_out, and the data is in rows 2 to 157.
SQL: load the file into the SQL Query Playground. It runs in the browser and nothing is uploaded. There is only one table there, so
FROM leadsworks whatever you call it. In other databases, import the file as a table calledleads.Two things to know about SQL here. The playground compares text without caring about capital letters; many databases do care. The queries below use
LOWER(), so they give the same result everywhere. The playground also treats empty cells as NULL. If your database imports empty cells as empty text, write(email IS NULL OR email = '')instead ofemail IS NULL.
Tasks
Work in pairs. Write down each answer as a number and one sentence that explains it. Tasks 1 to 4 describe the problems, tasks 5 to 7 deal with duplicates and opt-outs, tasks 8 to 10 use the data for sales decisions.
Size. How many rows does the file have? How many different websites are there, first as written, then after removing "www."? What does the difference between rows and websites tell you?
Country spellings. List every value in the country column with how often it appears. How many different values are there? How many leads are in Germany once all spellings are counted together?
Missing values. How many rows have no email address? How many have no employee count? Which of the two gaps matters more for an email campaign?
Role addresses. How many emails start with info@, office@, sales@, contact@ or hello@? What do these rows have in common apart from the email?
Duplicate emails. Which email addresses appear more than once once you ignore capital letters? How many extra rows do they cause?
Duplicates without an email. Task 5 misses one person, because one of their rows has no email. Find all people who appear twice by comparing first name, last name and website instead.
Opt-outs. How many rows say opted_out = yes? One person has two rows that disagree. Who is it, and what went wrong?
Lead sources. For each source, count the leads and how many reached a meeting (status meeting or customer). Which source has the highest meeting rate? Why should you be careful with this comparison?
Stale leads. How many leads have the status contacted or replied but were last contacted before 1 May 2026, so more than six months ago? How many have never been contacted?
A send-ready list. Build a list for an email campaign with these rules: a personal email address (not empty, not a role address), nobody who opted out in any of their rows, no customers and nothing disqualified, and each email address only once. How many addresses are left?
Answers
Answers to tasks 1 to 4
1. Size
156 rows. 141 different websites as written, 140 after removing "www." (Fabrikam Couriers is in the list once as fabrikam-couriers.example and once as www.fabrikam-couriers.example). So there are 140 companies but 156 rows: some companies have two different contacts, and some people are in the list twice.
Spreadsheet:
=COUNTA(A2:A157),=COUNTA(UNIQUE(H2:H157))and=COUNTA(UNIQUE(SUBSTITUTE(H2:H157,"www.",""))).
SELECT COUNT(*) AS rows_total,
COUNT(DISTINCT website) AS sites_raw,
COUNT(DISTINCT REPLACE(LOWER(website), 'www.', '')) AS sites_clean
FROM leads2. Country spellings
27 different values for 12 countries. Germany is written five ways: Germany (39), DE (3), Deutschland (3), germany (2) and "Germany " with a space at the end (1). Counted together that is 48 leads in Germany. The other variants: United Kingdom, UK, Great Britain and England; Netherlands, The Netherlands, Holland and NL; Austria, AT and Österreich; Switzerland, CH and Schweiz; France and FR.
After cleaning: Germany 48, United Kingdom 22, France 11, Sweden 11, Switzerland 11, Netherlands 10, Austria 9, Italy 9, Belgium 8, Spain 8, Poland 5, Denmark 4. Together 156.
SQL
GROUP BYshows all 27 values. Some spreadsheet programs ignore capital letters in UNIQUE and pivot tables and show 26, because Germany and germany merge. The trailing space still makes "Germany " a value of its own.Fix: make a small mapping table (raw value, standard name), then add a column with VLOOKUP or XLOOKUP on the trimmed value.
Note: England and Great Britain are not the same as the United Kingdom. For a country column they all point to the same country, but the example shows why a fixed list of allowed values (or ISO codes such as DE, GB, NL) beats free text.
SELECT country, COUNT(*) AS leads
FROM leads
GROUP BY country
ORDER BY leads DESC;
SELECT COUNT(*) AS germany
FROM leads
WHERE LOWER(TRIM(country)) IN ('germany', 'de', 'deutschland')Run the two queries one at a time.
3. Missing values
13 rows have no email, 5 have no employee count. For an email campaign the missing emails matter more: those rows cannot be used at all. A missing employee count only makes a size filter less precise. Do not guess emails such as firstname.lastname@ to fill the gap: a wrong guess bounces, and an address the person never gave you raises data protection questions.
Spreadsheet:
=COUNTBLANK(L2:L157)and=COUNTBLANK(F2:F157).
SELECT COUNT(*) AS no_email FROM leads WHERE email IS NULL4. Role addresses
12 rows: 5 info@, 2 office@, 2 sales@, 2 contact@ and 1 hello@. None of these rows has a first name, last name or role, because nobody knows who reads the mailbox. They are useful to reach a company, but not for a personal email, and you cannot tell whether one person or ten will read it.
Spreadsheet:
=COUNTIF(L2:L157,"info@*")and the same for the other four, added together. COUNTIF ignores capital letters.
SELECT lead_id, company, email
FROM leads
WHERE email LIKE 'info@%' OR email LIKE 'office@%' OR email LIKE 'sales@%'
OR email LIKE 'contact@%' OR email LIKE 'hello@%'Answers to tasks 5 to 7
5. Duplicate emails
7 addresses appear twice, so there are 7 extra rows. Each pair teaches something different:
Person | Rows | What differs |
|---|---|---|
Maya Sommer | L123, L125 | Nothing: an exact copy |
Sofia Moreau | L075, L104 | Capital letters in the email |
Aylin Becker | L001, L116 | Email in capitals and a new job title |
Samir Kowalski | L053, L096 | Company name without "Ltd" |
Marek Yilmaz | L056, L151 | Website with www. and a newer contact date |
Samir Peeters | L038, L068 | Phone without spaces, status moved to meeting |
Sofia Steiner | L030, L083 | One row says opted out, the other does not |
When you merge a pair, keep the newest status, the newest contact date and the newest job title, and keep any opt-out.
Spreadsheet: add a helper column with
=LOWER(L2), then a second one with=COUNTIF(R$2:R$157,R2)>1and filter on TRUE: 14 rows, 7 pairs.
SELECT LOWER(email) AS email_clean, COUNT(*) AS copies
FROM leads
WHERE email IS NOT NULL
GROUP BY LOWER(email)
HAVING copies > 16. Duplicates without an email
8 people appear twice: the 7 from task 5 plus Lotte Moreau at Fabrikam Physio (L029 and L042). Her row L029 has no email, so matching on email could not find it. Altogether the file holds 148 different contacts (156 rows minus 8 copies) at 140 companies.
Matching on names has its own risk: two different people can share a name. That is why the key here combines name and website. Eight companies, for example Coho Building and Trey Hotels, have two different contacts on purpose: same company, different person, so they are not duplicates. At Proseware Home and Garden, Hugo Jansen and Noah Jansen share a last name but are two people.
Spreadsheet: a helper column
=I2&" "&J2&" "&SUBSTITUTE(H2,"www.",""), then COUNTIF as in task 5. Ignore rows with an empty last name.
SELECT contact_first_name, contact_last_name,
REPLACE(LOWER(website), 'www.', '') AS site,
COUNT(*) AS copies
FROM leads
WHERE contact_last_name IS NOT NULL
GROUP BY contact_first_name, contact_last_name, REPLACE(LOWER(website), 'www.', '')
HAVING copies > 17. Opt-outs
10 rows say yes. The conflict is Sofia Steiner at Lamna Care: row L083 says she replied and opted out (last contact 22 September 2026), row L030 says no, and that row was contacted again on 25 October 2026. Because the opt-out was only stored on one copy, she was contacted after she had asked to stop.
Under the GDPR, a person can object to the use of their data for direct marketing at any time, and the data must then no longer be used for that purpose (Article 21(2) and (3)). In practice: an opt-out belongs to the person, not to one row, so it must survive every merge and every new import. Keep a separate suppression list of opted-out addresses and check every list against it.
Spreadsheet:
=COUNTIF(Q2:Q157,"yes"). Then filter the duplicate pairs from task 5 and compare column Q.
SELECT LOWER(email) AS email_clean, COUNT(*) AS copies,
SUM(opted_out = 'yes') AS opt_outs
FROM leads
WHERE email IS NOT NULL
GROUP BY LOWER(email)
HAVING copies > 1 AND opt_outs > 0opted_out = 'yes' is 1 when true and 0 when false, so SUM counts the yes rows in each group.
Answers to tasks 8 to 10
8. Lead sources
Counted on the raw file, with meeting or customer as "reached a meeting":
Source | Leads | Reached a meeting | Meeting rate |
|---|---|---|---|
website form | 24 | 9 | 37.5 % |
company register | 16 | 5 | 31.3 % |
job ad | 19 | 5 | 26.3 % |
referral | 17 | 4 | 23.5 % |
purchased list | 38 | 8 | 21.1 % |
trade fair | 20 | 3 | 15.0 % |
webinar | 11 | 1 | 9.1 % |
partner | 11 | 1 | 9.1 % |
Website forms have the highest rate. Be careful: the groups are small (one more meeting for webinars would raise their rate from 9.1 to 18.2 %), the file still contains duplicates, and many leads are still open. The data is fictional, so it says nothing about real sources. A fair comparison also needs the cost per source and enough leads in each group.
SELECT source, COUNT(*) AS leads,
SUM(lead_status IN ('meeting', 'customer')) AS meetings,
ROUND(100.0 * SUM(lead_status IN ('meeting', 'customer')) / COUNT(*), 1) AS meeting_rate
FROM leads
GROUP BY source
ORDER BY meeting_rate DESC9. Stale leads
43 leads: 30 contacted and 13 replied, all last contacted before 1 May 2026. 29 leads have the status new and have never been contacted (their last_contacted is empty). Contact data ages: people change jobs and companies move, so stale leads should be checked again before anyone writes to them.
Spreadsheet:
=COUNTIFS(O2:O157,"contacted",P2:P157,"<"&DATE(2026,5,1))plus the same for "replied". If your program keeps the dates as text, filter the column instead.
SELECT COUNT(*) AS stale
FROM leads
WHERE lead_status IN ('contacted', 'replied')
AND last_contacted < '2026-05-01'Dates written as YYYY-MM-DD sort correctly even as text, which is one reason this format is used in data files.
10. A send-ready list
90 addresses. Step by step:
Step | Rows left |
|---|---|
All rows | 156 |
With an email address | 143 |
Without role addresses | 131 |
Without anyone who opted out in any row (10 rows plus Sofia Steiner's second row) | 120 |
Without customers and disqualified leads | 96 |
Each address once, ignoring capitals | 90 |
The SQL below returns 90 rows. Without subqueries the playground cannot count them in the same query; read the number of rows it shows.
SELECT LOWER(email) AS email_clean, COUNT(*) AS copies
FROM leads
WHERE email IS NOT NULL
AND email NOT LIKE 'info@%' AND email NOT LIKE 'office@%'
AND email NOT LIKE 'sales@%' AND email NOT LIKE 'contact@%'
AND email NOT LIKE 'hello@%'
AND lead_status NOT IN ('customer', 'disqualified')
GROUP BY LOWER(email)
HAVING SUM(opted_out = 'yes') = 0The opt-out check sits in HAVING on purpose. A WHERE condition opted_out = 'no' would drop row L083 but keep row L030, and Sofia Steiner would be back on the list.
A clean list is not yet a lawful one. Whether you may email these people at all depends on where they are and how the data was collected: see where B2B leads come from.
Ideas for more
Statistics: the mean and median number of employees per industry, and why they differ so much (a few very large companies pull the mean up).
Data protection: 38 leads come from a purchased list. Write three questions you would ask the seller before using them, and say which GDPR principle each question protects.
Lead scoring: give every lead points for fit and activity with the lead scoring worksheet.
Deduplication tool: the free CSV Deduplicator runs in the browser. Compare its results on the email column with your answer to task 5.
License
The data and the tasks are released into the public domain under CC0 1.0. Use them however you like, no credit needed.
