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 = {"Source:https://dev.to/locatenext/parsing-property-owner-names-in-pandas-individuals-joint-owners-trusts-and-llcs-61n