A county property export already contains what a direct-mail list needs: who owns each property and where they receive mail. Turning it into a list a mail house will accept takes a few deliberate steps, mostly about choosing the right records and avoiding sending the same owner five letters.
This guide walks through the process with pandas, from a raw export to a clean hand-off file. The same logic works in SQL or in a spreadsheet for smaller counties. Column names are from a Bulk Property Records export; the field dictionary explains each one.
Decide who you are writing to
Everything else follows from this. The two most common audiences want almost opposite filters:
| Audience | Typical sender | Core filter | Mail to |
|---|---|---|---|
| Absentee owners | Investors, property managers, lenders to landlords | Owner Occupied = false | Owner mailing address |
| Owner-occupants | Home services, local businesses, agents farming a neighbourhood | Owner Occupied = true | Owner mailing address (usually the property) |
Pick one per mailing. Mixing them in one list makes the message vaguer and the response harder to read. If you want both, build two lists from the same export.
Step 1: Load the county and look before filtering
Load the export with identifier columns as text, restrict it to the county you mean (the example uses Fulton County, Georgia: State GA, County Fulton), and look at the distribution of the columns you are about to filter on:
import pandas as pd
TEXT = {"APN": "string", "Property ID": "string", "Zip": "string",
"Owner Mailing Zip": "string"}
df = pd.read_csv("county_export.csv", dtype=TEXT)
# One county only, in case the file covers several. Use the exact
# spelling the value_counts output shows.
print(df[["State", "County"]].value_counts())
df = df[(df["State"] == "GA") & (df["County"] == "Fulton")].copy()
print(len(df), "records")
print(df["Property Type"].value_counts(dropna=False))
print(df["Owner Occupied"].value_counts(dropna=False))The value_counts output tells you three things: the exact Property Type labels in your file, how the yes/no flags are written, and how many values are blank. Filtering on a label you assumed rather than one you saw is the most common way to end up with an empty or oversized list.
Step 2: Choose property types
Most residential campaigns keep single-family homes, condos and small multi-family, and drop vacant land, commercial and industrial parcels. Write the list of types you keep explicitly, from the labels you saw in step 1, so the next person can see the decision:
KEEP = ["<label 1 from value_counts>", "<label 2>", "<label 3>"]
df = df[df["Property Type"].isin(KEEP)]If you are targeting small landlords, the Units Count column separates duplexes, triplexes and fourplexes from single-family homes more precisely than a type label.
Step 3: Split absentee and owner-occupied records
The export has no separate absentee column. Owner Occupied is the one to use: records where it is false are an approximation of absentee owners, and it can be blank. Convert it and the other yes/no columns to real booleans, then split:
TRUE_VALUES = {"true", "t", "1", "y", "yes"}
FALSE_VALUES = {"false", "f", "0", "n", "no"}
def to_bool(col: pd.Series) -> pd.Series:
s = col.astype("string").str.strip().str.lower()
out = pd.Series(pd.NA, index=col.index, dtype="boolean")
out[s.isin(TRUE_VALUES)] = True
out[s.isin(FALSE_VALUES)] = False
return out # True, False, or <NA> when the source was blank
for c in ["Owner Occupied", "Corporate Owned", "Vacant?"]:
df[c] = to_bool(df[c])
occ = df["Owner Occupied"]
absentee = df[occ.eq(False).fillna(False)] # not owner-occupied: absentee, approximately
occupied = df[occ.eq(True).fillna(False)]
unclear = df[occ.isna()] # blank
print(len(absentee), len(occupied), len(unclear))The unclear group holds records where Owner Occupied is blank. Leave it out of the first mailing. If it is large, look at a sample: comparing Address with Owner Mailing Address by hand usually shows which side each record belongs on. The same comparison on a sample of the absentee group is a quick check of the approximation.
Optional filters
- Corporate owners. The
Corporate Ownedflag marks owners that appear to be businesses. Some campaigns exclude them because letters to an LLC’s registered address rarely reach a decision maker; others target them specifically. - Vacant properties. For owner-occupant lists, drop rows where
Vacant?is true. For absentee lists it does not matter, because you are mailing the owner elsewhere. - Equity or tenure.
Equity PercentandYears Ownednarrow a list to owners more likely to fit your offer. There is no high-equity flag; filter on the numbers with a cutoff you choose.
Step 4: Decide between one piece per owner and one per property
An owner with several properties in the county appears once per property. Whether you collapse them depends on the letter:
- One per property when the letter is about a specific address (“about your property at …”). The owner gets several letters, each relevant.
- One per owner when the letter is about the owner (“we buy rental portfolios”). Sending the same generic letter five times to one address wastes postage and looks careless.
Absentee lists usually benefit most from deduplication, because that is where multi-property owners are.
Step 5: Deduplicate owners
A good dedupe key is the owner’s name together with their mailing address. Neither alone is enough:
- The same name can belong to different people in a large county.
- The same mailing address can serve many different owners: a property manager, an accountant, or a registered agent’s office. Deduplicating on address alone would merge them into one letter.
The export splits the owner’s name into Owner 1 First Name and Owner 1 Last Name, so join the two into one name first. Normalise both parts before comparing: uppercase, punctuation removed, whitespace collapsed. Because Owner Mailing Zip was loaded as text, it keeps its leading zeros. Then group and count:
def norm(s: pd.Series) -> pd.Series:
return (s.fillna("").astype("string").str.upper()
.str.replace(r"[^A-Z0-9 ]", " ", regex=True)
.str.replace(r"\s+", " ", regex=True)
.str.strip())
def full_name(first: pd.Series, last: pd.Series) -> pd.Series:
return (first.fillna("") + " " + last.fillna("")).str.strip()
lst = absentee.copy()
lst["owner_name"] = full_name(lst["Owner 1 First Name"], lst["Owner 1 Last Name"])
lst["owner_2"] = full_name(lst["Owner 2 First Name"], lst["Owner 2 Last Name"])
lst["owner_key"] = norm(lst["owner_name"])
lst["mail_key"] = norm(lst["Owner Mailing Address"]) + "|" + norm(lst["Owner Mailing Zip"])
owners = (lst.groupby(["owner_key", "mail_key"], as_index=False, dropna=False)
.agg(owner_name=("owner_name", "first"),
owner_2=("owner_2", "first"),
mail_street=("Owner Mailing Address", "first"),
mail_city=("Owner Mailing City", "first"),
mail_state=("Owner Mailing State", "first"),
mail_zip=("Owner Mailing Zip", "first"),
properties=("Property ID", "count"),
example_address=("Address", "first")))
print(len(lst), "property records ->", len(owners), "owners")
print(owners.sort_values("properties", ascending=False).head(20))The properties column is worth keeping. It lets you write a different letter to owners of one property and owners of many, and the top of the sorted output is a quick check that the grouping worked. It counts properties on this list only; the export’s Owner Property Count column says how many properties the owner holds in total.
Step 6: Remove addresses that cannot be mailed
Some records have an incomplete mailing address: a blank street, a missing ZIP, or no state because the owner is outside the U.S. Separate them rather than deleting them, so you can review them later:
has_street = owners["mail_street"].fillna("").str.strip() != ""
has_zip = owners["mail_zip"].fillna("").str.fullmatch(r"\d{5}")
has_state = owners["mail_state"].fillna("").str.fullmatch(r"[A-Za-z]{2}")
mailable = owners[has_street & has_zip & has_state].copy()
held_out = owners[~(has_street & has_zip & has_state)]
held_out.to_csv("held_out_for_review.csv", index=False)
print(len(mailable), "mailable;", len(held_out), "held out")PO boxes are deliverable and should stay. Owners with a foreign mailing address are reachable, but international mail has different postage and formatting, so most domestic campaigns hold them out.
Step 7: Prepare names
Owner names come from county records and are written for record-keeping, not for letters. Before you merge them into a salutation:
- Look at a random sample of 50 joined names. You will see individuals, trusts, estates and businesses, often in uppercase. Names that are not a person may not split neatly into first and last name, so check how they read once joined.
- Decide on a rule per type. A common approach: use the name as-is in the address block, and a neutral salutation (“Dear Property Owner”) in the letter body when the name is not clearly an individual.
- Keep the second owner (
Owner 2 First NameandOwner 2 Last Name, joined intoowner_2in step 5) for the address block where space allows. Letters addressed to both owners of a jointly owned home read as more personal than to one. - Title-case with care. Automated title-casing gets names like McDonald, O’Brien and LLC wrong; review the output or leave names in uppercase in the address block, which is standard for mail.
Step 8: Standardise and update addresses (CASS and NCOA)
Two address-hygiene steps are standard practice for U.S. bulk mail. Mail houses and list-processing services run them; you usually just send the file.
- CASS (Coding Accuracy Support System) is the USPS certification for software that standardises addresses, corrects formatting, adds ZIP+4 codes and confirms that an address exists. CASS-processed addresses are required for USPS automation postage prices.
- NCOA (National Change of Address) matches names and addresses against change-of-address requests filed with USPS, so you can send mail to an owner’s new address instead of their old one. USPS Move Update rules require a recent move-update check for presorted and automation-price mail; your mail house can tell you the current requirements.
A third service, DPV (Delivery Point Validation), is commonly included with CASS processing and confirms that the specific unit or suite is deliverable. Ask what your provider includes, and ask for the output flags back: knowing which addresses failed validation is useful the next time you build a list from the same county.
Step 9: Apply suppressions
Before you send anything, remove people who should not receive this mailing:
- Owners who have asked you not to contact them. Keep this list permanently and apply it to every campaign.
- Existing customers or contacts already in a conversation with your team.
- Owners mailed recently for the same offer, if you are pacing a sequence.
Match suppressions on the same normalised owner and mailing-address keys from step 5, so formatting differences do not let a record slip through.
Step 10: Build the hand-off file
Mail houses want a predictable, minimal file. Keep the columns they need for addressing and a few you need for tracking responses (names as created in step 5):
| Column | Purpose |
|---|---|
| record_id | Your own ID per row, printed on the piece or encoded in a tracking URL, so responses can be matched back |
| owner_name, owner_2 | Address block |
| mail_street, mail_city, mail_state, mail_zip | Address block |
| properties | Number of properties for this owner, for letter versioning |
| example_address | The property address, for letters that reference a specific property |
| Property ID | Key back to the original record (one-per-property lists) |
Export it as CSV with mail_zip and Property ID as text, and keep a copy of the exact file you sent, stamped with the date and the month of the export it came from (the export’s Data Version column). When responses arrive, that file is how you connect them to the records and filters that produced them.
Test before the full drop, and keep the segments
Before you print the whole list, send a small batch drawn at random from it. A test mailing tells you whether the addresses are clean, how many pieces come back undeliverable, and whether the letter gets a response, at a fraction of the cost of the full run. Draw it at random rather than taking the first rows of the file, which are often sorted by ZIP or owner name and would not represent the list.
Keep the columns you used to build the list alongside each record_id: absentee or owner-occupied, number of properties, equity band, years owned. When responses come in, you can compare segments instead of judging the list as a whole. Over a few mailings, that tells you which filters are worth keeping for the next county and which ones only made the list smaller.
Returned mail is useful data too. Record which addresses came back and why, and add them to your suppression file until a later export shows a new mailing address for that owner.
Compliance
Direct mail to property owners for business purposes is a common and legitimate use of public-record data, but you are responsible for following the laws and rules that apply to your outreach and industry, and for honouring opt-out requests. Property data from Bulk Property Records may not be used for any purpose covered by the Fair Credit Reporting Act, such as tenant, employment, credit or insurance screening.
If you plan to mail the same county regularly, the next question is how to keep the list current. That is covered in how often property records change.