Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Dedupe Gmail Dots and Plus Tags (Before You Verify)

One Gmail inbox can sit in your list as three different strings. Collapse them with a short script, then pay to verify each inbox once.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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.
  • curl and jq.
  • A CSV with an email column. The example uses LeadOcean's export header, email_1_address, plus email_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.

VariantExampleSame inbox asSource
Dotsj.a.n.e@gmail.comjane@gmail.comGoogle Help, September 2026
Plus tagjane+news@gmail.comjane@gmail.comGoogle Workspace Learning Center, September 2026
Alias domainjane@googlemail.comjane@gmail.comSame 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.

bash
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.

bash
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.

python
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.

bash
python3 canon.py export.csv unique.csv

The run on the placeholder file:

text
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: 6

Look 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.

bash
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")
PY

On 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:

text
emailType: ["personal"], jobLevel: ["C-Team"], country: ["US"]
-> total 59849, totalIsExact true, totalAll 2163853, credits 0

That 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

ErrorCauseFix
401Missing, wrong or revoked keyCheck the x-api-key header and that $LEADOCEAN_API_KEY is set in this shell.
429 on searchOver your plan's rate per account: 5 a second on Free, 50 on Solo, 200 on AgencyWait for Retry-After and retry.
402 on exportFewer records left than the export needsThe 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 exportContact columns asked without the enrich scopeUse a key with both scopes. Free keys have both.
Empty resultsA filter value is not an exact enum string, such as Personal for personalCheck values with leadocean_list_enum_values or GET /v1/enums.
400 or 429 on exportOver 50,000 rows a request, or 500,000 rows a daySplit 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 →