Most people who buy property records already have a list of their own: loans, policies, CRM contacts, a portfolio, a tax roll from a different source. The value comes from joining the two. In theory that is one line of SQL on the parcel number. In practice parcel numbers come in many formats, county codes lose their leading zeros, and a few percent of records need a different route altogether.
This guide covers the join from start to finish: building a clean county FIPS on both sides, normalising APNs so both sides agree, measuring the match rate county by county, and falling back to address matching for what is left. Examples use SQL and pandas and the column names from a Bulk Property Records export.
The key: county FIPS + APN
There is no national parcel number in the United States. Each county assessor issues its own Assessor’s Parcel Numbers, so an APN is unique within a county but can repeat in the next county over. The key that identifies a parcel across the country is the pair of the county’s five-digit FIPS code and the APN. Every join in this guide uses both.
Your own data probably has the APN somewhere. It may or may not have a FIPS code. The export has the APN in APN but no FIPS column: it names the county in County (for example “Fulton”) alongside State. Derive a FIPS code on both sides first, because without it an APN join across more than one county can produce false matches.
Step 1: Get a clean county FIPS on both sides
A county FIPS code is two digits of state plus three digits of county, always five characters, always stored as text. Your data will usually arrive in one of three shapes:
| What you have | Example | Fix |
|---|---|---|
| A five-digit code that went through a spreadsheet | 6037 | Left-pad to five digits |
| Separate state and county codes | 6 and 37 | Pad to 2 and 3 digits, concatenate |
| Only county and state names | Los Angeles, CA | Look up in the Census county reference file |
import pandas as pd
mine = pd.read_csv("my_parcels.csv", dtype=str) # everything as text
# Case 1: a five-digit code that lost its leading zero ("6037" -> "06037").
mine["county_fips"] = mine["county_fips"].str.strip().str.zfill(5)
# Case 2: separate state and county codes ("6" and "37").
mine["county_fips"] = (
mine["state_fips"].str.strip().str.zfill(2)
+ mine["county_code"].str.strip().str.zfill(3)
)
# Either way, check the result is exactly five digits.
bad = mine[~mine["county_fips"].str.fullmatch(r"\d{5}", na=False)]
print(len(bad), "rows with an invalid county_fips")Name lookups need more care. County names repeat across states (there are many Washington Counties), some states use parishes or boroughs instead of counties, and some independent cities are county-level areas of their own. Always look up by state and name together. Written as a function, the same lookup serves your file and the export:
# Case 3: only county and state names. Build a lookup from the Census
# Bureau's county reference file, national_county2020.txt from census.gov
# (pipe-delimited; columns STATE, STATEFP, COUNTYFP, COUNTYNAME, ...).
ref = pd.read_csv("national_county2020.txt", sep="|", dtype=str)
ref["county_fips"] = ref["STATEFP"] + ref["COUNTYFP"]
def county_key(state: pd.Series, county: pd.Series) -> pd.Series:
# "Fulton County" and "Fulton" both become "GA|FULTON".
name = (county.fillna("").str.upper()
.str.replace(r"\b(COUNTY|PARISH|BOROUGH|CENSUS AREA|CITY AND BOROUGH|MUNICIPALITY)\b", "", regex=True)
.str.replace(r"[^A-Z ]", "", regex=True).str.split().str.join(" "))
return state.fillna("").str.strip().str.upper() + "|" + name
ref["key"] = county_key(ref["STATE"], ref["COUNTYNAME"])
def add_fips(df: pd.DataFrame, state_col: str, county_col: str) -> pd.DataFrame:
out = df.assign(key=county_key(df[state_col], df[county_col]))
out = out.merge(ref[["key", "county_fips"]], on="key", how="left",
validate="many_to_one").drop(columns="key")
print(out["county_fips"].isna().sum(), "rows without a FIPS match; inspect them by name")
return out
mine = add_fips(mine, "state", "county") # your own column namesWhatever is left unmatched after a name lookup is usually a spelling difference (“St.” vs “Saint”, “De Kalb” vs “DeKalb”). There are rarely many; fix them by hand with a small override table rather than more rules.
On the export side, run the same function on State and County, as the pandas example in step 2 does. If the export lives in a database, load the Census file as a table and do the same join there:
-- The same lookup in Postgres. county_ref is national_county2020.txt loaded
-- as a table (state, statefp, countyfp, countyname); properties is the export
-- loaded with its own header names, so they are quoted.
CREATE OR REPLACE FUNCTION county_key(st text, name text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT upper(trim(coalesce(st, ''))) || '|' || trim(regexp_replace(regexp_replace(regexp_replace(
upper(coalesce(name, '')),
'\m(COUNTY|PARISH|BOROUGH|CENSUS AREA|CITY AND BOROUGH|MUNICIPALITY)\M', '', 'g'),
'[^A-Z ]', '', 'g'), '\s+', ' ', 'g'))
$$;
ALTER TABLE properties ADD COLUMN county_fips char(5);
UPDATE properties p
SET county_fips = r.statefp || r.countyfp
FROM county_ref r
WHERE county_key(r.state, r.countyname) = county_key(p."State", p."County");
SELECT "State", "County", count(*) FROM properties
WHERE county_fips IS NULL GROUP BY 1, 2 ORDER BY 3 DESC; -- expect few or noneEither way, keep the derived code as five-character text, and read APN as text too; the large-CSV guide shows the settings for spreadsheets, pandas and databases.
Step 2: Normalise APNs on both sides
The same parcel number can be written several ways. Counties format APNs with dashes, dots, spaces or none, and systems that copied the number along the way may have changed it again. The illustrative values below all describe one parcel:
| Source (illustrative) | APN as stored | Normalised |
|---|---|---|
| County website | 14-0078-0003-019 | 1400780003019 |
| Loan file | 14 0078 0003 019 | 1400780003019 |
| Older vendor file | 14.0078.0003.019 | 1400780003019 |
| Spreadsheet export | 1.40078E+12 | Lost: re-pull from the source |
The first three normalise to the same key and join cleanly. The last is the case to avoid entirely: once a spreadsheet has converted an APN to scientific notation and the file has been saved, the original digits are gone, and no amount of normalisation brings them back.
The safest general rule is the minimal one: upper case and remove everything that is not a letter or a digit. Do it in a separate column so the original survives for display and for checking against the county.
-- One normalised form for both sides: upper case, every character that is
-- not a letter or digit removed. Keep the original column untouched.
CREATE OR REPLACE FUNCTION apn_key(apn text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT nullif(upper(regexp_replace(coalesce(apn, ''), '[^A-Za-z0-9]', '', 'g')), '')
$$;
ALTER TABLE properties ADD COLUMN apn_norm text GENERATED ALWAYS AS (apn_key("APN")) STORED;
ALTER TABLE my_parcels ADD COLUMN apn_norm text GENERATED ALWAYS AS (apn_key(apn)) STORED;
CREATE INDEX ON properties (county_fips, apn_norm);
CREATE INDEX ON my_parcels (county_fips, apn_norm);Removing punctuation can, in principle, make two distinct APNs identical, for example where a county uses a separator to distinguish a sub-parcel. Check once per load:
-- Did normalising merge two different parcels? Expect no rows.
SELECT county_fips, apn_norm, array_agg("APN") AS originals
FROM properties
GROUP BY county_fips, apn_norm
HAVING count(DISTINCT "APN") > 1;If that returns rows, keep punctuation for the affected county and normalise only case and whitespace there. The same normalisation in pandas:
def apn_key(s: pd.Series) -> pd.Series:
return s.fillna("").str.upper().str.replace(r"[^A-Z0-9]", "", regex=True).replace("", pd.NA)
props = pd.read_csv("state.csv", dtype={"APN": str, "Property ID": str, "Zip": str,
"Owner Mailing Zip": str})
props = add_fips(props, "State", "County") # the export has names, not FIPS
props["apn_norm"] = apn_key(props["APN"])
mine["apn_norm"] = apn_key(mine["apn"])
joined = mine.merge(
props, on=["county_fips", "apn_norm"], how="left",
suffixes=("", "_prop"), indicator=True, validate="many_to_one",
)validate="many_to_one" makes pandas raise an error if the export side has duplicate keys, which would otherwise silently multiply your rows.
Step 3: Measure the match rate by county
An overall match rate hides problems. A join that matches almost everything in most counties and almost nothing in one county is a formatting issue in that county, not general data quality. Always break the rate down:
SELECT
m.county_fips,
count(*) AS my_rows,
count(p."APN") AS matched,
round(100.0 * count(p."APN") / count(*), 1) AS match_pct
FROM my_parcels m
LEFT JOIN properties p
ON p.county_fips = m.county_fips AND p.apn_norm = m.apn_norm
GROUP BY m.county_fips
ORDER BY match_pct ASC, my_rows DESC;Sort ascending and look at the worst counties with meaningful row counts. When a county matches poorly, compare the shape of its APNs on both sides. Replacing digits with 9 and letters with A reveals format differences at a glance:
-- Compare APN "shapes" for one county: digits become 9, letters become A.
-- Different dominant shapes on the two sides explain most low match rates.
WITH shapes AS (
SELECT 'export' AS side,
translate(upper("APN"), '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'9999999999AAAAAAAAAAAAAAAAAAAAAAAAAA') AS shape
FROM properties WHERE county_fips = '13121'
UNION ALL
SELECT 'mine', translate(upper(apn), '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'9999999999AAAAAAAAAAAAAAAAAAAAAAAAAA')
FROM my_parcels WHERE county_fips = '13121'
)
SELECT side, shape, count(*) AS n
FROM shapes
GROUP BY side, shape
ORDER BY side, n DESC;Common patterns and what they mean:
| What you see | Likely cause | Fix |
|---|---|---|
| Your side is consistently 1–2 characters shorter | Leading zeros dropped in one segment, often by a spreadsheet | Left-pad your normalised key to the export's length for that county |
| Your side has extra trailing digits | A suffix for sub-parcels, condo units or tax-roll sequence | Match on the leading characters, then check the candidates |
| Completely different shapes | Different numbering scheme (old vs new, or a tax account number instead of an APN) | Use address fallback, or get a crosswalk from the county |
| Same shapes, still unmatched | Parcels split, merged or renumbered since your data was compiled | Address fallback; compare against an older export if you have one |
-- If one side drops leading zeros in a section of the APN, pad that side's
-- normalised key to the export's length for the affected county only.
UPDATE my_parcels
SET apn_fixed = lpad(apn_norm, 10, '0')
WHERE county_fips = '13121' AND length(apn_norm) < 10;Apply county-specific fixes in a separate column (apn_fixed above) and join on it as a second pass, so you can always tell which rows matched only after a fix.
Step 4: Fall back to address matching
Some rows will not match on APN no matter what: your source used a different identifier, the parcel was renumbered, or the APN was missing to begin with. For these, match on the property address within the same county. Addresses are messier than APNs, so the rules are stricter:
- Normalise both sides the same way: upper case, punctuation removed, common suffixes abbreviated.
- Require a house number. Addresses without one are too ambiguous to match automatically.
- Match only within the same county, using the FIPS you repaired in step 1.
- Accept a match only when the address is unique in the export for that county. Condos, apartment complexes and multi-parcel sites can share a street address.
import re
SUFFIX = {"STREET": "ST", "AVENUE": "AVE", "ROAD": "RD", "DRIVE": "DR",
"LANE": "LN", "COURT": "CT", "BOULEVARD": "BLVD", "PLACE": "PL"}
DIRS = {"NORTH": "N", "SOUTH": "S", "EAST": "E", "WEST": "W"}
def addr_key(a: str) -> str | None:
if not isinstance(a, str):
return None
a = re.sub(r"[^A-Z0-9 ]", " ", a.upper())
a = re.sub(r"\b(APT|UNIT|STE|SUITE)\b.*$", "", a) # drop unit designators
words = [SUFFIX.get(w, DIRS.get(w, w)) for w in a.split()]
if not words or not words[0].isdigit():
return None # need a house number
return " ".join(words)
unmatched = joined[joined["_merge"] == "left_only"][mine.columns].copy()
unmatched["addr_key"] = unmatched["address"].map(addr_key) # your street line
props["addr_key"] = props["Address"].map(addr_key) # export: street line only
# Only accept an address match that is unique within the county.
candidates = props.dropna(subset=["addr_key"])
counts = candidates.groupby(["county_fips", "addr_key"]).size().rename("n")
unique = candidates.join(counts, on=["county_fips", "addr_key"])
unique = unique[unique["n"] == 1].drop(columns="n")
fallback = unmatched.merge(unique, on=["county_fips", "addr_key"], how="inner",
suffixes=("", "_prop"))
fallback["match_method"] = "address"
print(len(fallback), "of", len(unmatched), "unmatched rows recovered by address")Dropping unit designators makes building addresses match, which is useful for single-parcel buildings but wrong for condominiums where each unit is its own parcel. The uniqueness rule protects you: a condo building whose units share a street address produces several candidates, so none is accepted automatically.
For anything still unmatched after both passes, a USPS-certified (CASS) address standardisation on both sides usually recovers more, because it resolves abbreviations and misspellings that simple rules cannot. Whether it is worth the cost depends on how many rows remain and what they are worth to you.
Step 5: Keep a crosswalk
Once you have matched your records, store the result as a small crosswalk table from your own identifier to the export’s key, instead of re-deriving it each time:
CREATE TABLE parcel_crosswalk (
my_id text NOT NULL, -- your own record id
county_fips char(5) NOT NULL, -- derived from State + County
apn text NOT NULL, -- "APN" as delivered in the export
match_method text NOT NULL CHECK (match_method IN ('apn', 'apn_fixed', 'address', 'manual')),
matched_month date NOT NULL, -- export month the match was made against
PRIMARY KEY (my_id)
);Next month the work is incremental:
- Derive FIPS on the new month’s export as in step 1, then join through the crosswalk on
county_fips+APN. - Rows that no longer match point to parcels that were split, merged or renumbered. Rematch only those, starting with the address fallback.
- Run the APN and address passes only for records you added since last month.
- Compare the per-county match rates with last month. A sudden drop in one county is worth a look.
Renumbering is the main reason a crosswalk drifts. The article on how often property records change explains when it happens and how to spot it in a monthly diff.
Many-to-one and one-to-many
Before trusting the joined file, check the cardinality in both directions:
- Several of your rows to one parcel is often legitimate: two loans on one property, or several contacts for one owner. Make sure it is intended before summing values across the join.
- One of your rows to several parcels should not happen with an APN join if the export key is unique. With address matching it can, which is why the uniqueness rule above exists.
A quick check in SQL: SELECT my_id, count(*) FROM joined GROUP BY 1 HAVING count(*) > 1 should return nothing.
Summary
| Step | Goal | Output |
|---|---|---|
| 1. FIPS | Five-digit text code on every row of both files | county_fips |
| 2. APN | One normalised form on both sides, originals kept | apn_norm |
| 3. Match rate | Per-county rates; fix format issues where they cluster | apn_fixed |
| 4. Address | Recover the rest with strict, unique address matches | match_method = 'address' |
| 5. Crosswalk | Store matches; rematch only what changes | parcel_crosswalk |