<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Property Data Notes]]></title><description><![CDATA[Property Data Notes]]></description><link>https://locatenext.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 17:27:08 GMT</lastBuildDate><atom:link href="https://locatenext.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Merge Skip Trace Results Back into Your Property List with SQLite and Python]]></title><description><![CDATA[Reconcile a skip trace return file, rank and dedupe phones, keep DNC and litigator flags intact, honor your own opt-outs, and export a dialer-ready CSV…]]></description><link>https://locatenext.hashnode.dev/merge-skip-trace-results-back-into-property-list-sqlite-python</link><guid isPermaLink="true">https://locatenext.hashnode.dev/merge-skip-trace-results-back-into-property-list-sqlite-python</guid><category><![CDATA[SQL]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Python]]></category><category><![CDATA[data cleaning ]]></category><category><![CDATA[Real Estate]]></category><dc:creator><![CDATA[Locate Next]]></dc:creator><pubDate>Fri, 23 Oct 2026 14:39:24 GMT</pubDate><content:encoded><![CDATA[<p>You pulled an absentee owner list or a tax-delinquent list and sent it out for skip tracing. The return file that comes back rarely lines up one-to-one with your list. One owner has four phones, another has none, and a few returned IDs aren't in your list at all. This guide shows how to merge skip trace results back into your property list so every phone lands on the right record.</p>
<p>If you're the person on the team who gets the vendor file and has to hand acquisitions a dialer upload by Monday, this is the script you rerun every batch.</p>
<p>A plain join and pivot can cause three problems. It can send your dialer a number that texted STOP last month. It can call the same owner twice about two properties. And it can let you pay for a batch that doesn't match your list. This guide treats the merge as an audit instead. The script stops whenever counts, flags or duplicate copies of a phone disagree, reports phones shared across owners so you can assign them before the upload, and never edits the raw file.</p>
<h2>How do you merge skip trace results back into your property list?</h2>
<p>SQLite ships with Python as the <code>sqlite3</code> module, so there's nothing to install. The seven steps:</p>
<ol>
<li>Check that SQLite supports window functions</li>
<li>Load and normalize the source list and the return file</li>
<li>Reconcile your counts against the vendor's hit count</li>
<li>Check duplicate phones before ranking</li>
<li>Rank and dedupe phones per owner with <code>ROW_NUMBER()</code></li>
<li>Suppress your own opt-outs</li>
<li>Export the audit, dialer and no-dialable files</li>
</ol>
<p>All data here is fictional. The names, addresses and 555 numbers are made up. To run the script, paste the Python blocks from Steps 1 through 7 into one <code>.py</code> file in order. It needs no input files and writes three CSVs to the current folder. The appendix loader is for real files and is not part of that sequence.</p>
<h2>Why does every record need a stable record_id?</h2>
<p>Before the list goes out, give every row a <code>record_id</code> that never changes and is never reused (like <code>R0001</code>), and store it with the record in your CRM. Spreadsheet row numbers don't work because they shift as soon as someone sorts or deletes a row.</p>
<p>If the list combines several lead sources, such as a driving-for-dollars sheet, an absentee pull and a tax-delinquent list, dedupe owners before you assign IDs. Otherwise you pay to trace the same person twice. The loader stops on any repeated <code>record_id</code>.</p>
<h2>Step 1: Does your Python's SQLite support ROW_NUMBER()?</h2>
<p>Window functions like <code>ROW_NUMBER() OVER (...)</code> need SQLite 3.25 or newer, so the script checks the version first.</p>
<pre><code class="language-python">import csv
import io
import re
import sqlite3
from collections import Counter
from datetime import datetime

SCRIPT_VERSION = "merge-v2"  # bump whenever the logic changes, and log it
print("Script version:", SCRIPT_VERSION)
print("SQLite version:", sqlite3.sqlite_version)
if sqlite3.sqlite_version_info &lt; (3, 25, 0):
    raise SystemExit("This script needs SQLite 3.25 or newer for window functions.")

con = sqlite3.connect(":memory:")
con.row_factory = sqlite3.Row
</code></pre>
<p>For real files, swap <code>":memory:"</code> for <code>"merge.db"</code> so you can inspect the results afterward. The script rebuilds its tables on every run, so copy <code>merge.db</code> somewhere else if you want to keep a batch.</p>
<h2>Step 2: How do you load a skip trace return file into SQLite?</h2>
<p>The return file has one row per phone. Each row carries:</p>
<ul>
<li><code>vendor_rank</code> (lower is better)</li>
<li><code>phone_type</code></li>
<li><code>dnc_flag</code>, the vendor's do-not-call indicator</li>
<li><code>litigator_flag</code>, explained below</li>
<li><code>contact_name</code>, the person the phone is listed under</li>
</ul>
<p><code>litigator_flag = Y</code> marks numbers the vendor associates with people who have filed telephone consumer protection lawsuits. Coverage and definitions vary by vendor, so check their data dictionary, and don't treat an <code>N</code> as proof a number is safe. The script passes both flags through as data, not as legal determinations.</p>
<p>The sample covers the cases that break simple merges:</p>
<ul>
<li><code>R0001</code> has four phones. The fourth is listed under a relative and written <code>937.555.0104</code></li>
<li><code>R0002</code> has the same landline twice, plus a mobile labeled <code>Wireless</code></li>
<li><code>R0003</code>'s mobile also comes back under <code>R0005</code></li>
<li><code>R0004</code> wasn't returned, <code>R0006</code> came back with no phone (a no-hit), and <code>R0009</code> isn't in your list</li>
<li><code>R0005</code> has a phone with a blank rank, listed under <code>RUIZ, ELENA</code></li>
</ul>
<pre><code class="language-python">SOURCE_CSV = """record_id,owner_name,property_address,city,state,zip
R0001,Dana Whitfield,412 Alder Ln,Springfield,OH,45501
R0002,Marcus Bell,88 Quarry Rd,Springfield,OH,45503
R0003,Priya Raman,1450 Cedar Ct,Fairborn,OH,45324
R0004,Tom Okafor,9 Lantern Way,Xenia,OH,45385
R0005,Elena Ruiz,301 Mill St,Fairborn,OH,45324
R0006,Jordan Pike,77 Birch Ave,Xenia,OH,45385
"""

RETURN_CSV = """record_id,phone,phone_type,vendor_rank,dnc_flag,litigator_flag,contact_name
R0001,+19375550101,mobile,1,N,N,Dana Whitfield
R0001,+19375550102,landline,2,Y,N,Dana Whitfield
R0001,+19375550103,mobile,3,N,N,Dana Whitfield
R0001,937.555.0104,voip,4,N,N,Kim Whitfield
R0002,+19375550121,landline,1,N,N,Marcus Bell
R0002,+19375550122,Wireless,1,N,N,Marcus Bell
R0002,+19375550121,landline,3,N,N,Marcus Bell
R0003,+19375550131,mobile,2,Y,N,Priya Raman
R0005,+19375550151,mobile,1,N,Y,Elena Ruiz
R0005,+19375550131,mobile,2,Y,N,Priya Raman
R0005,+19375550152,landline,,N,N,"RUIZ, ELENA"
R0006,,,,,,
R0009,+19375550191,mobile,1,N,N,Sam Doyle
"""

con.executescript("""
DROP VIEW IF EXISTS ranked_phones;
DROP TABLE IF EXISTS vendor_return;
DROP TABLE IF EXISTS source_list;
CREATE TABLE source_list (record_id TEXT PRIMARY KEY, owner_name TEXT,
    property_address TEXT, city TEXT, state TEXT, zip TEXT);
CREATE TABLE vendor_return (record_id TEXT, phone TEXT, phone_type TEXT,
    vendor_rank INTEGER, dnc_flag TEXT, litigator_flag TEXT, contact_name TEXT);
CREATE INDEX idx_return_record ON vendor_return(record_id);
""")

# Translate the vendor's spellings to yours. Add whatever your vendor sends.
VALUE_MAP = {
    "phone_type": {"Wireless": "mobile", "Cell": "mobile", "Landline": "landline"},
    "dnc_flag": {"Yes": "Y", "No": "N"},
    "litigator_flag": {"Yes": "Y", "No": "N"},
}
ENTITY_WORDS = {"LLC", "INC", "TRUST", "CORP", "LP", "ESTATE"}
BAD_VALUES = []


def normalize_phone(raw):
    digits = re.sub(r"\D", "", raw)
    if len(digits) == 11 and digits.startswith("1"):
        digits = digits[1:]
    if len(digits) != 10:
        BAD_VALUES.append(("phone", raw))
        return None
    return "+1" + digits


def normalize_name(raw):
    # "WHITFIELD, DANA" -&gt; "DANA WHITFIELD"; "Oak Holdings, LLC" stays as-is
    parts = [p.strip() for p in raw.split(",")]
    if len(parts) != 2 or not parts[1] or parts[1].upper().strip(".") in ENTITY_WORDS:
        return raw
    return f"{parts[1]} {parts[0]}"


def normalize_date(raw):
    # CRM exports mix 9/14/2026 and 2026-09-14; store everything as ISO YYYY-MM-DD
    for fmt in ("%Y-%m-%d", "%m/%d/%Y"):
        try:
            return datetime.strptime(raw, fmt).date().isoformat()
        except ValueError:
            pass
    BAD_VALUES.append(("date", raw))
    return None


def clean(col, value):
    if value is None or value.strip() == "":
        return None
    value = VALUE_MAP.get(col, {}).get(value.strip(), value.strip())
    if col == "phone":
        return normalize_phone(value)
    if col in ("owner_name", "contact_name"):
        return normalize_name(value)
    if col == "added_on":
        return normalize_date(value)
    return value


def load_csv(table, text):
    reader = csv.DictReader(io.StringIO(text))
    cols = reader.fieldnames
    if not cols:
        raise SystemExit(f"{table}: the file is empty (no header row).")
    rows = list(reader)
    if table == "source_list":
        dupes = sorted(i for i, n in Counter(r["record_id"] for r in rows).items() if n &gt; 1)
        if dupes:
            raise SystemExit(f"source_list: duplicate record_id values: {dupes[:20]}")
    sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})"
    con.executemany(sql, [[clean(c, r[c]) for c in cols] for r in rows])
    if BAD_VALUES:
        raise SystemExit(f"{table}: {len(BAD_VALUES)} values failed to normalize: {BAD_VALUES[:20]}")
    return len(rows)


print("source rows loaded:", load_csv("source_list", SOURCE_CSV))
print("return rows loaded:", load_csv("vendor_return", RETURN_CSV))

bad = con.execute("""
    SELECT 'phone_type' AS col, phone_type AS val FROM vendor_return
     WHERE phone_type IS NOT NULL AND phone_type NOT IN ('mobile','landline','voip')
    UNION SELECT 'dnc_flag', dnc_flag FROM vendor_return
     WHERE dnc_flag IS NOT NULL AND dnc_flag NOT IN ('Y','N')
    UNION SELECT 'litigator_flag', litigator_flag FROM vendor_return
     WHERE litigator_flag IS NOT NULL AND litigator_flag NOT IN ('Y','N')
    UNION SELECT 'vendor_rank', vendor_rank FROM vendor_return
     WHERE typeof(vendor_rank) NOT IN ('integer','null')""").fetchall()
if bad:
    raise SystemExit("Unexpected vendor values (add them to VALUE_MAP): "
                     + ", ".join(f"{r['col']}={r['val']!r}" for r in bad))
print("vendor values check: passed")
</code></pre>
<p>Output of Steps 1 and 2 (your SQLite version may differ):</p>
<pre><code class="language-text">Script version: merge-v2
SQLite version: 3.45.1
source rows loaded: 6
return rows loaded: 13
vendor values check: passed
</code></pre>
<p>Two checks stop the run on purpose:</p>
<ul>
<li><strong>Phones and dates that won't normalize.</strong> An extension, an international number or a date like <code>Sept 14</code> goes on <code>BAD_VALUES</code>. The run then stops and prints the count and the first 20 bad values, so you can fix a whole batch in one or two runs instead of one per row. Without the stop, a bad phone would load as blank and look like a no-hit.</li>
<li><strong>Unmapped vendor values.</strong> A <code>Cell</code> or <code>Yes</code> missing from <code>VALUE_MAP</code>, or a rank like <code>1st</code>, would land in the wrong ranking bucket or slip past a DNC filter. Add each value the check lists to <code>VALUE_MAP</code> and rerun.</li>
</ul>
<p><code>normalize_name</code> matters more than it looks. If your vendor writes names as <code>WHITFIELD, DANA</code> and you don't flip them, Step 5 ranks every one of the owner's own phones as a relative's.</p>
<h2>Step 3: Does the skip trace return file match your list?</h2>
<p>Count before you merge. The counts tell you whether the file belongs to this list, and they give you something concrete to raise with the vendor before you pay.</p>
<pre><code class="language-python">counts = con.execute("""
SELECT
  (SELECT COUNT(*) FROM source_list) AS source_records,
  (SELECT COUNT(DISTINCT s.record_id) FROM source_list s
     JOIN vendor_return v ON v.record_id = s.record_id) AS matched_records,
  (SELECT COUNT(DISTINCT s.record_id) FROM source_list s
     JOIN vendor_return v ON v.record_id = s.record_id
     WHERE v.phone IS NOT NULL) AS matched_with_phone,
  (SELECT COUNT(*) FROM source_list s WHERE NOT EXISTS
     (SELECT 1 FROM vendor_return v WHERE v.record_id = s.record_id)) AS unmatched_records,
  (SELECT COUNT(*) FROM vendor_return v WHERE NOT EXISTS
     (SELECT 1 FROM source_list s WHERE s.record_id = v.record_id)) AS orphan_return_rows
""").fetchone()
for key in counts.keys():
    print(f"{key:&gt;20}: {counts[key]}")

REPORTS = {
    "Unmatched source records": """SELECT s.record_id, s.owner_name FROM source_list s
        WHERE NOT EXISTS (SELECT 1 FROM vendor_return v WHERE v.record_id = s.record_id)""",
    "Returned with no phone (no-hit)": """SELECT s.record_id, s.owner_name FROM source_list s
        JOIN vendor_return v ON v.record_id = s.record_id
        GROUP BY s.record_id HAVING COUNT(v.phone) = 0""",
    "Orphan return rows (ID not in source)": """SELECT v.record_id, v.phone FROM vendor_return v
        WHERE NOT EXISTS (SELECT 1 FROM source_list s WHERE s.record_id = v.record_id)""",
}
for title, sql in REPORTS.items():
    print(f"\n{title}:")
    for r in con.execute(sql + " ORDER BY 1, 2"):
        print(" ", r[0], r[1])

VENDOR_REPORTED_HITS = 4  # fictional; copy the real figure from the vendor's batch summary
HIT_COLUMN = "matched_with_phone"  # or "matched_records", per the vendor's definition
ACCEPT_HIT_MISMATCH = False  # set True only after settling the gap, and log why

if counts[HIT_COLUMN] == VENDOR_REPORTED_HITS:
    print(f"\nVendor-reported hits ({VENDOR_REPORTED_HITS}) match {HIT_COLUMN}.")
elif ACCEPT_HIT_MISMATCH:
    print(f"\nACCEPTED MISMATCH: vendor {VENDOR_REPORTED_HITS}, {HIT_COLUMN} {counts[HIT_COLUMN]}.")
else:
    raise SystemExit(f"Stopping: the vendor reports {VENDOR_REPORTED_HITS} hits, but "
                     f"{HIT_COLUMN} is {counts[HIT_COLUMN]}. Settle it with the vendor.")
</code></pre>
<pre><code class="language-text">      source_records: 6
     matched_records: 5
  matched_with_phone: 4
   unmatched_records: 1
  orphan_return_rows: 1

Unmatched source records:
  R0004 Tom Okafor

Returned with no phone (no-hit):
  R0006 Jordan Pike

Orphan return rows (ID not in source):
  R0009 +19375550191

Vendor-reported hits (4) match matched_with_phone.
</code></pre>
<table>
<thead>
<tr>
<th>Count</th>
<th>What it means</th>
<th>What to do when it's off</th>
</tr>
</thead>
<tbody><tr>
<td><code>matched_records</code></td>
<td>Source IDs that came back, with or without a phone</td>
<td>Send the unmatched IDs (R0004) to the vendor and ask whether they were traced</td>
</tr>
<tr>
<td><code>matched_with_phone</code></td>
<td>Matched records with at least one phone. The gap from <code>matched_records</code> is the no-hits (R0006)</td>
<td>If your no-hit count is far above the vendor's, check the phone column mapping</td>
</tr>
<tr>
<td><code>orphan_return_rows</code></td>
<td>Return rows whose ID isn't in your list. They never reach an export</td>
<td>A large count usually means the wrong file or a mangled ID column</td>
</tr>
</tbody></table>
<p>Set <code>HIT_COLUMN</code> to your vendor's definition of a hit. Here the vendor's 4 matches <code>matched_with_phone</code>.</p>
<h2>Step 4: How do you find duplicate and conflicting phones in a skip trace file?</h2>
<p>Step 5 keeps only one copy of each repeated phone. Before that happens, confirm that the copies agree on type, both flags and name. If they don't, the dedupe would pick a <code>dnc_flag</code> by sort order alone. <code>COUNT(DISTINCT ...)</code> ignores <code>NULL</code>, so the query wraps each column in <code>COALESCE(..., '')</code> to count blanks as their own value.</p>
<p>Settle conflicts in <code>RESOLUTIONS</code>, never in the vendor's file. The sample has no conflicts, so the dictionary is empty. The commented entry shows the format.</p>
<pre><code class="language-python">RESOLUTIONS = {
    # ("R0002", "+19375550121"): {"dnc_flag": "Y"},  # vendor confirmed by email
}
for (record_id, phone), fix in RESOLUTIONS.items():
    if not fix or not set(fix) &lt;= {"phone_type", "dnc_flag", "litigator_flag", "contact_name"}:
        raise SystemExit(f"Resolution for {record_id} {phone}: unsupported columns {sorted(fix)}")
    cur = con.execute(
        f"UPDATE vendor_return SET {', '.join(f'{k} = ?' for k in fix)} "
        "WHERE record_id = ? AND phone = ?", [*fix.values(), record_id, phone])
    if cur.rowcount == 0:
        raise SystemExit(f"Resolution for {record_id} {phone} matched no rows.")
print("resolutions applied:", len(RESOLUTIONS))

print("\nDuplicate phones within a record:")
conflicts = []
for r in con.execute("""
    SELECT record_id, phone, COUNT(*) AS copies,
           MAX(COUNT(DISTINCT COALESCE(phone_type, '')),
               COUNT(DISTINCT COALESCE(dnc_flag, '')),
               COUNT(DISTINCT COALESCE(litigator_flag, '')),
               COUNT(DISTINCT COALESCE(contact_name, ''))) AS max_values
    FROM vendor_return
    WHERE phone IS NOT NULL AND record_id IN (SELECT record_id FROM source_list)
    GROUP BY record_id, phone HAVING COUNT(*) &gt; 1 ORDER BY record_id, phone"""):
    status = "CONFLICT" if r["max_values"] &gt; 1 else "type, flags, and name agree"
    if r["max_values"] &gt; 1:
        conflicts.append(f"{r['record_id']} {r['phone']}")
    print(f"  {r['record_id']} {r['phone']} copies={r['copies']} ({status})")
if conflicts:
    raise SystemExit("Stopping: add a RESOLUTIONS entry for " + ", ".join(conflicts))

print("\nSame phone returned for more than one owner record:")
for r in con.execute("""
    SELECT phone, GROUP_CONCAT(record_id || ' ' || owner_name || ', listed under '
                               || COALESCE(contact_name, 'no name'), '|') AS details
    FROM (SELECT DISTINCT v.phone, v.record_id, s.owner_name, v.contact_name
          FROM vendor_return v JOIN source_list s ON s.record_id = v.record_id
          WHERE v.phone IS NOT NULL)
    GROUP BY phone HAVING COUNT(*) &gt; 1 ORDER BY phone"""):
    print(" ", r["phone"])
    for detail in sorted(r["details"].split("|")):  # sorted so reruns print identically
        print("   ", detail)

SHARED_REMOVALS = {
    # ("R0005", "+19375550131"): "listed under Priya Raman; assigned to R0003",
}
for (record_id, phone), why in SHARED_REMOVALS.items():
    cur = con.execute("DELETE FROM vendor_return WHERE record_id = ? AND phone = ?",
                      [record_id, phone])
    if cur.rowcount == 0:
        raise SystemExit(f"Shared removal for {record_id} {phone} matched no rows.")
print("shared removals applied:", len(SHARED_REMOVALS))
</code></pre>
<pre><code class="language-text">resolutions applied: 0

Duplicate phones within a record:
  R0002 +19375550121 copies=2 (type, flags, and name agree)

Same phone returned for more than one owner record:
  +19375550131
    R0003 Priya Raman, listed under Priya Raman
    R0005 Elena Ruiz, listed under Priya Raman
shared removals applied: 0
</code></pre>
<p>If you can't confirm the right value for a conflict, resolve toward the restrictive one (<code>dnc_flag = 'Y'</code>) and log why. A resolution whose key matches no row also stops the run, so an outdated fix can't quietly do nothing.</p>
<p>A phone returned under two different records means one of two things:</p>
<ul>
<li><strong>Same person.</strong> One owner holds several properties under name variants your list dedupe missed, and your dialer would call them twice in one campaign.</li>
<li><strong>Different person.</strong> It's a relative or a shared line, which makes it a weaker match for one of the records.</li>
</ul>
<p>Here the vendor lists the number under Priya Raman both times. It's most likely Priya's phone, showing up as an associated contact on Elena's record. The script reports the shared phone but doesn't change anything. Decide which record it belongs to before the upload, and log your decision.</p>
<h2>Step 5: How do you rank and dedupe phone numbers per owner with ROW_NUMBER()?</h2>
<p>One view uses two window functions. The first numbers the copies of each phone within a record, and <code>copy_num = 1</code> keeps the best-ranked copy. The second ranks the remaining phones for each owner, sorting by:</p>
<ol>
<li><strong>Name match</strong> (<code>name_order</code>). Phones listed under the owner come before relatives' phones. If you trust the vendor's rank more than its name data, remove this term.</li>
<li><strong>Vendor rank.</strong> SQLite sorts <code>NULL</code> before numbers, so without the <code>vendor_rank IS NULL</code> term, blank-rank phones would jump to the top. That term pushes them to the end.</li>
<li><strong>Type.</strong> Mobile first, then landline, then everything else.</li>
<li><strong>The phone itself</strong>, as a tiebreaker so the order is the same on every run.</li>
</ol>
<pre><code class="language-python">con.executescript("""
CREATE VIEW ranked_phones AS
WITH matched AS (
    SELECT v.*,
           CASE WHEN UPPER(v.contact_name) = UPPER(s.owner_name) THEN 0 ELSE 1 END AS name_order,
           CASE v.phone_type WHEN 'mobile' THEN 1 WHEN 'landline' THEN 2 ELSE 3 END AS type_order
    FROM vendor_return v JOIN source_list s ON s.record_id = v.record_id
    WHERE v.phone IS NOT NULL
),
deduped AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY record_id, phone
                                 ORDER BY vendor_rank IS NULL, vendor_rank) AS copy_num
    FROM matched
)
SELECT record_id, phone, phone_type, vendor_rank, dnc_flag, litigator_flag, contact_name,
       ROW_NUMBER() OVER (PARTITION BY record_id
           ORDER BY name_order, vendor_rank IS NULL, vendor_rank, type_order, phone) AS phone_rank
FROM deduped WHERE copy_num = 1;
""")

print(f"  {'record':&lt;6} {'rk':&gt;2}  {'phone':&lt;13} {'type':&lt;8} {'v_rank':&gt;6}  dnc lit  listed under")
for r in con.execute("SELECT * FROM ranked_phones ORDER BY record_id, phone_rank"):
    vr = "" if r["vendor_rank"] is None else r["vendor_rank"]
    # blank cells come back as None; "or ''" keeps the width formatting from failing
    print(f"  {r['record_id']:&lt;6} {r['phone_rank']:&gt;2}  {r['phone']:&lt;13} "
          f"{r['phone_type'] or '':&lt;8} {vr!s:&gt;6}  {r['dnc_flag'] or '':&lt;3} "
          f"{r['litigator_flag'] or '':&lt;3}  {r['contact_name'] or ''}")
</code></pre>
<pre><code class="language-text">  record rk  phone         type     v_rank  dnc lit  listed under
  R0001   1  +19375550101  mobile        1  N   N    Dana Whitfield
  R0001   2  +19375550102  landline      2  Y   N    Dana Whitfield
  R0001   3  +19375550103  mobile        3  N   N    Dana Whitfield
  R0001   4  +19375550104  voip          4  N   N    Kim Whitfield
  R0002   1  +19375550122  mobile        1  N   N    Marcus Bell
  R0002   2  +19375550121  landline      1  N   N    Marcus Bell
  R0003   1  +19375550131  mobile        2  Y   N    Priya Raman
  R0005   1  +19375550151  mobile        1  N   Y    Elena Ruiz
  R0005   2  +19375550152  landline         N   N    ELENA RUIZ
  R0005   3  +19375550131  mobile        2  Y   N    Priya Raman
</code></pre>
<p>For R0002, the duplicate landline collapsed to one row. The mobile and the landline tied at vendor rank 1, and the mobile won on type. For R0005, <code>normalize_name</code> turned <code>RUIZ, ELENA</code> into a match for the owner. That landline therefore ranks above the shared number listed under Priya, even though its vendor rank is blank.</p>
<p>The SQL never filters out or rewrites <code>dnc_flag</code> or <code>litigator_flag</code>. Ranking decides the column order, not which numbers you're allowed to call.</p>
<p>The vendor's flags reflect their check at trace time and don't replace your own scrub before each campaign. Numbers also get reassigned. Texting a mobile can be treated differently from calling a landline, which is why every export slot keeps <code>phone_type</code>. Calls and texts can fall under the TCPA, federal and state do-not-call rules, and state telemarketing laws. Check the rules that apply to you with your counsel.</p>
<h2>Step 6: How do you suppress your own opt-outs and STOP replies after a re-trace?</h2>
<p>Your team already keeps its own do-not-contact list: STOP replies, "don't call me" notes from acquisitions calls and numbers flagged in your CRM. A re-trace can bring those numbers back flagged <code>N</code>. A vendor's <code>N</code> never overrides your own opt-out.</p>
<p>Export the list from your CRM fresh on every run. It goes through the same <code>clean</code> function, so <code>(937) 555-0103</code> matches <code>+19375550103</code> and <code>9/14/2026</code> becomes <code>2026-09-14</code>.</p>
<pre><code class="language-python">SUPPRESSION_CSV = """phone,reason,added_on
(937) 555-0103,replied STOP,9/14/2026
937-555-0103,asked not to be called,2026-10-02
"""  # fictional; in practice, export this from your CRM's do-not-contact field

con.executescript("""
DROP TABLE IF EXISTS suppression_raw;
DROP TABLE IF EXISTS internal_suppression;
CREATE TABLE suppression_raw (phone TEXT, reason TEXT, added_on TEXT);
""")
print("suppression rows loaded:", load_csv("suppression_raw", SUPPRESSION_CSV))
con.executescript("""
CREATE TABLE internal_suppression AS
SELECT phone, MIN(reason) AS reason, MAX(added_on) AS added_on
FROM suppression_raw WHERE phone IS NOT NULL GROUP BY phone;
CREATE UNIQUE INDEX idx_supp_phone ON internal_suppression(phone);
""")

print("Ranked phones on your internal suppression list:")
for r in con.execute("""
    SELECT p.record_id, p.phone, i.reason, i.added_on
    FROM ranked_phones p JOIN internal_suppression i ON i.phone = p.phone
    ORDER BY p.record_id, p.phone"""):
    print(f"  {r['record_id']} {r['phone']} {r['reason']} (latest {r['added_on']})")
</code></pre>
<pre><code class="language-text">suppression rows loaded: 2
Ranked phones on your internal suppression list:
  R0001 +19375550103 asked not to be called (latest 2026-10-02)
</code></pre>
<p>The two CRM rows collapse into one. The unique index guarantees one row per phone, so the Step 7 join can't repeat rows.</p>
<p><code>MAX(added_on)</code> compares text, so it only gives the right answer for <code>YYYY-MM-DD</code> dates. That's why <code>clean</code> converts the dates first. Unconverted, <code>9/14/2026</code> would sort after <code>2026-10-02</code> and be picked as the latest date. The collapsed row keeps one <code>reason</code> (the alphabetically first). See <code>suppression_raw</code> for all of them.</p>
<p>The vendor returned R0001's third number with <code>dnc_flag = N</code>, but the owner opted out. Step 7 marks the number in the audit file and keeps it out of the dialer file.</p>
<h2>Step 7: How do you export phone_1, phone_2, phone_3 columns for your CRM and dialer?</h2>
<p>Most CRMs and dialers want one row per property, with <code>phone_1</code> through <code>phone_3</code> side by side. Conditional aggregation (<code>MAX(CASE WHEN phone_rank = n ...)</code>) builds each slot with its own type and flags, so a flag can't drift onto a neighboring number.</p>
<p>The query starts from <code>source_list</code> with a <code>LEFT JOIN</code>. That keeps the unmatched and no-hit records and drops the orphans. The dialer file removes flagged numbers first and then renumbers the ones that are left.</p>
<pre><code class="language-python">TOP_N = 3
FLAGS = {"type": "p.phone_type", "dnc": "p.dnc_flag", "litigator": "p.litigator_flag",
         "internal_dnc": "CASE WHEN i.phone IS NULL THEN 'N' ELSE 'Y' END"}
slot_cols = []
for n in range(1, TOP_N + 1):
    slot_cols.append(f"MAX(CASE WHEN p.phone_rank = {n} THEN p.phone END) AS phone_{n}")
    for name, expr in FLAGS.items():
        slot_cols.append(f"MAX(CASE WHEN p.phone_rank = {n} THEN {expr} END) AS phone_{n}_{name}")

export_sql = f"""
SELECT s.*, COUNT(p.phone) AS distinct_phones, {', '.join(slot_cols)}
FROM source_list s
LEFT JOIN ranked_phones p ON p.record_id = s.record_id
LEFT JOIN internal_suppression i ON i.phone = p.phone
GROUP BY s.record_id ORDER BY s.record_id"""

DIALER_POLICY = "exclude dnc_flag=Y, litigator_flag=Y, internal suppression; blank flags as N"
DIAL_FILTER = """COALESCE(p.dnc_flag, 'N') &lt;&gt; 'Y'
      AND COALESCE(p.litigator_flag, 'N') &lt;&gt; 'Y'
      AND NOT EXISTS (SELECT 1 FROM internal_suppression i WHERE i.phone = p.phone)"""

dial_cols = []
for n in range(1, TOP_N + 1):
    dial_cols.append(f"MAX(CASE WHEN d.dial_rank = {n} THEN d.phone END) AS phone_{n}")
    dial_cols.append(f"MAX(CASE WHEN d.dial_rank = {n} THEN d.phone_type END) AS phone_{n}_type")

dialer_sql = f"""
WITH dialable AS (
    SELECT p.record_id, p.phone, p.phone_type,
           ROW_NUMBER() OVER (PARTITION BY p.record_id ORDER BY p.phone_rank) AS dial_rank
    FROM ranked_phones p WHERE {DIAL_FILTER})
SELECT s.*, {', '.join(dial_cols)}
FROM source_list s JOIN dialable d ON d.record_id = s.record_id
GROUP BY s.record_id ORDER BY s.record_id"""

no_dial_sql = f"""
SELECT s.* FROM source_list s
WHERE s.record_id NOT IN (SELECT p.record_id FROM ranked_phones p WHERE {DIAL_FILTER})
ORDER BY s.record_id"""


def write_csv(path, sql):
    cur = con.execute(sql)
    header = [d[0] for d in cur.description]
    rows = cur.fetchall()
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(header)
        writer.writerows([["" if v is None else v for v in row] for row in rows])
    print(f"Wrote {len(rows)} rows ({len(header)} columns) to {path}")
    return rows


for row in write_csv("merged_export.csv", export_sql):
    slots = []
    for n in range(1, TOP_N + 1):
        cells = [row[f"phone_{n}{s}"] for s in ("", "_dnc", "_litigator", "_internal_dnc")]
        slots.append("/".join(c or "-" for c in cells))
    print(f"  {row['record_id']} n={row['distinct_phones']}  " + "  ".join(slots))

print("\nDialer policy:", DIALER_POLICY)
for row in write_csv("dialer_upload.csv", dialer_sql):
    print(f"  {row['record_id']}  " + "  ".join(row[f"phone_{n}"] or "-" for n in range(1, TOP_N + 1)))

print()
for row in write_csv("no_dialable_phone.csv", no_dial_sql):
    print(f"  {row['record_id']} {row['owner_name']}")

con.commit()
con.close()
</code></pre>
<p>Each audit slot prints as <code>phone/dnc/litigator/internal_dnc</code>:</p>
<pre><code class="language-text">Wrote 6 rows (22 columns) to merged_export.csv
  R0001 n=4  +19375550101/N/N/N  +19375550102/Y/N/N  +19375550103/N/N/Y
  R0002 n=2  +19375550122/N/N/N  +19375550121/N/N/N  -/-/-/-
  R0003 n=1  +19375550131/Y/N/N  -/-/-/-  -/-/-/-
  R0004 n=0  -/-/-/-  -/-/-/-  -/-/-/-
  R0005 n=3  +19375550151/N/Y/N  +19375550152/N/N/N  +19375550131/Y/N/N
  R0006 n=0  -/-/-/-  -/-/-/-  -/-/-/-

Dialer policy: exclude dnc_flag=Y, litigator_flag=Y, internal suppression; blank flags as N
Wrote 3 rows (12 columns) to dialer_upload.csv
  R0001  +19375550101  +19375550104  -
  R0002  +19375550122  +19375550121  -
  R0005  +19375550152  -  -

Wrote 3 rows (6 columns) to no_dialable_phone.csv
  R0003 Priya Raman
  R0004 Tom Okafor
  R0006 Jordan Pike
</code></pre>
<p><strong><code>merged_export.csv</code> is your audit record and CRM import.</strong> It has 22 columns: the 6 source columns, <code>distinct_phones</code> and five columns per slot.</p>
<ul>
<li>Use <code>record_id</code> as the external ID, so a later re-trace updates existing records instead of creating duplicates.</li>
<li>Map the three flag columns to your CRM's do-not-contact fields.</li>
<li>Don't upload this file to a dialer. Every number in it still carries its flags, so the dialer would call R0003's DNC number, R0001's opted-out number and R0005's litigator-flagged number.</li>
</ul>
<p><strong><code>dialer_upload.csv</code> is the only file that goes to the dialer.</strong> The filter runs before the slots are renumbered. As a result, R0001's fourth phone, which <code>TOP_N</code> cut from the audit export, becomes its <code>phone_2</code> here. To dial only numbers that appear in the audit export, add this line right after <code>DIAL_FILTER</code> is defined:</p>
<pre><code>DIAL_FILTER += f"\n      AND p.phone_rank &lt;= {TOP_N}"
</code></pre>
<p>Both the dialer file and the no-dialable file pick it up. (It's not part of the sample run, so the outputs above don't include it.)</p>
<p>Two more settings to check:</p>
<ul>
<li><strong>Blank flags count as <code>N</code>.</strong> That's a policy choice, not a fact about the number. To skip numbers the vendor didn't check, change <code>'N'</code> to <code>'Y'</code> in both <code>COALESCE</code> calls and update <code>DIALER_POLICY</code> to match.</li>
<li><strong>Shared numbers.</strong> A clean shared number would land in both owners' rows. Add a <code>SHARED_REMOVALS</code> entry for the record it doesn't belong to.</li>
</ul>
<p><strong><code>no_dialable_phone.csv</code> is still a lead list.</strong> R0004 wasn't returned and R0006 was a no-hit, so send them to your mailer or a second-source trace. For R0003, a DNC flag on the phone doesn't bar mail. Still check whether the owner asked not to be contacted at all before you send anything.</p>
<p>These filters are a starting point, not a legal determination. Scrub against current do-not-call lists before each campaign, and confirm your policy with counsel.</p>
<h2>What to log for every skip trace batch</h2>
<p>Save the vendor's return file and your suppression export exactly as they arrived, with the batch date in the file names, and never edit them. Weeks later someone may ask why a number was or wasn't dialed. You can then rerun the script on the untouched originals with the logged settings and get the same answer.</p>
<p><strong>Per-batch checklist (log each item):</strong></p>
<ul>
<li><code>SCRIPT_VERSION</code>, and confirmation that the vendor returned <code>record_id</code> unchanged</li>
<li>Every <code>VALUE_MAP</code> entry (and <code>COLUMN_MAP</code> entry, if you use the appendix loader) you added for this vendor, and how you handled each <code>BAD_VALUES</code> item (in code, never in the raw file)</li>
<li>The Step 3 counts, the vendor's hit count, <code>HIT_COLUMN</code>, and the reason if you set <code>ACCEPT_HIT_MISMATCH</code></li>
<li>Each <code>RESOLUTIONS</code> entry and where the value came from (restrictive when unconfirmed)</li>
<li>Each shared phone, the record you assigned it to, and its <code>SHARED_REMOVALS</code> entry</li>
<li>The suppression export date and how many ranked phones it caught</li>
<li><code>TOP_N</code>, the ranking order and <code>DIALER_POLICY</code></li>
<li>That only <code>dialer_upload.csv</code> went to the dialer, that <code>no_dialable_phone.csv</code> went to mail or a re-trace, and that the campaign was scrubbed first</li>
</ul>
<h2>Appendix: How do you load real CSV files instead of inline text?</h2>
<p>This version replaces the <code>load_csv</code> function in Step 2 and its two calls. Don't run both loaders. If you do, <code>source_list</code> loads twice and the duplicate IDs break the <code>PRIMARY KEY</code>.</p>
<pre><code class="language-python">COLUMN_MAP = {"Phone Number": "phone", "Record ID": "record_id"}  # examples


def load_csv(table, path, encoding="utf-8-sig"):
    with open(path, newline="", encoding=encoding) as f:
        reader = csv.DictReader(f, restkey="_extra")
        if not reader.fieldnames:
            raise SystemExit(f"{table}: {path} is empty (no header row).")
        cols = [COLUMN_MAP.get(c, c) for c in reader.fieldnames]
        if len(set(cols)) != len(cols):
            raise SystemExit(f"{table}: two header columns map to the same name: {cols}")
        rows = []
        for r in reader:
            if "_extra" in r or None in r.values():
                raise SystemExit(f"{table}: {path} line {reader.line_num} doesn't match the header.")
            rows.append({COLUMN_MAP.get(k, k): v for k, v in r.items()})
    known = {r[1] for r in con.execute(f"PRAGMA table_info({table})")}
    unknown = [c for c in cols if c not in known]
    if unknown:
        raise SystemExit(f"{table}: header columns not in the table: {unknown}")
    if table == "source_list":
        dupes = sorted(i for i, n in Counter(r["record_id"] for r in rows).items() if n &gt; 1)
        if dupes:
            raise SystemExit(f"source_list: duplicate record_id values: {dupes[:20]}")
    sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})"
    con.executemany(sql, [[clean(c, r[c]) for c in cols] for r in rows])
    if BAD_VALUES:
        raise SystemExit(f"{table}: {len(BAD_VALUES)} values failed to normalize: {BAD_VALUES[:20]}")
    return len(rows)


# print("source rows loaded:", load_csv("source_list", "source_list.csv"))
# print("return rows loaded:", load_csv("vendor_return", "vendor_return.csv"))
# Step 6: load_csv("suppression_raw", "crm_do_not_contact.csv")
</code></pre>
<p>Each of these problems stops the run with a clear message:</p>
<ul>
<li><strong>Header problems.</strong> <code>PRAGMA table_info</code> catches vendor headers you haven't mapped yet. A <code>set</code> comparison catches two headers mapped to the same name.</li>
<li><strong>Rows that don't match the header.</strong> A row with more or fewer fields than the header usually means an unquoted comma in an owner name or address. The message gives the line number.</li>
<li><strong>Repeated <code>record_id</code> values.</strong> <code>Counter</code> counts them in one pass, so a 40,000-row absentee pull doesn't stall the way a nested <code>list.count</code> check would.</li>
</ul>
<p><code>encoding="utf-8-sig"</code> strips the invisible byte order mark that Excel often adds to the start of a CSV. Without it, <code>record_id</code> would read as <code>\ufeffrecord_id</code>. If the load fails with <code>UnicodeDecodeError</code>, the file was most likely saved in a Windows encoding such as cp1252. Open it with <code>encoding="cp1252"</code>, or re-save it as CSV UTF-8 from Excel.</p>
<p>Published by Locate Next (locatenext.com).</p>
]]></content:encoded></item><item><title><![CDATA[Flagging absentee owners with Python and pandas by comparing situs and mailing addresses]]></title><description><![CDATA[Normalize county parcel addresses with the standard library, then sort owners into clear, reviewable categories]]></description><link>https://locatenext.hashnode.dev/flagging-absentee-owners-situs-vs-mailing-address-pandas</link><guid isPermaLink="true">https://locatenext.hashnode.dev/flagging-absentee-owners-situs-vs-mailing-address-pandas</guid><category><![CDATA[Python]]></category><category><![CDATA[pandas]]></category><category><![CDATA[data cleaning ]]></category><category><![CDATA[Real Estate]]></category><category><![CDATA[address-normalization]]></category><dc:creator><![CDATA[Locate Next]]></dc:creator><pubDate>Thu, 22 Oct 2026 22:17:10 GMT</pubDate><content:encoded><![CDATA[<p>County parcel files often hold a useful signal: the property address and the owner's mailing address don't always match. If an owner gets their tax bill somewhere other than the property, they may not live there. For wholesalers and acquisition teams pulling absentee owner lists for skip tracing, mailers and dialer campaigns, building that list from county records is a common first step.</p>
<p>Getting a reliable flag takes more work than checking whether two columns are equal. This guide shows how to normalize addresses with the Python standard library and compare situs and mailing addresses in pandas. It then puts each parcel into a category (owner occupied, absentee, or needs review) that you can explain to someone else.</p>
<h2>Situs address vs mailing address</h2>
<p>Most county assessor and parcel exports have two sets of address fields:</p>
<ul>
<li><strong>Situs address</strong>: the physical location of the parcel, where the house, lot or building actually sits.</li>
<li><strong>Mailing address</strong>: where the county sends tax bills and notices for the owner of record. It can be the property itself, another home, a business office, a PO box, or the address of a trustee or attorney.</li>
</ul>
<p>If the mailing address matches the situs address, the owner probably lives at the property (owner occupied). If it doesn't match, the owner may be absentee.</p>
<p>Column names vary a lot between counties. You might see <code>SITUS_ADDR</code>, <code>PROP_ADDR</code>, <code>MAIL_ADDR1</code>, <code>MAIL_ADDR2</code>, <code>OWN_ZIP</code> and many others. The examples below use generic names, so map your county's columns onto them first.</p>
<h2>Why naive string comparison fails</h2>
<p>The obvious first try is to compare the strings directly. Here is what happens with three parcels where each owner clearly lives at the property.</p>
<p>All names, addresses, ZIPs and parcel IDs in this guide are fictional example data.</p>
<pre><code class="language-python">import pandas as pd

parcels = pd.DataFrame({
    "parcel_id": ["P-1001", "P-1002", "P-1003"],
    "situs_address": ["412 Maple Grove Street", "88 N. Birch Ave Apt 2", "1907 Quarry Rd"],
    "situs_zip": ["43001", "43001", "43002"],
    "mail_address": ["412 MAPLE GROVE ST", "88 North Birch Avenue, Apt. 2", "1907 Quarry Road"],
    "mail_zip": ["43001-2218", "43001", "43002-0417"],
})

parcels["naive_match"] = (
    (parcels["situs_address"] == parcels["mail_address"])
    &amp; (parcels["situs_zip"] == parcels["mail_zip"])
)

for row in parcels.itertuples(index=False):
    print(row.parcel_id, row.naive_match)
</code></pre>
<pre><code class="language-text">P-1001 False
P-1002 False
P-1003 False
</code></pre>
<p>All three come back as mismatches, so every one of these owners would be wrongly flagged as absentee. The usual causes are:</p>
<ul>
<li><strong>Case</strong>: <code>Maple Grove</code> vs <code>MAPLE GROVE</code>.</li>
<li><strong>Suffix abbreviations</strong>: <code>Street</code> vs <code>ST</code>, <code>Avenue</code> vs <code>Ave</code>, <code>Road</code> vs <code>Rd</code>.</li>
<li><strong>Directionals</strong>: <code>N.</code> vs <code>North</code>, and <code>Street N</code> vs <code>St North</code>.</li>
<li><strong>Punctuation</strong>: periods, commas, hyphens and <code>#</code> signs.</li>
<li><strong>Unit lines</strong>: one side may have the unit in the main address field and the other in a separate <code>mail_address_2</code> field.</li>
<li><strong>ZIP+4 vs five digit ZIP</strong>: for matching, <code>43001-2218</code> and <code>43001</code> are the same delivery area.</li>
<li><strong>Lost leading zeros</strong>: if a spreadsheet or pandas reads ZIPs as numbers, <code>02134</code> turns into <code>2134</code>, or <code>2134.0</code> if the column has blanks.</li>
</ul>
<h2>A standard library address normalization module</h2>
<p>The fix is to convert both addresses to the same standard form before comparing them. You don't need a heavy address parsing package for this. A small suffix map, a directional map and a few regular expressions handle most of what county files contain.</p>
<p>Save the following as <code>address_norm.py</code>. The later examples import from it.</p>
<pre><code class="language-python">import re

import pandas as pd

SUFFIXES = {
    "STREET": "ST", "AVENUE": "AVE", "ROAD": "RD", "DRIVE": "DR",
    "LANE": "LN", "BOULEVARD": "BLVD", "COURT": "CT", "PLACE": "PL",
    "CIRCLE": "CIR", "TERRACE": "TER", "PARKWAY": "PKWY", "HIGHWAY": "HWY",
}
DIRECTIONALS = {
    "NORTH": "N", "SOUTH": "S", "EAST": "E", "WEST": "W",
    "NORTHEAST": "NE", "NORTHWEST": "NW", "SOUTHEAST": "SE", "SOUTHWEST": "SW",
}
DIRECTIONAL_TOKENS = set(DIRECTIONALS) | set(DIRECTIONALS.values())
UNIT_WORDS = {"APT", "APARTMENT", "UNIT", "STE", "SUITE", "#"}
PO_BOX = re.compile(r"\b(P\s*O|POST\s+OFFICE)\s*BOX\b")


def clean_text(value):
    if pd.isna(value):
        return ""
    text = str(value).upper().replace("#", " # ")
    text = re.sub(r"[^A-Z0-9# ]", " ", text)
    return re.sub(r"\s+", " ", text).strip()


def normalize_street(value):
    tokens = clean_text(value).split()
    unit = ""
    for i, tok in enumerate(tokens):
        if i &gt; 0 and tok in UNIT_WORDS:
            unit = "".join(t for t in tokens[i + 1:] if t not in UNIT_WORDS)
            tokens = tokens[:i]
            break
    last = len(tokens) - 1
    out = []
    for i, tok in enumerate(tokens):
        if tok in DIRECTIONALS and i in (1, last):
            tok = DIRECTIONALS[tok]
        elif tok in SUFFIXES and (
            i == last or (i == last - 1 and tokens[last] in DIRECTIONAL_TOKENS)
        ):
            tok = SUFFIXES[tok]
        out.append(tok)
    return " ".join(out), unit


def normalize_zip(value):
    text = "" if pd.isna(value) else str(value).strip()
    text = re.sub(r"\.0$", "", text)  # 2134.0 from a float column
    if text.isdigit() and len(text) in (3, 4, 7, 8):  # leading zeros lost as a number
        text = text.zfill(5 if len(text) &lt; 5 else 9)
    digits = re.sub(r"\D", "", text)
    return digits[:5] if len(digits) &gt;= 5 else ""


def is_po_box(value):
    return bool(PO_BOX.search(clean_text(value)))
</code></pre>
<p>Here is how it handles the problem cases:</p>
<pre><code class="language-python">from address_norm import is_po_box, normalize_street, normalize_zip

samples = [
    "412 Maple Grove Street",
    "412 MAPLE GROVE ST",
    "88 N. Birch Ave Apt 2",
    "88 North Birch Avenue, Apt. 2",
    "300 Elm St. #4B",
    "300 Elm Street Apt #4-B",
    "100 Main Street N",
    "100 Main St North",
    "P.O. Box 117",
]
for s in samples:
    street, unit = normalize_street(s)
    print(f"{street} | unit={unit} | po_box={is_po_box(s)}")

for z in ["43001-2218", "43001", "2134", 2134.0, "501", " 85004 "]:
    print(repr(z), "-&gt;", repr(normalize_zip(z)))
</code></pre>
<pre><code class="language-text">412 MAPLE GROVE ST | unit= | po_box=False
412 MAPLE GROVE ST | unit= | po_box=False
88 N BIRCH AVE | unit=2 | po_box=False
88 N BIRCH AVE | unit=2 | po_box=False
300 ELM ST | unit=4B | po_box=False
300 ELM ST | unit=4B | po_box=False
100 MAIN ST N | unit= | po_box=False
100 MAIN ST N | unit= | po_box=False
P O BOX 117 | unit= | po_box=True
'43001-2218' -&gt; '43001'
'43001' -&gt; '43001'
'2134' -&gt; '02134'
2134.0 -&gt; '02134'
'501' -&gt; '00501'
' 85004 ' -&gt; '85004'
</code></pre>
<p>A few design choices are worth explaining:</p>
<ul>
<li><strong>Position matters.</strong> Suffixes are only abbreviated in the last position, or just before a trailing directional. Directionals are only abbreviated right after the house number or at the end. That keeps a street name like <code>Lane Ridge</code> intact in most cases.</li>
<li><strong>Units are split off, not discarded.</strong> The street line and the unit come back separately, so you can check the building and the unit as two separate questions. <code>Apt #4-B</code>, <code>Unit 4B</code> and <code>#4B</code> all become <code>4B</code>.</li>
<li><strong>ZIPs are cut to five digits.</strong> ZIP+4 detail helps mail delivery but adds noise when you're matching addresses.</li>
<li><strong>Read ZIPs as text.</strong> Passing <code>dtype=str</code> to <code>pd.read_csv</code> prevents most leading zero problems. The <code>.0</code> strip and zero padding are only a fallback for files already damaged in a spreadsheet. Padding only applies to values made entirely of digits, so a mangled entry like <code>43001-22</code> isn't silently turned into a different ZIP.</li>
</ul>
<p>Keep the maps small and add entries when your own data needs them. Known gaps you may need to cover yourself: units written without a keyword (<code>88 Birch Ave 2</code>), <code>C/O</code> name lines in the mailing address, states spelled out (<code>Ohio</code> instead of <code>OH</code>), and foreign mailing addresses.</p>
<h2>Comparison logic and categories</h2>
<p>With normalization in place, you can set up categories that you can still explain when someone asks why a parcel was flagged. This example uses four:</p>
<ul>
<li><code>owner_occupied</code>: the street line and five digit ZIP match.</li>
<li><code>absentee_in_state</code>: the addresses differ and the mailing state matches the county's state.</li>
<li><code>absentee_out_of_state</code>: the addresses differ and the mailing state is somewhere else.</li>
<li><code>needs_review</code>: the record can't be classified with confidence, for example a PO box mailing address, missing fields, or the same building with different units.</li>
</ul>
<p>A PO box goes to review because it says nothing about where the owner lives. Plenty of rural and small town owners use a box at their local post office and still live on the parcel.</p>
<p>The script below keeps every original column and adds two new ones: <code>occupancy_flag</code> and <code>flag_reason</code>. Keeping the source fields lets a reviewer see exactly what the logic saw. Run it from the same folder as <code>address_norm.py</code>.</p>
<pre><code class="language-python">import pandas as pd

from address_norm import clean_text, is_po_box, normalize_street, normalize_zip

COUNTY_STATE = "OH"


def classify(row):
    situs_street, situs_unit = normalize_street(row["situs_address"])
    mail_line = f"{clean_text(row['mail_address'])} {clean_text(row['mail_address_2'])}"
    mail_street, mail_unit = normalize_street(mail_line)
    situs_zip = normalize_zip(row["situs_zip"])
    mail_zip = normalize_zip(row["mail_zip"])
    mail_state = clean_text(row["mail_state"])

    if not (situs_street and mail_street and situs_zip and mail_zip):
        return "needs_review", "missing_field"
    if is_po_box(mail_line):
        return "needs_review", "po_box"
    if situs_street == mail_street and situs_zip == mail_zip:
        if situs_unit and mail_unit and situs_unit != mail_unit:
            return "needs_review", "unit_mismatch"
        return "owner_occupied", "address_match"
    if not mail_state:
        return "needs_review", "missing_state"
    if mail_state == COUNTY_STATE:
        return "absentee_in_state", "in_state_mailing"
    return "absentee_out_of_state", "out_of_state_mailing"


parcels = pd.DataFrame({
    "parcel_id": ["P-1001", "P-1002", "P-1003", "P-1004", "P-1005", "P-1006", "P-1007"],
    "owner_name": ["Dana Whitfield", "Marcus Ellery", "Priya Okafor",
                   "Halvorsen Family Trust", "Gerald Pruitt", "Ruth Amsel", "Tomas Reyes"],
    "situs_address": ["412 Maple Grove Street", "88 N. Birch Ave Apt 2", "1907 Quarry Rd",
                      "300 Elm St. #4B", "16 Cedar Ct", "2290 Old Mill Road",
                      "300 Elm St Unit 2A"],
    "situs_zip": ["43001", "43001", "43002", "43002", "43001", "43003", "43002"],
    "mail_address": ["412 MAPLE GROVE ST", "88 North Birch Avenue", "5520 Lakeview Terrace",
                     "71 Cactus Wren Dr", "P.O. Box 117", None, "300 Elm Street"],
    "mail_address_2": [None, "Apt. 2", None, None, None, None, "Unit 4B"],
    "mail_state": ["OH", "OH", "OH", "AZ", "OH", None, "OH"],
    "mail_zip": ["43001-2218", "43001", "43215", "85004-1180", "43001", None, "43002"],
})

flags = [classify(row) for row in parcels.to_dict("records")]
parcels["occupancy_flag"] = [flag for flag, _ in flags]
parcels["flag_reason"] = [reason for _, reason in flags]

for row in parcels.itertuples(index=False):
    print(f"{row.parcel_id}  {row.occupancy_flag:&lt;22} {row.flag_reason}")

print()
counts = parcels["occupancy_flag"].value_counts()
for flag in ["owner_occupied", "absentee_in_state", "absentee_out_of_state", "needs_review"]:
    print(f"{flag}: {counts.get(flag, 0)}")

print()
print(list(parcels.columns))
</code></pre>
<pre><code class="language-text">P-1001  owner_occupied         address_match
P-1002  owner_occupied         address_match
P-1003  absentee_in_state      in_state_mailing
P-1004  absentee_out_of_state  out_of_state_mailing
P-1005  needs_review           po_box
P-1006  needs_review           missing_field
P-1007  needs_review           unit_mismatch

owner_occupied: 2
absentee_in_state: 1
absentee_out_of_state: 1
needs_review: 3

['parcel_id', 'owner_name', 'situs_address', 'situs_zip', 'mail_address', 'mail_address_2', 'mail_state', 'mail_zip', 'occupancy_flag', 'flag_reason']
</code></pre>
<p>Some notes on the logic:</p>
<ul>
<li><strong>The order of checks matters.</strong> Missing addresses and ZIPs are checked first, then PO boxes, then the address match. That way a record with an empty mailing field never slips into an absentee category by default. The mailing state is only required once you know the addresses differ.</li>
<li><strong>The unit line is merged before normalizing.</strong> P-1002 has its unit in <code>mail_address_2</code>, and it still matches because both lines are combined first. The PO box check also runs on the merged line, so a box on line 2 is caught.</li>
<li><strong>A unit mismatch is not treated as absentee.</strong> P-1007's owner could live in unit 2A and get mail at a unit they also own, or the county record could be out of date. A person should look at it.</li>
<li><strong><code>flag_reason</code> sits next to the category.</strong> Being able to filter on why a record was flagged makes review and auditing much easier.</li>
</ul>
<h2>Running it on a real county export</h2>
<p>Load everything as text, rename your county's columns to the names used above, set <code>COUNTY_STATE</code>, and write the result out:</p>
<pre><code class="language-python">parcels = pd.read_csv("parcels.csv", dtype=str)
parcels = parcels.rename(columns={
    "SITUS_ADDR": "situs_address", "SITUS_ZIP": "situs_zip",
    "MAIL_ADDR1": "mail_address", "MAIL_ADDR2": "mail_address_2",
    "MAIL_STATE": "mail_state", "MAIL_ZIP": "mail_zip",
})
</code></pre>
<p>Then run the same <code>classify</code> loop and save with <code>parcels.to_csv("parcels_flagged.csv", index=False)</code>. If your file has no second mailing line, add an empty one first with <code>parcels["mail_address_2"] = None</code>. A row by row loop is fast enough for a county-sized file.</p>
<h2>Using the flag in your lead workflow</h2>
<p>The flag becomes useful once it feeds the rest of your acquisition process:</p>
<ul>
<li><strong>Stack it with distress lists.</strong> Match <code>absentee_out_of_state</code> rows against your tax-delinquent or probate lists on <code>parcel_id</code>. An out-of-state owner who is also behind on taxes or tied to a probate case is often a stronger lead than either list alone.</li>
<li><strong>Send only classified rows to skip tracing.</strong> Export the absentee categories for skip tracing and hold <code>needs_review</code> rows until someone has checked them, so you don't trace records you may end up discarding.</li>
<li><strong>Load the flags into your CRM as tags.</strong> Import <code>occupancy_flag</code> and <code>flag_reason</code> as tags or custom fields. You can then build mailer, texting and dialer campaigns by segment and still see later why each lead was tagged.</li>
</ul>
<pre><code class="language-python">tax_delinquent = pd.read_csv("tax_delinquent.csv", dtype=str)
stacked = parcels[
    (parcels["occupancy_flag"] == "absentee_out_of_state")
    &amp; parcels["parcel_id"].isin(tax_delinquent["parcel_id"])
]
stacked.to_csv("skip_trace_batch.csv", index=False)
</code></pre>
<p>Owners who hold several parcels appear once per parcel, and the same owner often shows up under slightly different name spellings across files. Deduplicate owners before or after flagging so one person doesn't get counted, or contacted, several times, and reuse these normalization helpers as match keys.</p>
<h2>A mismatch is a signal, not proof</h2>
<p>This caveat matters more than any of the code. <strong>A situs and mailing address mismatch is a signal, not proof of absentee status.</strong> Common innocent explanations include:</p>
<ul>
<li>The owner recently moved in and the county record hasn't been updated yet.</li>
<li>Tax bills go to a mortgage servicer, accountant, attorney or family member.</li>
<li>The property is held in a trust or LLC whose mailing address is a registered agent.</li>
<li>The owner lives on the parcel but uses a different mailing address for privacy or reliability.</li>
<li>The county file contains typos or stale data.</li>
</ul>
<p>The reverse also happens: a matching address doesn't guarantee the owner lives there. Before any outreach, verify the status with current sources and review the <code>needs_review</code> records by hand. Then follow TCPA, federal and state Do Not Call rules, and any state texting or calling rules that apply to your outreach, and check with your counsel before you call, text or mail anyone on this list. Treat the flag as a way to prioritize research, not as a final answer.</p>
<h2>Wrapping up</h2>
<p>A dependable absentee owner list comes down to careful address normalization: uppercase the text, strip punctuation, abbreviate suffixes and directionals by position, separate unit lines, and cut ZIPs to five digits after repairing lost leading zeros. After that, clear categories with a stated reason for each keep the output readable, easy to audit and ready for your skip tracing and CRM workflow.</p>
<p>Published by Locate Next (locatenext.com).</p>
]]></content:encoded></item><item><title><![CDATA[How to Deduplicate Property Owner Records Across Multiple Lead Lists with Python and pandas]]></title><description><![CDATA[Normalize addresses and owner names, merge exact matches, keep a record ID crosswalk, and send fuzzy matches to a person before you skip trace or mail]]></description><link>https://locatenext.hashnode.dev/deduplicating-property-owner-records-across-lead-lists</link><guid isPermaLink="true">https://locatenext.hashnode.dev/deduplicating-property-owner-records-across-lead-lists</guid><category><![CDATA[Python]]></category><category><![CDATA[pandas]]></category><category><![CDATA[data cleaning ]]></category><category><![CDATA[Real Estate]]></category><dc:creator><![CDATA[Locate Next]]></dc:creator><pubDate>Thu, 22 Oct 2026 20:02:43 GMT</pubDate><content:encoded><![CDATA[<p><em>All names, addresses and IDs in the examples are fictional.</em></p>
<h2>Why the same owner shows up on more than one lead list</h2>
<p>Real estate teams often pull several lead lists for the same area: an absentee owner list, a tax-delinquent list, a code-violation list. The same property shows up on more than one, written differently each time:</p>
<ul>
<li><code>123 North Main Street, 02134</code> on one list</li>
<li><code>123 N MAIN ST, 2134</code> on another (the ZIP lost its leading zero in a spreadsheet)</li>
</ul>
<p>If you combine the lists without deduplicating, you pay to skip trace the same owner twice, dial or text them from two campaigns, and send duplicate mailers that make your team look careless. If you deduplicate carelessly, you merge two different properties, or you lose track of which source row a result belongs to.</p>
<p>This walkthrough builds a dedupe pipeline in pandas that:</p>
<ol>
<li>Normalizes addresses and owner names into match keys</li>
<li>Merges exact matches automatically</li>
<li>Keeps every original record ID in a crosswalk</li>
<li>Sends near matches and incomplete rows to a person for review instead of merging them</li>
</ol>
<h2>Sample data</h2>
<p>Save these as two CSV files to follow along.</p>
<p><code>absentee.csv</code></p>
<pre><code>Record ID,Owner Name,Property Address,Property Zip
A-001,John Q. Smith,123 North Main Street,02134
A-002,"Rivera Holdings, L.L.C.",45 Oak Ave Apt 2,07030
A-003,Mary Jones,9 Elm St,60614
</code></pre>
<p><code>tax_delinquent.csv</code></p>
<pre><code>ID,Owner,Situs Address,Zip
T-1001,SMITH JOHN Q,123 N MAIN ST,2134
T-1002,Rivera Holdings LLC,45 Oak Avenue #2,07030-1234
T-1003,Mary Jones,9 Elm Street,60614
T-1004,Rivera Holdngs LLC,45 Oak Ave Unit 2,07030
T-1005,Kevin Lee,124 N Main St,02134
T-1006,Mary Jones,9 Ellm St,60614
</code></pre>
<p>The second list uses different column names, writes names last-first, has a ZIP+4, a typo in an owner name, a typo in a street name, and a neighbor at 124 who must not be merged with 123.</p>
<h2>Step 1: Load every list as text and tag its source</h2>
<pre><code class="language-python">import re
from difflib import SequenceMatcher
from itertools import combinations

import pandas as pd

# Map each list's own column names to one shared set.
SOURCES = {
    "absentee": (
        "absentee.csv",
        {"Record ID": "record_id", "Owner Name": "owner_name",
         "Property Address": "address", "Property Zip": "zip"},
    ),
    "tax_delinquent": (
        "tax_delinquent.csv",
        {"ID": "record_id", "Owner": "owner_name",
         "Situs Address": "address", "Zip": "zip"},
    ),
}

frames = []
for list_name, (path, columns) in SOURCES.items():
    df = pd.read_csv(path, dtype=str, keep_default_na=False)
    df = df.rename(columns=columns)
    df["source_list"] = list_name
    df["source_row"] = range(1, len(df) + 1)   # data row number, header not counted
    frames.append(df)

rows = pd.concat(frames, ignore_index=True)

# Record IDs from different lists can collide, so prefix them with the list name.
rows["source_key"] = rows["source_list"] + ":" + rows["record_id"]
</code></pre>
<p>Three details matter here:</p>
<ul>
<li><code>dtype=str</code> stops pandas from turning <code>02134</code> into <code>2134</code> or a numeric ID like <code>000123</code> into <code>123</code>.</li>
<li><code>keep_default_na=False</code> keeps blank cells as empty strings instead of NaN, so the string functions below never receive a float.</li>
<li>The order of <code>SOURCES</code> is the order of trust. When a merged record needs one owner name, it takes the first non-blank value, starting from the first list.</li>
</ul>
<h2>Step 2: Normalize addresses</h2>
<p>The goal is a match key, not a mailing address. Keep the original <code>address</code> column for mailing and output, and build a separate key that is the same for every way of writing the same property.</p>
<pre><code class="language-python">SUFFIXES = {
    "STREET": "ST", "AVENUE": "AVE", "AV": "AVE", "ROAD": "RD",
    "DRIVE": "DR", "LANE": "LN", "COURT": "CT", "BOULEVARD": "BLVD",
    "PLACE": "PL", "TERRACE": "TER", "CIRCLE": "CIR",
    "PARKWAY": "PKWY", "HIGHWAY": "HWY",
}
DIRECTIONALS = {
    "NORTH": "N", "SOUTH": "S", "EAST": "E", "WEST": "W",
    "NORTHEAST": "NE", "NORTHWEST": "NW", "SOUTHEAST": "SE", "SOUTHWEST": "SW",
}
UNIT_WORDS = {"APARTMENT", "APT", "UNIT", "STE", "SUITE", "#"}


def clean_text(value):
    text = str(value).upper()
    text = text.replace(".", "")        # "L.L.C." -&gt; "LLC", "ST." -&gt; "ST"
    text = re.sub(r"[,;]", " ", text)
    text = text.replace("#", " # ")     # "#2" -&gt; "# 2"
    return " ".join(text.split())


def street_key(value):
    tokens = []
    for token in clean_text(value).split():
        if token in UNIT_WORDS:
            token = "UNIT"
            if tokens and tokens[-1] == "UNIT":
                continue                # "APT #2" -&gt; "UNIT 2", not "UNIT UNIT 2"
        else:
            token = SUFFIXES.get(token, token)
            token = DIRECTIONALS.get(token, token)
        tokens.append(token)
    return " ".join(tokens)


def zip5(value):
    digits = re.sub(r"\D", "", str(value))
    if len(digits) in (5, 9):
        return digits[:5]
    if len(digits) in (4, 8):           # leading zero lost in a spreadsheet
        return "0" + digits[:4]
    return ""                           # missing or malformed: review by hand
</code></pre>
<p>With these rules, <code>123 North Main Street</code> and <code>123 N MAIN ST</code> both become <code>123 N MAIN ST</code>, and <code>45 Oak Ave Apt 2</code> and <code>45 Oak Avenue #2</code> both become <code>45 OAK AVE UNIT 2</code>.</p>
<p>Keep the unit. <code>45 OAK AVE UNIT 2</code> and <code>45 OAK AVE UNIT 3</code> are different properties with possibly different owners, and dropping the unit is one of the easiest ways to merge them by mistake.</p>
<p>Two ZIP cases to know about. ZIPs in Puerto Rico and the U.S. Virgin Islands start with <code>00</code>, so a spreadsheet can strip two digits; <code>zip5</code> returns a blank for those, and they go to manual review. And if a list was ever saved with ZIPs as numbers, look for values like <code>2134.0</code> before you run this, because the function would read them as <code>21340</code>.</p>
<p>For production work, a full address parser (such as the <code>usaddress</code> library) or a CASS-certified standardization service will handle far more cases than this small dictionary. If both lists carry a parcel number (APN) from the same county, that is usually a better property key than any address, once you strip the dashes and spaces that counties format differently.</p>
<h2>Step 3: Normalize owner names</h2>
<pre><code class="language-python">ENTITY_WORDS = {
    "LIMITED LIABILITY COMPANY": "LLC",
    "INCORPORATED": "INC",
    "CORPORATION": "CORP",
    "COMPANY": "CO",
}


def owner_key(value):
    text = clean_text(value).replace("&amp;", " AND ")
    for long_form, short_form in ENTITY_WORDS.items():
        text = re.sub(rf"\b{long_form}\b", short_form, text)
    # Sorting the words makes "SMITH JOHN Q" and "JOHN Q SMITH" the same key.
    return " ".join(sorted(text.split()))
</code></pre>
<p>Sorting tokens handles last-first ordering, which county and tax records use a lot. It also means <code>JOHN SMITH</code> and <code>SMITH JOHN</code> can never be told apart, which is fine for a match key and another reason to keep the original column. Joint owners (<code>JOHN AND MARY SMITH</code> on one list, <code>SMITH JOHN</code> on another) will not match exactly; they show up in the review step if they share a property.</p>
<h2>Step 4: Build match keys and merge exact matches</h2>
<pre><code class="language-python">rows["street_key"] = rows["address"].map(street_key)
rows["zip5"] = rows["zip"].map(zip5)
rows["owner_key"] = rows["owner_name"].map(owner_key)
rows["property_key"] = rows["street_key"] + "|" + rows["zip5"]
rows["match_key"] = rows["property_key"] + "|" + rows["owner_key"]

# Rows without a usable ZIP or street never merge automatically.
rows["has_key"] = (rows["zip5"] != "") &amp; (rows["street_key"] != "")
rows.loc[~rows["has_key"], "match_key"] = "NOKEY|" + rows.loc[~rows["has_key"], "source_key"]
rows[~rows["has_key"]].to_csv("needs_manual_review.csv", index=False)

# One cluster per match key, numbered in order of first appearance.
rows["cluster_id"] = (
    rows.groupby("match_key", sort=False).ngroup().map(lambda n: f"C{n + 1:05d}")
)
</code></pre>
<p>The match key combines property and owner on purpose. The same property with a different owner name might be a typo, or it might be a sale between the two list dates. That decision belongs to a person, so those rows stay in separate clusters and go to review in Step 6.</p>
<p>Match on the property address, not the mailing address. An absentee owner with ten rentals has one mailing address and ten properties, and merging on mailing address would collapse them into one row.</p>
<h2>Step 5: Keep every record ID</h2>
<p>Never throw away the source IDs. You need them to update each original list, to suppress the right rows later, and to trace any result back to where it came from.</p>
<pre><code class="language-python"># One row per source record: which cluster it landed in.
crosswalk = rows[["source_list", "record_id", "source_row", "cluster_id"]]
crosswalk.to_csv("crosswalk.csv", index=False)


def first_filled(values):
    # First non-blank value, in source trust order.
    for value in values:
        if value.strip():
            return value
    return ""


def build_merged(rows, cluster_col):
    # One row per cluster, with all the source IDs that fed it.
    return (
        rows.groupby(cluster_col, sort=False)
        .agg(
            owner_name=("owner_name", first_filled),
            address=("address", first_filled),
            zip5=("zip5", first_filled),
            source_lists=("source_list", lambda s: ";".join(sorted(set(s)))),
            source_keys=("source_key", ";".join),
            row_count=("source_key", "size"),
        )
        .reset_index()
    )


merged = build_merged(rows, "cluster_id")
merged.to_csv("merged.csv", index=False)
</code></pre>
<p>With the sample data, <code>merged.csv</code> has six clusters. John Smith, the first Rivera Holdings row and the first Mary Jones row each merged across both lists. <code>T-1004</code> (the owner name typo), <code>T-1005</code> (the neighbor at 124) and <code>T-1006</code> (the street typo) are still separate.</p>
<h2>Step 6: Review fuzzy matches instead of merging them</h2>
<p>Exact keys catch formatting differences. Typos need fuzzy matching, and fuzzy matching makes confident mistakes, so this step only proposes pairs.</p>
<pre><code class="language-python">def similarity(a, b):
    return SequenceMatcher(None, a, b).ratio()


def house_number(street):
    first = street.split(" ", 1)[0]
    return first if first[:1].isdigit() else ""


def unit_number(street):
    tokens = street.split()
    return tokens[tokens.index("UNIT") + 1] if "UNIT" in tokens[:-1] else ""


clusters = rows[rows["has_key"]].drop_duplicates("cluster_id")[
    ["cluster_id", "zip5", "street_key", "owner_key", "owner_name", "address"]
].copy()
clusters["house"] = clusters["street_key"].map(house_number)
clusters["unit"] = clusters["street_key"].map(unit_number)

REVIEW_COLUMNS = [
    "cluster_a", "cluster_b", "address_a", "address_b", "owner_a", "owner_b",
    "street_score", "owner_score", "reason", "decision",
]
candidates = []
# Only compare clusters with the same ZIP and house number. 123 and 124 Main St
# look almost identical as text, so they must never be paired, and blocking this
# way also keeps the number of comparisons down.
for _, block in clusters.groupby(["zip5", "house"]):
    for a, b in combinations(block.itertuples(index=False), 2):
        if a.unit and b.unit and a.unit != b.unit:
            continue                    # Unit 2 and Unit 3 are different properties.
        street_score = similarity(a.street_key, b.street_key)
        owner_score = similarity(a.owner_key, b.owner_key)
        if a.street_key == b.street_key:
            reason = "same property, owner name differs"
        elif street_score &gt;= 0.85 and owner_score &gt;= 0.85:
            reason = "possible address typo, similar owner"
        else:
            continue
        candidates.append({
            "cluster_a": a.cluster_id, "cluster_b": b.cluster_id,
            "address_a": a.address, "address_b": b.address,
            "owner_a": a.owner_name, "owner_b": b.owner_name,
            "street_score": round(street_score, 2),
            "owner_score": round(owner_score, 2),
            "reason": reason,
            "decision": "",
        })

review = pd.DataFrame(candidates, columns=REVIEW_COLUMNS)
review.to_csv("review_pairs.csv", index=False)
</code></pre>
<p>With the sample data, the review file has two pairs:</p>
<table>
<thead>
<tr>
<th>cluster_a</th>
<th>cluster_b</th>
<th>owner_a</th>
<th>owner_b</th>
<th>reason</th>
</tr>
</thead>
<tbody><tr>
<td>C00002</td>
<td>C00004</td>
<td>Rivera Holdings, L.L.C.</td>
<td>Rivera Holdngs LLC</td>
<td>same property, owner name differs</td>
</tr>
<tr>
<td>C00003</td>
<td>C00006</td>
<td>Mary Jones</td>
<td>Mary Jones</td>
<td>possible address typo, similar owner</td>
</tr>
</tbody></table>
<p>Kevin Lee at 124 is never paired with John Smith at 123, because the house numbers differ. The trade-off is that a typo in the house number itself (123 vs 132) will not be caught here. If you want to catch those, run a second, separate review pass that blocks on ZIP plus <code>street_key</code> with the house number removed, and flag pairs whose owner_score is at least 0.95 and whose house numbers differ by one swapped or mistyped digit. Keep that pass review-only: a duplicate that a person spots later is a safer failure than two neighbors merged automatically.</p>
<h3>How to review</h3>
<p>Open <code>review_pairs.csv</code> and fill the <code>decision</code> column with one of:</p>
<ul>
<li><code>merge</code>: the same owner and property, written differently</li>
<li><code>keep_separate</code>: genuinely different</li>
<li><code>check_records</code>: you cannot tell from the lists, so look at the county assessor or recorder record before deciding</li>
</ul>
<p>Only <code>merge</code> changes anything in Step 7. Pairs marked <code>check_records</code> stay separate until you change the decision and run Step 7 again.</p>
<p>If you use a spreadsheet, import the file with every column as text and save it back as CSV, so nothing gets reformatted.</p>
<p>A different owner name on the same property is the case to slow down on. It can mean a typo, a sale, a transfer into an LLC, or a trust. The lists alone cannot tell you which.</p>
<p>The <code>0.85</code> thresholds are starting points. Run the script on a list you know well, look at what lands in the review file, and adjust.</p>
<p>Also work through <code>needs_manual_review.csv</code> from Step 4. Those rows had no usable ZIP or street, so they were never compared at all.</p>
<h2>Step 7: Apply the approved merges</h2>
<p>Cluster IDs are numbered in order of first appearance, so they are only stable for the exact same input files in the exact same order. Run this step on the same <code>rows</code> that produced <code>review_pairs.csv</code>, either in the same session or by re-running Steps 1 to 5 on unchanged files. If a list has changed since the review, rebuild the review file instead of reusing old decisions. The check at the top of the code below stops the run if the stored addresses no longer match the current clusters.</p>
<p>Approved pairs can overlap (A with C, and B with C), so treat them as a graph and group everything that is connected. A small union-find does that:</p>
<pre><code class="language-python">review = pd.read_csv("review_pairs.csv", dtype=str, keep_default_na=False)
approved = review[review["decision"].str.strip().str.lower() == "merge"]

# Guard against stale decisions: each cluster ID must still point at the same address.
first_address = rows.drop_duplicates("cluster_id").set_index("cluster_id")["address"]
stale = approved[
    (approved["cluster_a"].map(first_address) != approved["address_a"])
    | (approved["cluster_b"].map(first_address) != approved["address_b"])
]
if not stale.empty:
    raise ValueError("review_pairs.csv does not match the current clusters; rebuild it")

parent = {}


def find(cluster_id):
    parent.setdefault(cluster_id, cluster_id)
    while parent[cluster_id] != cluster_id:
        cluster_id = parent[cluster_id]
    return cluster_id


for a, b in zip(approved["cluster_a"], approved["cluster_b"]):
    root_a, root_b = find(a), find(b)
    if root_a != root_b:
        # Keep the earlier cluster ID as the surviving one.
        parent[max(root_a, root_b)] = min(root_a, root_b)

rows["final_cluster_id"] = rows["cluster_id"].map(find)
rows[["source_list", "record_id", "source_row", "cluster_id", "final_cluster_id"]].to_csv(
    "crosswalk.csv", index=False
)

merged = build_merged(rows, "final_cluster_id")
merged.to_csv("merged.csv", index=False)
</code></pre>
<p>If you approve both sample pairs, <code>merged.csv</code> drops from six clusters to four. The crosswalk now records both the automatic cluster and the reviewed one, so you can always see which merges were automatic and which a person approved.</p>
<h2>Using merged.csv and crosswalk.csv in your outreach workflow</h2>
<p><code>merged.csv</code> is the file you upload for skip tracing or import into your CRM. Each owner and property appears once, so you pay for one lookup per owner and each owner lands in one campaign.</p>
<p>Keep <code>crosswalk.csv</code> next to it. When an owner opts out or comes back as a DNC hit, find their cluster in the crosswalk and suppress every source row in it, whether that row came from your absentee, tax-delinquent or code-violation list. Store suppressions by source list and record ID, so the owner does not slip back into your dialer, texting or mail queue when you pull a fresh copy of any list.</p>
<h2>Checklist</h2>
<pre><code>[ ] Every list loaded with dtype=str and keep_default_na=False
[ ] Source list name and original record ID kept on every row
[ ] Original address and owner columns kept; keys built in new columns
[ ] Units kept in the address key, and different units never paired
[ ] Matching on property address, not mailing address
[ ] Rows without a usable ZIP or street excluded from automatic merges and reviewed by hand
[ ] Fuzzy pairs reviewed by a person; different house numbers never paired automatically
[ ] Approved merges applied to the same inputs the review file was built from
[ ] crosswalk.csv saved alongside merged.csv and used for suppression
</code></pre>
<h2>Before you contact anyone</h2>
<p>Deduplication is data hygiene. It does not tell you whether you may call, text or mail someone. If your outreach includes calls or texts, federal Do Not Call and TCPA rules and state rules may apply, and they change over time. Check the rules that apply to you with your counsel before you start. This post is not legal advice.</p>
<hr />
<p>Published by Locate Next (locatenext.com).</p>
]]></content:encoded></item></channel></rss>