Bulk Property Records

Joining Property Records to Your Own Data by APN and County FIPS

Repair county FIPS codes, normalise APN formats, check match rates county by county and fall back to address matching for the rest.

10 min read

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 haveExampleFix
A five-digit code that went through a spreadsheet6037Left-pad to five digits
Separate state and county codes6 and 37Pad to 2 and 3 digits, concatenate
Only county and state namesLos Angeles, CALook up in the Census county reference file
repair county_fipspython
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:

county names to FIPSpython
# 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 names

Whatever 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:

county names to FIPS (postgres)sql
-- 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 none

Either 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 storedNormalised
County website14-0078-0003-0191400780003019
Loan file14 0078 0003 0191400780003019
Older vendor file14.0078.0003.0191400780003019
Spreadsheet export1.40078E+12Lost: 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.

normalised APN keysql
-- 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:

collision checksql
-- 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:

normalise and joinpython
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:

match rate per countysql
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 shapessql
-- 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 seeLikely causeFix
Your side is consistently 1–2 characters shorterLeading zeros dropped in one segment, often by a spreadsheetLeft-pad your normalised key to the export's length for that county
Your side has extra trailing digitsA suffix for sub-parcels, condo units or tax-roll sequenceMatch on the leading characters, then check the candidates
Completely different shapesDifferent 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 unmatchedParcels split, merged or renumbered since your data was compiledAddress fallback; compare against an older export if you have one
pad a county's keyssql
-- 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:

address fallbackpython
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:

crosswalk tablesql
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:

  1. Derive FIPS on the new month’s export as in step 1, then join through the crosswalk on county_fips + APN.
  2. Rows that no longer match point to parcels that were split, merged or renumbered. Rematch only those, starting with the address fallback.
  3. Run the APN and address passes only for records you added since last month.
  4. 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:

A quick check in SQL: SELECT my_id, count(*) FROM joined GROUP BY 1 HAVING count(*) > 1 should return nothing.

Summary

StepGoalOutput
1. FIPSFive-digit text code on every row of both filescounty_fips
2. APNOne normalised form on both sides, originals keptapn_norm
3. Match ratePer-county rates; fix format issues where they clusterapn_fixed
4. AddressRecover the rest with strict, unique address matchesmatch_method = 'address'
5. CrosswalkStore matches; rematch only what changesparcel_crosswalk