Article content and detailed guides remain in English. The selected language applies to controls and quick instructions.

Back to articles

Data Reconciliation for Email Lists: Merging and Resolving Conflicts

On this page

What Data Reconciliation Solves

When email lists come from multiple sources (CRM exports, web scraping, event lists, purchased data, manual collection), the same person often appears in multiple lists with inconsistent information. Data reconciliation is the process of identifying these overlaps, resolving conflicts and producing a single, accurate record for each contact.

Without reconciliation, you get:

  • Duplicate outreach. The same person receives your email from two different campaigns, sometimes on the same day.
  • Conflicting data. Your CRM says a contact works at Company A; your event list says Company B (they may have changed jobs).
  • Inflated metrics. Your "10,000-contact list" may contain 7,000 unique email strings after address deduplication; the number of people requires separate identity reconciliation.
  • Compliance risk. A contact who unsubscribed from one source still receives email from another.

The Reconciliation Process

Step 1: Normalise all sources

Before comparing records across sources, standardise the format:

Field Normalisation
Email address Lowercase, trim whitespace
Name Title case, remove titles (Mr., Dr.) and suffixes (Jr., III) unless critical
Company name Standardise abbreviations (Inc., Corp., Ltd.), remove legal suffixes for matching
Phone Remove spaces, dashes, parentheses; standardise to international format
Job title Standardise common variations (VP = Vice President; Dir. = Director)
Country Convert to ISO 3166-1 alpha-2 codes
def normalise_email(email):
    """Normalise an email address for matching."""
    if not email:
        return None
    email = email.strip().lower()
    # Handle common typos
    email = email.replace(" ", "")
    email = email.replace("..", ".")
    return email

def normalise_company(company):
    """Normalise company name for fuzzy matching."""
    if not company:
        return None
    company = company.strip()
    # Remove common suffixes
    for suffix in [
        " Inc.", " Inc", " LLC", " Ltd.", " Ltd",
        " Corp.", " Corp", " Co.", " Co",
        " GmbH", " S.A.", " B.V.", " Pty Ltd",
    ]:
        if company.endswith(suffix):
            company = company[: -len(suffix)]
    return company.strip()

Step 2: Identify matches

Matching records across sources requires comparing on multiple fields, because no single field is reliable alone.

Match keys (in order of reliability):

Match key Reliability Limitations
Email address (exact) Very high People change email addresses
Email address (normalised, ignoring plus addressing) High May merge intentionally separate accounts
First name + last name + company Medium Common names cause false positives
First name + last name + domain Medium Better than company name alone
Phone number (normalised) Medium People share phone numbers; formatting varies
First name + last name + city Low Too many false positives without additional context

Match strategy:

def find_matches(record, existing_records):
    """Find potential matches for a record in existing data."""
    matches = []
    email = normalise_email(record.get("email"))

    # Tier 1: Exact email match
    for existing in existing_records:
        if normalise_email(existing.get("email")) == email:
            matches.append(("exact_email", existing))
            return matches  # Exact email is definitive

    # Tier 2: Name + company match
    name = f"{record.get('first_name', '')} {record.get('last_name', '')}".strip().lower()
    company = normalise_company(record.get("company"))

    for existing in existing_records:
        existing_name = f"{existing.get('first_name', '')} {existing.get('last_name', '')}".strip().lower()
        existing_company = normalise_company(existing.get("company"))

        if name and existing_name and name == existing_name:
            if company and existing_company and company.lower() == existing_company.lower():
                matches.append(("name_company", existing))

    return matches

Step 3: Resolve conflicts

When two records match but contain different information, you need conflict resolution rules:

Resolution strategies:

Strategy When to use
Most recent wins For fields that change over time (job title, company, phone)
Most complete wins For fields that are sometimes missing (choose the record with more data)
Source priority Assign trust levels to sources; higher-trust sources override lower
Manual review For high-value contacts where automated resolution is risky

Source priority example:

Priority Source Reason
1 (highest) CRM (customer-provided data) The contact provided this data directly
2 LinkedIn (recent profile) Self-reported and usually current
3 Email verification service Verified as deliverable
4 Conference attendee list Provided at registration, may be current
5 Web scraping May be outdated; extraction quality varies
6 (lowest) Purchased list Age and accuracy unknown

Conflict resolution logic:

def resolve_conflict(field_name, values_by_source):
    """Resolve conflicting values for a field using source priority."""
    SOURCE_PRIORITY = {
        "crm": 1,
        "linkedin": 2,
        "verification": 3,
        "conference": 4,
        "scraping": 5,
        "purchased": 6,
    }

    # Sort by source priority (lowest number = highest priority)
    sorted_values = sorted(
        values_by_source.items(),
        key=lambda x: SOURCE_PRIORITY.get(x[0], 99),
    )

    # Return the highest-priority non-empty value
    for source, value in sorted_values:
        if value and str(value).strip():
            return value, source

    return None, None

Step 4: Merge records

After resolving conflicts, merge matched records into a single canonical record:

def merge_records(records_to_merge, source_priority):
    """Merge multiple records into one canonical record."""
    merged = {}
    provenance = {}

    all_fields = set()
    for record in records_to_merge:
        all_fields.update(record.keys())

    for field in all_fields:
        values_by_source = {}
        for record in records_to_merge:
            source = record.get("_source", "unknown")
            if field in record and record[field]:
                values_by_source[source] = record[field]

        if values_by_source:
            value, source = resolve_conflict(field, values_by_source)
            merged[field] = value
            provenance[field] = source

    merged["_sources"] = list(
        {r.get("_source") for r in records_to_merge}
    )
    merged["_provenance"] = provenance
    return merged

Step 5: Track provenance

After merging, the canonical record should show where each field came from. This is essential for:

  • Debugging. When a field is wrong, you need to know which source provided it.
  • Re-reconciliation. When you get new data from a source, you can selectively update fields.
  • Compliance. Under GDPR and similar regulations, you may need to demonstrate where personal data originated.

Common Reconciliation Scenarios

Scenario: Merging CRM with a conference attendee list

CRM record Conference record Reconciled
Email: jane@example.com Email: jane@example.com Email: jane@example.com
Name: Jane Smith Name: Jane Smith Name: Jane Smith
Title: Marketing Manager Title: Director of Marketing Title: Director of Marketing (more recent)
Company: Example Corp Company: Example Corp. Company: Example Corp
Phone: (555) 123-4567 Phone: (not provided) Phone: (555) 123-4567

The conference list has a newer job title, so the reconciled record uses the conference data for that field. The CRM has a phone number the conference list lacks, so the CRM value is kept.

Scenario: Merging scraped data with purchased data

Scraped record Purchased record Reconciled
Email: john@example.com Email: john@example.com Email: john@example.com
Name: John Name: John D. Anderson Name: John D. Anderson (more complete)
Title: (not found) Title: Sales Representative Title: Sales Representative
Company: Example Inc Company: Example, Inc. Company: Example Inc (normalised)
Source URL: example.com/team (purchased list) Source: example.com/team + purchased list

The purchased list has a more complete name and a title that was not found during scraping. The scraped data provides a verifiable source URL.

Scenario: Same person, different email addresses

Source A Source B Action
Email: jane@oldcompany.com Email: jane@newcompany.com Match on name+company if company changed; keep both emails, mark oldcompany as historical
Title: VP Marketing Title: CMO Use Source B (likely a promotion)
Company: Old Company Company: New Company Use Source B (job change)

This scenario requires human judgment or additional signals (like checking LinkedIn) to confirm the match. Automated matching on name alone risks false positives.

Handling Suppression Lists During Reconciliation

Suppression lists (unsubscribes, bounces, complaints) must be respected across all sources:

Rule Implementation
If an email is on the suppression list from any source, it stays suppressed in the reconciled list Check all incoming records against the master suppression list before adding
If a person has two email addresses and one is suppressed, the other can still be contacted Suppression is per-email, not per-person (unless the person requested full removal)
GDPR deletion requests apply to the person, not just one email If a person exercised their right to erasure, remove all records for that person
Purchased list suppressions do not override opt-in records If someone opted in directly but is on a purchased list's suppression file, the opt-in takes precedence

Tools for Reconciliation

For small lists (under 10,000 records)

Tool How to use it
Spreadsheet (Excel, Google Sheets) VLOOKUP or INDEX/MATCH to find matches; conditional formatting to highlight conflicts
Email Extractor Extract and deduplicate email addresses from multiple source files before reconciliation
Python script Custom matching and merging logic (examples in this guide)

For larger lists (10,000-100,000 records)

Tool How to use it
Python with pandas DataFrame merging with custom match functions
Dedupe (Python library) Machine learning-based deduplication that handles fuzzy matching
OpenRefine Open-source tool for data cleaning and reconciliation

For enterprise scale (100,000+ records)

Tool How to use it
CRM deduplication features Built-in merge tools in Salesforce, HubSpot, etc.
Master data management (MDM) platforms Dedicated software for maintaining a single source of truth
Database-level reconciliation SQL-based matching and merging with scheduled ETL jobs

Quality Checks After Reconciliation

After reconciling, verify the output:

Check How
Total unique records Should be less than the sum of all source records
Email validity Run the reconciled list through an email verification service
Suppression list applied Confirm no suppressed addresses appear in the active list
Field completeness What percentage of records have name, company, title?
Source distribution How many records came from each source?
Conflict log review Review a sample of resolved conflicts for accuracy

Extract emails

Explore tools

Verify emails

Check address validity before using your list.

ZeroBounce

Email Verification

Verifies email lists and provides tools for monitoring deliverability.

Useful when list cleaning and sender health belong in one workflow.

Explore ZeroBounce (opens in a new tab)