Gmail treats jane.doe@gmail.com, janedoe+webinar@gmail.com and Jane.Doe@googlemail.com as one inbox. Your spreadsheet sees three strings. A verifier checks each string on its own, so you pay to verify one person three times and then mail her three times.
The fix is a canonical key: a normalized form of the address that you compare instead of the raw string. This guide gives you the rules, a short Python script, and the real output from running it. It also shows where the LeadOcean export fits, because a merged list is where these duplicates come from.
LeadMagic covers the same ground in a long guide (LeadMagic, September 2026). This one is the short version you can run.
TL;DR
- Dedupe on a canonical key before you verify. For Gmail and googlemail.com, lowercase, drop the dots, drop everything after
+. For every other domain, change nothing. - Time: about 20 minutes, most of it waiting for the export to finish.
- Records: 0 for the dedup. The sample export in Step 2 spends 2 records per row because it includes email columns, 400 in total.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. The Free plan has 1,000 records, one-off, no card (sign up). Free keys get both the search and enrich scopes. - Python 3.9 or newer. The script uses only the standard library.
curlandjq.- A CSV with an email column. The example uses LeadOcean's export header,
email_1_address, plusemail_1_status.
Step 1: Learn the three rules
Gmail has three behaviours that create hidden duplicates. Only the first is documented as a Gmail account rule, so keep the key conservative.
| Variant | Example | Same inbox as | Source |
|---|---|---|---|
| Dots | j.a.n.e@gmail.com | jane@gmail.com | Google Help, September 2026 |
| Plus tag | jane+news@gmail.com | jane@gmail.com | Google Workspace Learning Center, September 2026 |
| Alias domain | jane@googlemail.com | jane@gmail.com | Same account, older domain |
Google's help page says dots do not matter for a personal Gmail address. It also says dots do change the address when Gmail runs on a work or school domain (Google Help, September 2026). So s.arah@acme.com and sarah@acme.com can be two real people.
Plus addressing is older than Gmail. RFC 5233 describes the user+detail form as subaddressing (RFC 5233, September 2026). Google's own guide shows plus variants arriving in the base inbox, on a Workspace address as well.
The practical rule: apply dot-stripping to gmail.com and googlemail.com only. Strip +tag only where you are sure the provider honours it. This script does it for Gmail and nothing else.
Step 2: Pull a personal-address list from LeadOcean
You need a messy list to test on. A merged list from several sources is the usual cause, and LeadOcean can be one of those sources. Size the audience first. Sizing is free.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emailType": ["personal"],
"jobLevel": ["C-Team"],
"country": ["US"],
"limit": 1
}' | jq '.meta'count=true goes in the query string, not the body. The call returns meta.total and no rows, so it spends no records.
Then export a small sample. emailType set to personal returns people whose best address is a personal one, which is where Gmail lives. Email columns need the full record, so each row costs 2 records. A 200-row sample spends 400 of your 1,000 free records.
curl -s -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "C-Team US personal emails, sample",
"filters": { "emailType": ["personal"], "jobLevel": ["C-Team"], "country": ["US"] },
"limit": 200,
"columns": ["person_id", "profile_full_name", "email_N_address", "email_N_status"],
"caps": { "emails": 1 }
}'The reply is a 202 with a job id and perRow, the records each row costs. Poll GET /v1/exports/{id}, then fetch /v1/exports/{id}/download. The Exports page at app.leadocean.io builds the same file with a filter builder and a price preview (LeadOcean exports docs, September 2026).
Step 3: Write the canonical key
Save this as canon.py. It parses the cell, builds the key, keeps the row with the best email status when two rows collide, and prints what it dropped.
import csv, sys
from email.utils import parseaddr
GMAIL = {"gmail.com", "googlemail.com"}
RANK = {"verified": 0, "catch_all_valid": 1, "catch_all": 2} # best status wins the tie
def canon(raw):
"""Return (clean_address, key, tag). Gmail rules apply to Gmail domains only."""
_, addr = parseaddr(raw.strip().removeprefix("mailto:"))
if addr.count("@") != 1:
return None, None, None
local, domain = addr.rsplit("@", 1)
domain = domain.lower()
if domain not in GMAIL:
return addr, f"{local}@{domain}", None # not Gmail: leave the local part alone
base, _, tag = local.lower().partition("+")
return addr, f"{base.replace('.', '')}@gmail.com", tag or None
rows = list(csv.DictReader(open(sys.argv[1])))
for r in rows:
r["email"], r["canonical_key"], r["tag"] = canon(r["email_1_address"])
rows.sort(key=lambda r: RANK.get(r["email_1_status"], 9)) # stable: ties keep input order
seen, kept = {}, []
print(f"rows in: {len(rows)}")
for r in rows:
if r["canonical_key"] is None:
print(f" drop {r['email_1_address']:30} unparseable"); continue
if r["canonical_key"] in seen:
print(f" drop {r['email']:30} same inbox as {seen[r['canonical_key']]}"); continue
seen[r["canonical_key"]] = r["email"]; kept.append(r)
print(f"unique inboxes: {len(kept)}")
w = csv.DictWriter(open(sys.argv[2], "w", newline=""), fieldnames=list(kept[0].keys()))
w.writeheader(); w.writerows(kept)Three choices matter here. The key is only a comparison value, so the original address stays in the email column. The tag column keeps the plus tag so you do not lose where a signup came from. Non-Gmail domains pass through with only the domain lowercased.
parseaddr also handles the noise that CRM exports add: display names such as Sam Poe <sam.poe+q3@gmail.com>, and mailto: prefixes. A cell that holds two addresses is not a duplicate to collapse. Split it into two rows before you run the script.
Log the tag before a row is dropped if attribution matters. Dropped rows lose their tag value, so write the dropped rows to a second file if you track signup source in the plus tag.
Step 4: Run it
Point it at the export and write a deduplicated file. The sample below uses nine placeholder rows with LeadOcean's export header, because a real export holds real people and this page does not print them.
python3 canon.py export.csv unique.csvThe run on the placeholder file:
rows in: 9
drop janedoe+webinar@gmail.com same inbox as jane.doe@gmail.com
drop Jane.Doe@googlemail.com same inbox as jane.doe@gmail.com
drop sam.poe+q3@gmail.com same inbox as sampoe@gmail.com
unique inboxes: 6Look at what it kept. For Jane Doe it kept the verified row from the export and dropped the two unknown rows from a CSV upload and a CRM. For Sam Poe it kept sampoe@gmail.com, the verified one, even though the CRM row came first in the file.
It also left j.roe@acme.com and jroe@acme.com alone, and both Ann Lee Outlook rows. That is deliberate. Those might be two different mailboxes, and a wrong merge is worse than a duplicate.
Step 5: Verify the survivors once
Now spend your verifier on the unique set. Rows that already carry verified from the LeadOcean export do not need another pass. Pull out only the rest.
python3 - <<'PY'
import csv
rows = list(csv.DictReader(open("unique.csv")))
todo = [r for r in rows if r["email_1_status"] != "verified"]
with open("to_verify.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(todo)
print(f"{len(todo)} of {len(rows)} rows need a verifier")
PYOn the placeholder file this prints 2 of 6 rows need a verifier. Send to_verify.csv to whichever verifier you use. Best email verification tools compares them. If you want to know what an SMTP check can and cannot tell you, read SMTP verification.
Do not skip the status read for the rows you keep. LeadOcean gives no refund for bounced emails, so read email_status before you send.
What you get
Sizing first. The MCP tool leadocean_count_leads ran on 2026-10-21 and spent 0 records:
emailType: ["personal"], jobLevel: ["C-Team"], country: ["US"]
-> total 59849, totalIsExact true, totalAll 2163853, credits 0That is the mailable audience: 59,849 people whose best address is personal and is verified, catch_all_valid or catch_all. totalAll is the same filters with no email condition, 2,163,853 people.
Then the script output from Step 4, shown above: nine rows in, six unique inboxes out, three drops, each with the row it collided with. The output file keeps canonical_key and tag columns, so you can group by inbox in a spreadsheet or load the table into a database with a unique index on canonical_key.
One honest limit. This page does not claim a LeadOcean export contains dot variants of the same address. The duplicates appear when you merge the export with a CRM dump, a form list and a purchased file. Within LeadOcean, person_id is the identity key, so dedupe on that first.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Check the x-api-key header and that $LEADOCEAN_API_KEY is set in this shell. |
| 429 on search | Over your plan's rate per account: 5 a second on Free, 50 on Solo, 200 on Agency | Wait for Retry-After and retry. |
| 402 on export | Fewer records left than the export needs | The Free plan's 1,000 records are one-off. Lower limit, or move to Solo ($97 a month) or Agency ($497 a month) (pricing). |
| 403 on export | Contact columns asked without the enrich scope | Use a key with both scopes. Free keys have both. |
| Empty results | A filter value is not an exact enum string, such as Personal for personal | Check values with leadocean_list_enum_values or GET /v1/enums. |
| 400 or 429 on export | Over 50,000 rows a request, or 500,000 rows a day | Split the pull into requests of 50,000 or fewer and spread large pulls across days. |
FAQ
Do dots matter in Gmail addresses?
Not on a personal Gmail address. Google says you own every dotted version of your address. On Gmail through a work or school domain, dots do change the address (Google Help, September 2026). Gate your rule on the domain.
Should I strip plus tags on every domain?
No. Strip them on Gmail, where you have confirmed the behaviour, and keep them elsewhere. Some corporate addresses contain a real + in the local part. A blanket split on + merges people who are not the same person.
Does verification catch these duplicates?
No. SMTP checks whether a string reaches a mailbox. Two dot variants can both pass, so you pay for both. That is why dedup comes first. SMTP verification explains what the check does and does not prove.
Can I find out who owns a Gmail address?
Yes, with a reverse lookup, which LeadOcean exposes at /v2/people/reverse. The walkthrough is in reverse Gmail search. The v2 contact endpoints cost one record per call, including a call that finds nothing, so dedupe first and pay once per inbox.
Where do the other recipes in this series live?
On the blog hub. It lists the how-to guides for building, enriching and cleaning lists with LeadOcean.
Dedupe first, then verify. Pull a personal-address sample from LeadOcean and run the script on it.
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →