If you have pulled a parcel list from a county assessor or recorder, you have seen the owner name field. It looks like a name column, but it holds far more: two spouses in one cell, a trust with a trustee tucked in, a business entity, a deceased owner's estate, plus Latin abbreviations that mean "and others."
Before you send a list out for skip tracing, you need to know who (or what) each record actually is. A skip trace on SMITH JOHN & MARY ETUX as one literal string won't work well. Sending MAIN STREET HOLDINGS LLC through a person search is a waste.
This guide builds a small pandas pipeline that:
- classifies each owner as an individual or an entity (business, trust, estate, government or nonprofit)
- strips co-owner and trustee markers and records them as flags
- splits joint owners into separate rows
- parses LAST FIRST MIDDLE names carefully, and flags ambiguity instead of guessing
- keeps the raw column untouched and adds a
parse_statuscolumn - tests the whole thing with plain
assertstatements
Every code block runs on its own with Python 3 and pandas (1.5 or newer, because of reset_index(names=...)).
Why the owner name field is so messy
Assessor systems were built to record ownership, not to feed a contact workflow. Here's what you'll commonly see:
-
LAST FIRST order.
SMITH JOHN Ameans John A. Smith. Some counties add a comma (SMITH, JOHN A), many don't. -
Joint owners with "&" or "AND".
SMITH JOHN & MARYusually means John Smith and Mary Smith.SMITH JOHN & JONES MARYmeans two people with different last names, but without punctuation you can't always tell where one name ends. - ETAL, ETUX, ETVIR. Abbreviations of Latin phrases: et al. ("and others"), et uxor ("and wife"), et vir ("and husband"). They mark co-owners who aren't named. They are not people you can trace.
-
TRUSTEE, TR, TRS, TTEE. A person holding title as trustee.
SMITH JOHN TRis an individual acting in a role, which is different fromSMITH FAMILY LIVING TRUST. - Trust names. "LIVING TRUST", "REVOCABLE TRUST", "FAMILY TR", "IRREVOCABLE".
- Business entities. "LLC", "L.L.C.", "INC", "CORP", "LP", "LTD", plus names that read like businesses even without a suffix ("HOLDINGS", "PROPERTIES", "INVESTMENTS").
- Estates. "ESTATE OF", "EST OF", "DECD". Who has authority over the property is a legal question that the name field can't answer.
-
Generational suffixes that may come after the last name (
SMITH JR JOHN) or at the very end (SMITH JOHN JR). - Truncation. Many systems cap the field length, so long joint names get cut off in the middle.
Conventions vary by county, and sometimes by the year a record was entered. The keyword lists below are a starting point, not a standard. Sample your own export before you trust any rule.
Start by sampling your own data
Before writing parsing rules, look at which tokens actually show up. Counting tokens, and especially last tokens, quickly shows which suffixes and markers your county uses. The cleanup regex keeps / so that slash markers like H/W survive as a single token.
import pandas as pd
# Replace with: df = pd.read_csv("assessor_export.csv", dtype=str)
df = pd.DataFrame({"owner_name": [
"SMITH JOHN A", "SMITH JOHN & MARY ETUX", "MAIN STREET HOLDINGS LLC",
"GARCIA MARIA TR", "JONES ROBERT ETAL", "SMITH FAMILY LIVING TRUST",
"ESTATE OF LEE DAVID", "BROWN JAMES H/W", None,
]})
clean = (df["owner_name"].fillna("").str.upper()
.str.replace(r"[^A-Z&/\s]", " ", regex=True))
print("Most common tokens:")
print(clean.str.split().explode().value_counts().head(30))
print("\nMost common last tokens:")
print(clean.str.split().str[-1].value_counts().head(30))
On a real file, read through the top few hundred tokens. You will probably find abbreviations specific to your county (TTEES, LIV TR, H/W, JTRS) that you'll want to add to the lists below. Slash markers like H/W need special handling, which is covered in the joint owner section.
Keep the raw column, normalize a copy
Never overwrite the source field. Every parsed row should trace back to the original string, so a reviewer can see exactly what the county recorded. Normalization should be light: uppercase, drop periods, standardize commas, collapse whitespace.
Classify entity vs individual with regex keyword lists
Classification comes first, because joint-owner splitting should only apply to individuals. JOHNSON & JOHNSON INC contains an ampersand but is one entity.
The patterns are checked in order, so the first match wins. Word boundaries (\b) matter here: \bTRUST\b must not match TRUSTEE, and STATE OF must not match inside ESTATE OF.
import re
import pandas as pd
# Checked in order: the first matching label wins.
ENTITY_PATTERNS = [
("government_or_nonprofit",
r"\b(?:CITY|COUNTY|STATE) OF\b|\bHOUSING AUTHORITY\b|\bSCHOOL DISTRICT\b"
r"|\bCHURCH\b|\bASSOCIATION\b|\bASSN\b|\bHOA\b"),
("business",
r"\bL ?L ?C\b|\bINC\b|\bCORP(?:ORATION)?\b|\bCOMPANY\b|\bCO\b|\bLTD\b"
r"|\bL ?L ?P\b|\bLP\b|\bPLLC\b|\bHOLDINGS?\b|\bPROPERTIES\b"
r"|\bINVESTMENTS?\b|\bPARTNERS(?:HIP)?\b|\bENTERPRISES?\b|\bBANK\b"),
("trust",
r"\bTRUST\b|\bTRST\b|\bLIVING TR\b|\bREV(?:OCABLE)? TR\b|\bFAMILY TR\b"
r"|\bIRREVOCABLE\b"),
("estate",
r"\bESTATE OF\b|\bEST OF\b|\bESTATE\b|\bDECD\b|\bDECEASED\b"),
]
def normalize_owner(raw):
if pd.isna(raw):
return ""
s = str(raw).upper().replace(".", "")
s = re.sub(r"\s*,\s*", ", ", s)
return re.sub(r"\s+", " ", s).strip(" ,")
def classify_owner(norm):
if not norm:
return "blank"
for label, pattern in ENTITY_PATTERNS:
if re.search(pattern, norm):
return label
return "individual"
df = pd.DataFrame({"owner_name": [
"SMITH JOHN A", "Main Street Holdings, L.L.C.", "SMITH FAMILY LIVING TRUST",
"ESTATE OF JONES MARY", "JOHNSON & JOHNSON INC", "GARCIA MARIA TR", None,
]})
df["owner_norm"] = df["owner_name"].map(normalize_owner)
df["owner_type"] = df["owner_norm"].map(classify_owner)
assert df["owner_type"].tolist() == [
"individual", "business", "trust", "estate",
"business", "individual", "blank",
]
# Known false positives: surnames and business words that match a keyword
assert classify_owner("CHURCH JOHN A") == "government_or_nonprofit"
assert classify_owner("ACME REAL ESTATE") == "estate"
print(df)
A few judgment calls are worth stating plainly:
-
\bCO\bcatches "ACME CO" but could also match a "CO" that stands for "co-trustee" in some counties. Check your sample. -
\bCHURCH\blabelsCHURCH JOHN Aasgovernment_or_nonprofit, even though Church is a common surname. If your county writes LAST FIRST, one option is to skip this keyword when it's the first token and the rest of the string looks like a person's name, or to review everyCHURCHmatch by hand. - The bare
\bESTATE\blabelsACME REAL ESTATEasestate, because no business keyword matched first. (ACME REAL ESTATE LLCwould be classified correctly as a business.) If this comes up in your data, drop the bareESTATEand rely onESTATE OF,EST OFandDECD, or addREAL ESTATEto the business pattern. -
GARCIA MARIA TRis classified as an individual on purpose. The person is the trustee, and the trustee is usually who you'd research. The trustee marker is handled in the next step. - If a trust or LLC is the owner, the name field alone won't tell you who controls it. For LLCs, the usual next step is your state's official business entity search (typically run by the Secretary of State). An LLC filing log helps you record what each filing actually says without adding assumptions.
Strip ETAL, ETUX and TRUSTEE markers, then split joint owners
For individuals, co-owner markers and trustee markers turn into boolean flags and get removed from the name text. Then the remaining string is split on &, the standalone word AND, or /.
import re
CO_OWNER_PATTERN = r"\b(?:ETAL|ET AL|ETUX|ET UX|ETVIR|ET VIR)\b"
TRUSTEE_PATTERN = r"\b(?:TRUSTEES?|TTEES?|TRS|TR)\b"
JOINT_SPLIT = r"\s*(?:&|\bAND\b|/)\s*"
def strip_markers(norm):
has_co_owner = bool(re.search(CO_OWNER_PATTERN, norm))
is_trustee = bool(re.search(TRUSTEE_PATTERN, norm))
s = re.sub(CO_OWNER_PATTERN, " ", norm)
s = re.sub(TRUSTEE_PATTERN, " ", s)
s = re.sub(r"\s+", " ", s).strip(" ,&/")
return s, has_co_owner, is_trustee
def split_joint(s):
return [p.strip(" ,") for p in re.split(JOINT_SPLIT, s) if p.strip(" ,")]
assert strip_markers("SMITH JOHN & MARY ETUX") == ("SMITH JOHN & MARY", True, False)
assert strip_markers("GARCIA MARIA TR") == ("GARCIA MARIA", False, True)
assert split_joint("SMITH JOHN & MARY") == ["SMITH JOHN", "MARY"]
assert split_joint("LEE DAVID AND KIM SUSAN") == ["LEE DAVID", "KIM SUSAN"]
assert split_joint("ANDERSON PAUL") == ["ANDERSON PAUL"] # \bAND\b, not ANDERSON
# Pitfall: slash markers left in the string create a fake owner named "W"
assert split_joint("SMITH JOHN H/W") == ["SMITH JOHN H", "W"]
print("Marker and split checks passed")
has_co_owner matters downstream: it tells your team that the named people may not be the only owners on title. Don't drop that information.
Slash markers must be removed before splitting. Because JOINT_SPLIT splits on /, any marker containing a slash, such as H/W (husband and wife), has to be stripped before split_joint runs. Otherwise SMITH JOHN H/W becomes two "owners," SMITH JOHN H and W, as the last assert shows. You can add H/W to CO_OWNER_PATTERN, but be aware that this sets has_co_owner to True, which may overstate things: H/W usually describes how the named owners hold title rather than pointing to someone unnamed. A cleaner option is a separate pattern that removes such markers (and records them in their own column if you want to keep them) without touching has_co_owner. Check how your county uses each slash marker before you choose.
Parse LAST FIRST MIDDLE carefully, and flag what you can't resolve
This is where most name parsers go wrong. They pick an interpretation and move on. The rules here are deliberately conservative:
- Comma present: everything before the comma is the last name. This is the most reliable form.
- Two tokens: LAST FIRST.
- Three tokens: LAST FIRST MIDDLE, assuming your county uses that order (confirm it in your sample).
-
A surname particle (DE, LA, VAN, VON, ST, and so on) in the first two tokens: flag as
ambiguous_particle.DE LA CRUZ MARIAhas a three-word last name, and a simple token split would get it wrong. -
Four or more tokens without a comma: flag as
ambiguous_long. Compound surnames, two middle names, and truncated joint names all look the same. -
One token: keep it as a last name and flag
single_token.
Generational suffixes (JR, SR, II, III, IV) are pulled out wherever they appear after the first token. V is left off on purpose because it collides with a middle initial V.
For the second and later names in a joint owner string:
- One token (
MARY) means a first name that shares the primary owner's last name. - Two tokens where the second is a single letter (
MARY K) means first name plus middle initial, shared last name. - A first token that repeats the primary last name (
SMITH MARY) gets parsed as a full name. - Anything else, such as
JONES MARY, is flaggedambiguous_joint. It could be Mary Jones, or it could be someone named Jones Mary Smith. Leave the name fields empty and keep the text inname_partfor a human to review.
SUFFIXES = {"JR", "SR", "II", "III", "IV"}
PARTICLES = {"DE", "DEL", "DELA", "DA", "DI", "DOS", "DU", "LA", "LE",
"VAN", "VON", "ST"}
EMPTY_NAME = {"last_name": None, "first_name": None,
"middle_name": None, "suffix": None}
def pop_suffix(tokens):
"""Remove a generational suffix found after the first token."""
keep, suffix = [tokens[0]], None
for t in tokens[1:]:
if t in SUFFIXES and suffix is None:
suffix = t
else:
keep.append(t)
return keep, suffix
def parse_last_first(name):
"""Parse one name assumed to be in LAST FIRST MIDDLE order."""
if "," in name:
last, _, rest = name.partition(",")
tokens = rest.split()
if not last.strip() or not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
suffix = next((t for t in tokens if t in SUFFIXES), None)
tokens = [t for t in tokens if t != suffix]
if not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
return {"last_name": last.strip(), "first_name": tokens[0],
"middle_name": " ".join(tokens[1:]) or None,
"suffix": suffix, "parse_status": "ok_comma"}
tokens = name.split()
if not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
tokens, suffix = pop_suffix(tokens)
if len(tokens) == 1:
return {**EMPTY_NAME, "last_name": tokens[0], "suffix": suffix,
"parse_status": "single_token"}
if any(t in PARTICLES for t in tokens[:2]):
return {**EMPTY_NAME, "parse_status": "ambiguous_particle"}
if len(tokens) > 3:
return {**EMPTY_NAME, "parse_status": "ambiguous_long"}
return {"last_name": tokens[0], "first_name": tokens[1],
"middle_name": tokens[2] if len(tokens) == 3 else None,
"suffix": suffix, "parse_status": "ok"}
assert parse_last_first("SMITH JOHN A")["middle_name"] == "A"
assert parse_last_first("SMITH JR JOHN")["suffix"] == "JR"
assert parse_last_first("SMITH, JOHN R JR")["parse_status"] == "ok_comma"
assert parse_last_first("DE LA CRUZ MARIA")["parse_status"] == "ambiguous_particle"
assert parse_last_first("GARCIA LOPEZ MARIA ELENA")["parse_status"] == "ambiguous_long"
# Known over-flag: LE is a common Vietnamese surname, not a particle here
assert parse_last_first("LE THANH VAN")["parse_status"] == "ambiguous_particle"
print("Name parsing checks passed")
The particle rule over-flags some common surnames. LE, DA and DU are in PARTICLES because they appear in French, Portuguese and Italian compound surnames, but they are also common Vietnamese surnames on their own. A plain three-part name like LE THANH VAN comes back ambiguous_particle, as the last assert shows, even though it most likely parses fine as LAST FIRST MIDDLE. If your county has many such names, you have a few options: remove those tokens from PARTICLES and accept that the occasional compound surname will be split wrong, keep them in a separate set that only flags when another particle follows (as in DE LA), or leave the rule as is and expect more rows in review. Look at your own sample before deciding which mistake is cheaper.
Flagged rows aren't failures. They're the rows where a confident guess would most likely send the wrong name to a skip trace vendor. A quick manual pass over them is usually cheaper than chasing bad results later.
The complete pipeline, with assert tests
This block combines everything. parse_owner returns a list of dicts, one per person or entity. parse_owner_column explodes those lists into rows while keeping every original column, including the untouched owner_name, plus a source_row that points back to the input row. The assert suite at the bottom is the real point: every time your data surprises you, add a case.
One caution about column names: the parsed fields (last_name, first_name, role, parse_status and the rest) are added next to your original columns. If your input already has a column with one of those names, a plain concat would quietly produce duplicate column names, so parse_owner_column checks for that and raises an error. Rename the existing columns first, or add a prefix to the parsed fields.
import re
import pandas as pd
ENTITY_PATTERNS = [
("government_or_nonprofit",
r"\b(?:CITY|COUNTY|STATE) OF\b|\bHOUSING AUTHORITY\b|\bSCHOOL DISTRICT\b"
r"|\bCHURCH\b|\bASSOCIATION\b|\bASSN\b|\bHOA\b"),
("business",
r"\bL ?L ?C\b|\bINC\b|\bCORP(?:ORATION)?\b|\bCOMPANY\b|\bCO\b|\bLTD\b"
r"|\bL ?L ?P\b|\bLP\b|\bPLLC\b|\bHOLDINGS?\b|\bPROPERTIES\b"
r"|\bINVESTMENTS?\b|\bPARTNERS(?:HIP)?\b|\bENTERPRISES?\b|\bBANK\b"),
("trust",
r"\bTRUST\b|\bTRST\b|\bLIVING TR\b|\bREV(?:OCABLE)? TR\b|\bFAMILY TR\b"
r"|\bIRREVOCABLE\b"),
("estate",
r"\bESTATE OF\b|\bEST OF\b|\bESTATE\b|\bDECD\b|\bDECEASED\b"),
]
CO_OWNER_PATTERN = r"\b(?:ETAL|ET AL|ETUX|ET UX|ETVIR|ET VIR)\b"
TRUSTEE_PATTERN = r"\b(?:TRUSTEES?|TTEES?|TRS|TR)\b"
JOINT_SPLIT = r"\s*(?:&|\bAND\b|/)\s*"
SUFFIXES = {"JR", "SR", "II", "III", "IV"}
PARTICLES = {"DE", "DEL", "DELA", "DA", "DI", "DOS", "DU", "LA", "LE",
"VAN", "VON", "ST"}
EMPTY_NAME = {"last_name": None, "first_name": None,
"middle_name": None, "suffix": None}
def normalize_owner(raw):
if pd.isna(raw):
return ""
s = str(raw).upper().replace(".", "")
s = re.sub(r"\s*,\s*", ", ", s)
return re.sub(r"\s+", " ", s).strip(" ,")
def classify_owner(norm):
if not norm:
return "blank"
for label, pattern in ENTITY_PATTERNS:
if re.search(pattern, norm):
return label
return "individual"
def strip_markers(norm):
has_co_owner = bool(re.search(CO_OWNER_PATTERN, norm))
is_trustee = bool(re.search(TRUSTEE_PATTERN, norm))
s = re.sub(CO_OWNER_PATTERN, " ", norm)
s = re.sub(TRUSTEE_PATTERN, " ", s)
s = re.sub(r"\s+", " ", s).strip(" ,&/")
return s, has_co_owner, is_trustee
def split_joint(s):
return [p.strip(" ,") for p in re.split(JOINT_SPLIT, s) if p.strip(" ,")]
def pop_suffix(tokens):
keep, suffix = [tokens[0]], None
for t in tokens[1:]:
if t in SUFFIXES and suffix is None:
suffix = t
else:
keep.append(t)
return keep, suffix
def parse_last_first(name):
if "," in name:
last, _, rest = name.partition(",")
tokens = rest.split()
if not last.strip() or not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
suffix = next((t for t in tokens if t in SUFFIXES), None)
tokens = [t for t in tokens if t != suffix]
if not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
return {"last_name": last.strip(), "first_name": tokens[0],
"middle_name": " ".join(tokens[1:]) or None,
"suffix": suffix, "parse_status": "ok_comma"}
tokens = name.split()
if not tokens:
return {**EMPTY_NAME, "parse_status": "needs_review"}
tokens, suffix = pop_suffix(tokens)
if len(tokens) == 1:
return {**EMPTY_NAME, "last_name": tokens[0], "suffix": suffix,
"parse_status": "single_token"}
if any(t in PARTICLES for t in tokens[:2]):
return {**EMPTY_NAME, "parse_status": "ambiguous_particle"}
if len(tokens) > 3:
return {**EMPTY_NAME, "parse_status": "ambiguous_long"}
return {"last_name": tokens[0], "first_name": tokens[1],
"middle_name": tokens[2] if len(tokens) == 3 else None,
"suffix": suffix, "parse_status": "ok"}
def parse_joint_part(part, primary_last):
tokens = part.split()
if "," in part or (primary_last and tokens[0] == primary_last):
return parse_last_first(part)
tokens, suffix = pop_suffix(tokens)
if primary_last and len(tokens) == 1:
return {"last_name": primary_last, "first_name": tokens[0],
"middle_name": None, "suffix": suffix,
"parse_status": "ok_shared_last"}
if primary_last and len(tokens) == 2 and len(tokens[1]) == 1:
return {"last_name": primary_last, "first_name": tokens[0],
"middle_name": tokens[1], "suffix": suffix,
"parse_status": "ok_shared_last"}
return {**EMPTY_NAME, "parse_status": "ambiguous_joint"}
def parse_owner(raw):
"""Return a list of dicts, one per person or entity in the field."""
norm = normalize_owner(raw)
owner_type = classify_owner(norm)
base = {"owner_norm": norm, "owner_type": owner_type}
if owner_type != "individual":
status = "blank" if owner_type == "blank" else "entity"
return [{**base, "owner_seq": 1, "name_part": norm, "role": None,
"has_co_owner": False, **EMPTY_NAME,
"parse_status": status}]
s, has_co_owner, is_trustee = strip_markers(norm)
role = "trustee" if is_trustee else "owner"
rows, primary_last = [], None
for seq, part in enumerate(split_joint(s), start=1):
if seq == 1:
parsed = parse_last_first(part)
primary_last = parsed["last_name"]
else:
parsed = parse_joint_part(part, primary_last)
rows.append({**base, "owner_seq": seq, "name_part": part,
"role": role, "has_co_owner": has_co_owner, **parsed})
if not rows:
rows.append({**base, "owner_seq": 1, "name_part": s, "role": role,
"has_co_owner": has_co_owner, **EMPTY_NAME,
"parse_status": "needs_review"})
return rows
def parse_owner_column(df, col="owner_name"):
"""One output row per parsed owner. The raw column is left untouched."""
out = (df.assign(_parsed=df[col].map(parse_owner))
.explode("_parsed")
.reset_index(names="source_row"))
fields = pd.DataFrame(out.pop("_parsed").tolist())
clash = set(fields.columns) & set(out.columns)
if clash:
raise ValueError(f"Input already has parsed column names: {sorted(clash)}")
return pd.concat([out, fields], axis=1)
# Assert tests: add a case every time your own data surprises you.
def first(raw):
return parse_owner(raw)[0]
# Classification
assert classify_owner(normalize_owner("Main Street Holdings, L.L.C.")) == "business"
assert classify_owner(normalize_owner("SMITH JOHN & MARY")) == "individual"
assert classify_owner(normalize_owner("SMITH FAMILY LIVING TRUST")) == "trust"
assert classify_owner(normalize_owner("ESTATE OF JOHN SMITH")) == "estate"
assert classify_owner(normalize_owner("COUNTY OF ORANGE")) == "government_or_nonprofit"
assert classify_owner(normalize_owner(None)) == "blank"
# TRUSTEE and TR are roles, not the TRUST keyword
assert classify_owner(normalize_owner("SMITH JOHN TRUSTEE")) == "individual"
assert first("SMITH JOHN TR")["role"] == "trustee"
assert first("SMITH JOHN TR")["first_name"] == "JOHN"
# Entities stay on one row, even with & in the name
rows = parse_owner("JOHNSON & JOHNSON INC")
assert len(rows) == 1 and rows[0]["parse_status"] == "entity"
# LAST FIRST MIDDLE
r = first("SMITH JOHN A")
assert (r["last_name"], r["first_name"], r["middle_name"]) == ("SMITH", "JOHN", "A")
assert r["parse_status"] == "ok"
# Comma form and suffixes in different positions
r = first("SMITH, JOHN R JR")
assert (r["last_name"], r["first_name"], r["middle_name"], r["suffix"]) == ("SMITH", "JOHN", "R", "JR")
r = first("SMITH JR JOHN")
assert (r["last_name"], r["first_name"], r["suffix"]) == ("SMITH", "JOHN", "JR")
# Joint owners sharing a last name
rows = parse_owner("SMITH JOHN & MARY")
assert [x["first_name"] for x in rows] == ["JOHN", "MARY"]
assert [x["last_name"] for x in rows] == ["SMITH", "SMITH"]
assert rows[1]["parse_status"] == "ok_shared_last"
rows = parse_owner("SMITH JOHN AND MARY K")
assert rows[1]["last_name"] == "SMITH" and rows[1]["middle_name"] == "K"
# Joint owners with the last name repeated
rows = parse_owner("SMITH JOHN & SMITH MARY")
assert rows[1]["parse_status"] == "ok" and rows[1]["first_name"] == "MARY"
# Two tokens after & could be FIRST MIDDLE or LAST FIRST: flag, do not guess
rows = parse_owner("SMITH JOHN & JONES MARY")
assert rows[1]["parse_status"] == "ambiguous_joint"
assert rows[1]["last_name"] is None and rows[1]["name_part"] == "JONES MARY"
# ETAL and ETUX are flags, not people
rows = parse_owner("SMITH JOHN ETUX")
assert len(rows) == 1 and rows[0]["has_co_owner"] is True
rows = parse_owner("SMITH JOHN & MARY ET AL")
assert len(rows) == 2 and all(x["has_co_owner"] for x in rows)
# Particles, long names and leftovers get flagged
assert first("DE LA CRUZ MARIA")["parse_status"] == "ambiguous_particle"
assert first("GARCIA LOPEZ MARIA ELENA")["parse_status"] == "ambiguous_long"
assert first("SMITH")["parse_status"] == "single_token"
assert first("ETAL")["parse_status"] == "needs_review"
# DataFrame round trip keeps the raw column and the parcel key
df = pd.DataFrame({"parcel_id": ["001", "002"],
"owner_name": ["SMITH JOHN & MARY", "ACME PROPERTIES LLC"]})
out = parse_owner_column(df)
assert len(out) == 3
assert out["owner_name"].tolist() == ["SMITH JOHN & MARY"] * 2 + ["ACME PROPERTIES LLC"]
assert out["parcel_id"].tolist() == ["001", "001", "002"]
# Existing columns with parsed field names are rejected, not duplicated
try:
parse_owner_column(df.assign(role="seller"))
raise AssertionError("expected a column collision error")
except ValueError:
pass
print("All tests passed")
print(out[["parcel_id", "owner_name", "owner_seq", "last_name",
"first_name", "parse_status"]])
A note on the role column: it's set for every person in the string. Some counties write SMITH JOHN TR & MARY where only some of the people are trustees. If that pattern shows up in your sample, treat the trustee flag as "at least one trustee is named" and review those rows.
Reviewing the output before skip tracing
Once the pipeline runs, look at the distribution of statuses before anything goes out the door. Every row should land in exactly one bucket, including rows where the owner field was empty:
import pandas as pd
# Stand-in for the output of parse_owner_column
out = pd.DataFrame({
"owner_type": ["individual", "individual", "business", "individual",
"trust", "blank"],
"parse_status": ["ok", "ok_shared_last", "entity", "ambiguous_joint",
"entity", "blank"],
"name_part": ["SMITH JOHN", "MARY", "ACME PROPERTIES LLC", "JONES MARY",
"SMITH FAMILY LIVING TRUST", ""],
})
print(pd.crosstab(out["owner_type"], out["parse_status"]))
ready = out[out["parse_status"].isin(["ok", "ok_comma", "ok_shared_last"])]
review = out[out["parse_status"].str.startswith(("ambiguous", "needs_review", "single"))]
entities = out[out["parse_status"] == "entity"]
blank = out[out["parse_status"] == "blank"]
assert len(ready) + len(review) + len(entities) + len(blank) == len(out)
print(f"\nready: {len(ready)}, review: {len(review)}, "
f"entities: {len(entities)}, blank: {len(blank)}")
The assert on the bucket totals is a cheap guard: if you add a new status later and forget to route it, the check fails instead of rows silently disappearing.
A practical split:
- Ready rows go to your person-level skip trace.
- Review rows get a quick human pass. Fixes go back into the assert suite as new test cases, not just into the spreadsheet.
- Entity rows follow a different research path: official state business filings for LLCs and corporations, and county or court records for trusts and estates. Procedures and what's publicly available vary by state, so check the official source for your jurisdiction. Don't treat a name match as proof of who has authority over a property.
- Blank rows have no owner name to work with. They need a different data source, such as a fresh pull from the assessor or recorder, or a look at the deed record for that parcel, before they can go anywhere.
What comes next in the pipeline
Owner parsing is one step in a longer prep workflow. After this:
- Deduplicate owners across lists. Once names sit in separate columns, matching the same person across several parcels gets much easier. See this guide on deduplicating property owner records across lead lists.
- Flag absentee owners by comparing the situs and mailing addresses: flagging absentee owners with pandas.
- Normalize returned phone numbers before a CRM import: normalizing US phone numbers to E.164 in Python and Excel.
- Keep compliance flags tied to the right number. When results come back, make sure each Do Not Call status stays attached to the specific phone it applies to: keeping DNC status attached to the right phone number. For the rules themselves, start with official sources such as the National Do Not Call Registry, and keep in mind that state rules and individual tool behavior can differ.
Wrapping up
The owner name field packs several facts into one string: who owns the property, what kind of owner it is, whether anyone is unnamed, and in what capacity someone holds title. A good parser separates those facts into columns without inventing anything. Keep the raw value, classify before you split, turn markers like ETAL and TR into flags, strip slash markers before splitting, and let parse_status say "I'm not sure" when the string really is ambiguous. Then sample your own county's data, watch for surnames that collide with your keyword lists, extend those lists, and grow the assert suite every time you find a new pattern.
For more guides on cleaning and preparing contact and property data, visit the resource hub.
Locate Next (https://www.locatenext.com) publishes these guides for teams working with property and contact data.













