A property dataset starts going stale the day you download it. Owners sell, refinance, move and pay off loans; assessors revalue; counties record new filings every business day. How quickly that matters depends on what you use the data for and which columns you rely on.
This guide explains where changes come from, which kinds of fields change and which mostly do not, and how to keep your own copy current by comparing one monthly export with the next. The Bulk Property Records dataset is refreshed monthly, and every row of an export carries the month it came from in its Data Version column (for example 2026-10), so the month is the natural unit for everything below.
Where changes come from
Property records change for three different reasons, on three different schedules. Knowing which drives a column tells you how often it is worth checking.
Recorded events
Deeds, mortgages, releases and foreclosure notices are filed with the county recorder whenever they happen. A sale changes the owner, the mailing address, the sale date and price, and usually the mortgage columns. A refinance changes the liens. A recorded release closes a lien. These changes arrive throughout the year, one property at a time.
Assessment cycles
Assessors revalue property and set tax bills on their own schedules, which differ by state and county. Some reassess every year; others on longer cycles, with smaller adjustments in between. When a county publishes a new roll, assessed values and tax bills can change for most parcels at once. The export does not include assessed value or tax amount columns, so these batch changes do not appear in it directly; the tax column it does carry is Tax Delinquent Year.
Recalculated values
Some columns are derived from the others and recalculated when the dataset is rebuilt. Estimated value moves with market data. Equity, equity percentage and loan-to-value move when either the value or the estimated loan balance does. Derived columns such as Free And Clear and Motivation Score are recomputed from the columns they depend on. These can change from one month to the next even when nothing was recorded for the property.
Which fields change, and how often
The table groups the export columns by what drives them. It describes general behaviour, not measured rates; the only way to know how much changes in your area is to compare two exports, which is covered next.
| Columns | Driven by | How often to expect changes |
|---|---|---|
Estimated Value, Estimated Equity, Equity Percent, DTV | Recalculation | Can move every month for many properties, usually by small amounts |
Free And Clear, Motivation Score | Recalculation from other columns | Change when their inputs change |
Mortgage Balance, Open Lien Count, Lien 1 Lender, Lien 1 Rate | Recorded loans and releases | On refinance, new loans, payoffs and sales |
Owner 1 First Name, Owner 1 Last Name, Owner 2 First Name, Owner 2 Last Name, Last Sale Date, Last Sale Price, Years Owned, Ownership Length (Months) | Recorded deeds | Only when the property changes hands (the ownership-length columns also grow with time) |
Owner Mailing Address, Owner Mailing City, Owner Mailing State, Owner Mailing Zip, Owner Occupied | Owner updates to the tax roll, sales | When an owner moves or a property sells |
Preforeclosure, Foreclosure Status, Foreclosure Auction Date, Tax Delinquent Year | Recorded notices, tax status | When a filing is recorded or a situation is resolved |
Bedrooms, Bathrooms, Living Square Feet, Lot (Square Feet), Year Built, Units Count | Assessor property characteristics | Rarely: additions, conversions, corrections |
Property ID, APN | Parcel identity | Very rarely: splits, merges, county renumbering |
The practical reading: if your work depends on ownership, mailing addresses or loans, a monthly refresh catches changes that matter. If it depends on physical characteristics, the data changes slowly, and refreshing less often may be fine.
Choosing a refresh strategy
There are three common approaches, in increasing order of effort and value:
- Replace. Pull the area again each month and overwrite your copy. Simple and always current, but you lose history and cannot tell what changed.
- Diff. Pull the area again, compare it with last month’s copy, and act on the differences: new owners, new liens, flags switching on. This is where most of the value of a refresh is.
- Keep history. Store every monthly copy as a snapshot. You can answer questions about any past month and see each property’s timeline.
Whichever you choose, refresh the records you actually use. On a plan with a daily export allowance, re-pulling an area uses allowance, so it helps to know your refresh volume in advance. The guide on estimating property data costs shows how to size it.
How to diff two monthly exports
Step 1: Pick the key
To compare two exports you need a column that identifies the same property in both. Every row carries a Property ID, so that is the key. (An APN on its own is unique only within a county.) Load both files as text: a lost leading zero makes every record look new.
import pandas as pd
KEY = ["Property ID"]
# Read every column as text: IDs, APNs and ZIPs keep their leading zeros.
old = pd.read_csv("export_2026-09.csv", dtype=str)
new = pd.read_csv("export_2026-10.csv", dtype=str)
# The key must be unique in each file before you compare, and each file
# should hold a single monthly edition.
for name, df in [("old", old), ("new", new)]:
dupes = df.duplicated(KEY, keep=False).sum()
print(name, len(df), "rows,", dupes, "rows with a duplicate key,",
"Data Version:", sorted(df["Data Version"].dropna().unique()))If the duplicate count is not zero, look at those rows before going further. Comparing on a non-unique key produces spurious matches.
Step 2: Normalise before comparing
Many apparent changes are formatting: trailing spaces, different capitalisation, an empty string in one file and a missing value in the other. Normalise text columns the same way in both files so the diff reports real changes only. Leave Data Version out of the comparison, since it differs on every row by design, along with Years Owned and Ownership Length (Months), which can grow with time alone:
def normalise(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
for c in out.columns:
if pd.api.types.is_string_dtype(out[c]):
out[c] = (out[c].astype("string").str.strip().str.upper()
.str.replace(r"\s+", " ", regex=True)
.replace("", pd.NA))
return out
# Data Version differs on every row by design, and the ownership-length
# columns can grow with time alone, so leave them out of the comparison.
IGNORE = ["Data Version", "Years Owned", "Ownership Length (Months)"]
old_n = normalise(old).drop(columns=IGNORE).set_index(KEY)
new_n = normalise(new).drop(columns=IGNORE).set_index(KEY)Step 3: Find added, removed and changed records
added_keys = new_n.index.difference(old_n.index)
removed_keys = old_n.index.difference(new_n.index)
common = new_n.index.intersection(old_n.index)
a = old_n.loc[common].sort_index()
b = new_n.loc[common].sort_index()[a.columns]
# A cell changed if the values differ and they are not both blank.
changed = (a != b).fillna(True) & ~(a.isna() & b.isna())
# One row per changed cell: key, column, old value, new value.
cells = changed.stack()
cells = cells[cells]
log = pd.DataFrame({
"column": cells.index.get_level_values(-1),
"old": [a.at[k[:-1], k[-1]] for k in cells.index],
"new": [b.at[k[:-1], k[-1]] for k in cells.index],
}, index=cells.index.droplevel(-1))
print(len(added_keys), "added;", len(removed_keys), "removed;",
changed.any(axis=1).sum(), "changed")
print(log["column"].value_counts())The output gives you three lists and a change log:
- Added: parcels in the new export that were not in the old one. New construction, newly split parcels, or properties that now match your filters.
- Removed: parcels that disappeared. Merged parcels, renumbered APNs, or properties that no longer match your filters.
- Changed: the same parcel with at least one different value. The log has one row per changed cell, and its
value_countsshows which columns moved most in your area this month.
Step 4: Decide what counts as a change
Modelled values such as Estimated Value can move a little every month. If you only care about meaningful movements, compare them with a tolerance instead of exact equality:
# Run this after computing `changed` and before building the log.
# Ignore small movements in modelled values: only log Estimated Value
# changes of more than 5%, or a value appearing or disappearing.
# Pick your own threshold.
col = "Estimated Value"
v_old = pd.to_numeric(a[col], errors="coerce")
v_new = pd.to_numeric(b[col], errors="coerce")
changed[col] = (((v_new - v_old).abs() > 0.05 * v_old.abs())
| (v_old.isna() != v_new.isna()))Do the same for Estimated Equity and DTV if you track them. Leave identifiers, names, addresses, lien counts and flags on exact comparison: any change there is a real event.
The same diff in SQL
If you keep the data in a database, a full outer join does the whole comparison in one query. This version returns only the columns most people act on:
-- Works in DuckDB or PostgreSQL. snap_old and snap_new are the two monthly
-- exports loaded as tables with the export's own headers (quoted, because
-- they contain spaces) and every column as text.
SELECT
coalesce(n."Property ID", o."Property ID") AS property_id,
CASE WHEN o."Property ID" IS NULL THEN 'added'
WHEN n."Property ID" IS NULL THEN 'removed'
ELSE 'changed' END AS change,
o."Owner 1 Last Name" AS old_owner_last, n."Owner 1 Last Name" AS new_owner_last,
o."Owner Mailing Address" AS old_mail, n."Owner Mailing Address" AS new_mail,
o."Open Lien Count" AS old_liens, n."Open Lien Count" AS new_liens
FROM snap_new n
FULL OUTER JOIN snap_old o
ON o."Property ID" = n."Property ID"
WHERE o."Property ID" IS NULL
OR n."Property ID" IS NULL
OR o."Owner 1 First Name" IS DISTINCT FROM n."Owner 1 First Name"
OR o."Owner 1 Last Name" IS DISTINCT FROM n."Owner 1 Last Name"
OR o."Owner Mailing Address" IS DISTINCT FROM n."Owner Mailing Address"
OR o."Open Lien Count" IS DISTINCT FROM n."Open Lien Count"
OR o."Preforeclosure" IS DISTINCT FROM n."Preforeclosure"
OR o."Tax Delinquent Year" IS DISTINCT FROM n."Tax Delinquent Year";IS DISTINCT FROM treats two blanks as equal and a blank versus a value as different, which is exactly what a diff needs. A plain <> would miss changes to or from blank.
Parcel splits, merges and renumbering
Most properties keep the same APN and Property ID for as long as the parcel exists, but not all. When a lot is subdivided, the original parcel usually disappears and several new ones appear. When neighbouring lots are combined, several APNs become one. Occasionally a county renumbers a whole area. In a diff, each of these shows up as removed and added records rather than as a change.
Before deleting anything you have attached your own data to, such as notes, CRM history or campaign responses, try to match each removed record to an added one. The property Address, City and Zip are the best first match, followed by the same owner name (Owner 1 First Name, Owner 1 Last Name) and Owner Mailing Address. Keep the matches in a small crosswalk table with the old key, the new key and the month, so your history follows the property across the change.
Check the refresh before you trust it
A diff compares whatever is in the two files, including your own mistakes. A few quick checks catch most of them before they reach a CRM or a mailing:
- Row counts per county. Compare them between months. A large difference usually means a different filter or area was exported, not that the county changed.
- Blank rates per column. If a column that was mostly filled is suddenly mostly blank, check the load settings, especially column types.
- Identifier format. Spot-check that
ZipandOwner Mailing Zipstill keep their leading zeros and thatProperty IDandAPNvalues look the same as last month. - The export month. Confirm that the new file’s
Data Versionreally is later than the old one’s.
What to do with the changes
A diff is only useful if it triggers something. Common rules:
| Change | Usually means | Typical action |
|---|---|---|
Owner 1 First Name, Owner 1 Last Name | The property sold | Update your CRM; stop outreach to the previous owner |
Owner Mailing Address | The owner moved or changed their mailing address | Update the address before the next mailing |
Open Lien Count | A refinance, a new loan or a payoff | Review for offers that depend on debt or equity |
Preforeclosure | A filing was recorded, or the situation resolved | Add to or remove from time-sensitive lists |
Owner Occupied | The owner moved in or out | Move the record between absentee and owner-occupant lists |
| Removed record | Parcel split, merged or renumbered | Look for an added record at the same address before deleting |
Keeping history
Storing each month as a snapshot costs disk space, but it makes questions about the past trivial. A single table with the export month in the primary key is enough:
-- One row per property per monthly export. Cheap to keep, easy to query.
-- Short column names, each mapped from an export header.
CREATE TABLE property_snapshots (
data_version char(7) NOT NULL, -- "Data Version", e.g. '2026-10'
property_id text NOT NULL, -- "Property ID"
apn text, -- "APN"
state char(2), -- "State"
county text, -- "County"
owner1_first text, -- "Owner 1 First Name"
owner1_last text, -- "Owner 1 Last Name"
mail_street text, -- "Owner Mailing Address"
mail_zip text, -- "Owner Mailing Zip"
est_value bigint, -- "Estimated Value"
open_lien_count integer, -- "Open Lien Count"
-- ...remaining columns...
PRIMARY KEY (data_version, property_id)
);
-- Ownership history for one property
SELECT data_version, owner1_first, owner1_last, mail_street
FROM property_snapshots
WHERE property_id = '<property id>'
ORDER BY data_version;Record the month from the export’s Data Version column, not the date you loaded it. If you load late, or reload an old file, the snapshot still lines up with the data it contains.
A monthly routine
- When the new monthly data is available, export the same areas, with the same filters, as last month.
- Save the file with its
Data Versionin its name, for examplefulton_2026-10.csv. - Check the key is unique, then normalise and diff against last month.
- Review the counts of added, removed and changed records. A sudden jump in added and removed records usually means a county renumbered parcels.
- Apply your action rules: CRM updates, list moves, suppressions.
- Append the new month to your snapshot table, or replace your working copy.
If the files are too large to open, the guide on working with multi-million-row CSVs covers pandas, DuckDB and database options.