Bulk Property Records

Filtering a Bulk Property File With DuckDB

Query a statewide property CSV in place by equity, property type, owner type and value with DuckDB: typed views, filters, Parquet and exports.

10 min read

Most questions people ask of a bulk property file are filters: absentee owners with more than half their home in equity, free-and-clear rentals held by individuals, homes in a value band in three counties. You do not need to load a database to answer them. DuckDB runs SQL directly against CSV files on your laptop, uses every core, and often answers a question about a statewide file in seconds.

This guide goes beyond opening the file. It sets up a typed view over every part of an export, then works through filters by equity, property type, owner type and value, and finishes with the Parquet conversion and export steps that make DuckDB practical for repeat work. The column headers are those of a Bulk Property Records export, such as Owner Mailing Zip and Equity Percent. For a first look at a large file in several tools, see how to open a 5-million-row property CSV.

Why DuckDB for this

installshell
# macOS
brew install duckdb

# Python (any OS): the same engine as a library
pip install duckdb

# Or download the single CLI binary from duckdb.org and run:
duckdb property.duckdb

Passing a file name such as property.duckdb keeps the views and macros you define between sessions. Without one, DuckDB runs in memory and forgets everything when you quit.

Step 1: A typed view over the export

DuckDB guesses column types from a sample of rows. That is convenient, but a guess based on a sample can turn a ZIP code into an integer or fail halfway through a large file when a later row does not fit. For property data it is safer to read everything as text and convert explicitly:

typed viewsql
-- Read every part of the export, all columns as text first, then convert
-- the ones you filter on. TRY_CAST returns NULL instead of failing.
CREATE OR REPLACE VIEW raw AS
SELECT * FROM read_csv('exports/*.csv', header = true, all_varchar = true);

-- The export's headers contain spaces, so quote them once here and give
-- each a short name. Every later query uses the short names.
CREATE OR REPLACE VIEW props AS
SELECT
  "Property ID" AS property_id, "APN" AS apn,
  "Address" AS address, "City" AS city, "State" AS state, "Zip" AS zip,
  "County" AS county, "Property Type" AS property_type, "Owner Type" AS owner_type,
  nullif(trim(concat_ws(' ', "Owner 1 First Name", "Owner 1 Last Name")), '') AS owner_name,
  "Owner 2 First Name" AS owner2_first, "Owner 2 Last Name" AS owner2_last,
  "Owner Mailing Address" AS mail_street, "Owner Mailing City" AS mail_city,
  "Owner Mailing State"   AS mail_state,  "Owner Mailing Zip"  AS mail_zip,
  TRY_CAST("Bedrooms"           AS INTEGER) AS beds,
  TRY_CAST("Living Square Feet" AS INTEGER) AS sqft,
  TRY_CAST("Year Built"         AS INTEGER) AS year_built,
  TRY_CAST("Units Count"        AS INTEGER) AS units,
  TRY_CAST("Years Owned"        AS DOUBLE)  AS years_owned,
  TRY_CAST("Estimated Value"    AS BIGINT)  AS est_value,
  TRY_CAST("Estimated Equity"   AS BIGINT)  AS equity,
  TRY_CAST("Equity Percent"     AS DOUBLE)  AS equity_pct,
  TRY_CAST("DTV"                AS DOUBLE)  AS dtv,
  TRY_CAST("Open Lien Count"    AS INTEGER) AS open_lien_count,
  TRY_CAST("Last Sale Date"     AS DATE)    AS last_sale_date,
  -- Flags are the text 'true' or 'false'; blank becomes false here.
  coalesce("Corporate Owned" = 'true', false) AS corporate,
  coalesce("Free And Clear"  = 'true', false) AS free_and_clear,
  coalesce("Owner Occupied"  = 'true', false) AS owner_occupied,
  coalesce("Vacant?"         = 'true', false) AS vacant,
  -- No absentee column in the file: approximate it as "not owner occupied".
  -- Blank stays NULL, so unknowns drop out of absentee filters.
  "Owner Occupied" = 'false'                  AS absentee
FROM raw;

The export’s headers contain spaces and punctuation (Owner Mailing Zip, Vacant?), so the view quotes each one once and gives it a short name; the rest of this guide uses those short names. Identifiers (APN, Zip, Owner Mailing Zip) stay text, so leading zeros survive. TRY_CAST turns blanks and odd values into NULL instead of aborting the query. A view costs nothing to define: DuckDB reads the files only when a query runs.

Step 2: Look before you filter

Turn on the timer, then run a few cheap queries to learn the file: how many rows, how complete the columns you care about are, and exactly how categorical values are spelled.

profilesql
.timer on

-- What is in the file? One pass, several answers.
SELECT count(*)                         AS records,
       count(DISTINCT state || '|' || county) AS counties,
       count(est_value)                 AS with_value,
       count(equity_pct)                AS with_equity_pct
FROM props;

-- The exact spellings you will filter on.
SELECT property_type, count(*) AS n FROM props GROUP BY 1 ORDER BY n DESC;

-- How the flags are written in this file (before conversion).
SELECT "Owner Occupied", "Corporate Owned", count(*) FROM raw GROUP BY ALL ORDER BY 3 DESC;

-- The source's own owner classification, if you want it instead of a rule.
SELECT owner_type, count(*) AS n FROM props GROUP BY 1 ORDER BY n DESC;

The Property Type query matters most. Filtering on a spelling that is not in the file returns zero rows without any error, so copy the values you need from this output rather than typing them from memory. The examples below use illustrative values such as 'Single Family'; replace them with what your file contains.

Step 3: Filter by equity

Equity Percent (equity_pct in the view) is on a 0–100 scale, so 50 means half the estimated value is equity. The file has no absentee column, so the view approximates it as Owner Occupied = false. Combining the two with ownership length gives the classic long-held, high-equity rental list:

equity filtersql
-- High-equity, long-held single-family homes with absentee owners
-- (absentee = not owner occupied; see the typed view).
SELECT address, owner_name, mail_street, mail_city, mail_state, mail_zip,
       est_value, equity_pct, years_owned
FROM props
WHERE property_type = 'Single Family'       -- use the spelling from your file
  AND absentee
  AND equity_pct >= 50
  AND years_owned >= 10
ORDER BY equity_pct DESC;

Equity is derived from two estimates, value and open lien balance, so treat the threshold as a ranking tool rather than a precise cutoff. The field dictionary explains how Estimated Equity, Equity Percent and DTV relate. The file has no ready-made high-equity flag, so the threshold is yours to choose.

Step 4: Filter and summarise by value

Value filters are straightforward with BETWEEN. Summaries are where DuckDB earns its place: banding and percentiles over a whole state are a single query.

value bands and quartilessql
-- Distribution of estimated value by county, in fixed bands.
SELECT state, county,
       CASE
         WHEN est_value <  150000 THEN '1: under 150k'
         WHEN est_value <  300000 THEN '2: 150k-300k'
         WHEN est_value <  600000 THEN '3: 300k-600k'
         WHEN est_value < 1000000 THEN '4: 600k-1m'
         ELSE                          '5: 1m and over'
       END AS band,
       count(*) AS n
FROM props
WHERE est_value IS NOT NULL
GROUP BY ALL
ORDER BY state, county, band;

-- Or let the data choose: quartiles of value per county.
SELECT state, county,
       quantile_cont(est_value, [0.25, 0.5, 0.75]) AS value_quartiles,
       count(*) AS n
FROM props
WHERE est_value > 0
GROUP BY ALL
ORDER BY n DESC;

The fixed bands are illustrative; pick ones that suit your market. GROUP BY ALL groups by every non-aggregated column in the select list, which saves repeating the CASE expression.

Step 5: Filter by owner type

The export’s Corporate Owned flag marks owners that appear to be businesses, and Owner Type carries the source’s own classification (profile its values in step 2). Name patterns refine the flag: trusts and estates are not businesses but behave differently from individual owners. The owner’s name is split across Owner 1 First Name and Owner 1 Last Name, so the view joins the two into one owner_name to match against. A DuckDB macro keeps the rule in one place:

owner typesql
-- Owner type from the Corporate Owned flag plus name patterns.
CREATE OR REPLACE MACRO owner_class(name, corp) AS
  CASE
    WHEN regexp_matches(upper(name), '\b(LLC|L L C|INC|CORP|LP|LTD|HOLDINGS|PROPERTIES)\b') THEN 'company'
    WHEN regexp_matches(upper(name), '\b(TRUST|TRUSTEE|TR|TRS)\b')                     THEN 'trust'
    WHEN regexp_matches(upper(name), '\b(ESTATE OF|HEIRS)\b')                         THEN 'estate'
    WHEN corp THEN 'company'
    ELSE 'individual'
  END;

SELECT owner_class(owner_name, corporate) AS owner_class,
       count(*)                           AS n,
       round(avg(equity_pct), 1)          AS avg_equity_pct
FROM props
GROUP BY 1
ORDER BY n DESC;

-- Free-and-clear properties held by individuals out of state.
SELECT *
FROM props
WHERE owner_class(owner_name, corporate) = 'individual'
  AND free_and_clear
  AND mail_state <> state;                  -- mailed outside the property's state

The keyword lists are a starting point. Run the classification, then look at a sample of each class and add the forms you see. Grouping owners into portfolios is a bigger task, covered in finding portfolio owners in a bulk file.

Step 6: Combine filters

Real lists combine several conditions. Putting the thresholds in a small params CTE keeps them in one visible place and makes the query easy to rerun with different numbers:

combined filtersql
-- One reusable filter, parameterised with a CTE.
WITH params AS (
  SELECT 40 AS min_equity_pct, 200000 AS min_value, 900000 AS max_value
)
SELECT p.*
FROM props p, params
WHERE p.equity_pct BETWEEN params.min_equity_pct AND 100
  AND p.est_value BETWEEN params.min_value AND params.max_value
  AND p.property_type IN ('Single Family', 'Condominium')
  AND NOT p.corporate
  AND p.open_lien_count <= 1;

Remember that comparisons with NULL are never true. A row with a blank Open Lien Count is excluded by open_lien_count <= 1. If you want to keep unknowns, say so explicitly with coalesce or OR … IS NULL.

Step 7: Convert to Parquet for repeat queries

Every query against the CSV parts re-reads and re-parses the text. For one or two questions that is fine. If you will query the same file all week, convert it to Parquet once:

convert to Parquetsql
-- Convert once. The typed view becomes a typed, compressed, columnar file.
COPY (SELECT * FROM props) TO 'state.parquet' (FORMAT parquet);

-- From now on, query the Parquet file instead of the CSV parts.
CREATE OR REPLACE VIEW props AS SELECT * FROM 'state.parquet';

Parquet stores each column compressed and already typed, so queries skip both parsing and the columns they do not use. The file is typically much smaller than the CSV, and repeat queries are typically much faster. The exact gain depends on the query and the machine, so check it with the timer on your own file.

How long queries take

Timings depend on file size, disk speed, core count and the query, so treat the following as rough guidance and measure with .timer on:

SituationWhat to expect (approximate)
First query over a statewide CSVThe slowest case: the whole file is read and parsed. Seconds to a minute or so on a recent laptop.
Later queries over the same CSVSimilar to the first. CSV has nothing to skip, so each query re-reads it.
Queries over a Parquet copyUsually a fraction of the CSV time, because only the needed columns are read and nothing is parsed.
Filters returning a few thousand rowsDominated by the scan, not by the filter. Adding conditions barely changes the time.
Sorting or grouping by high-cardinality columnsThe heaviest operations. May spill to disk on machines with little memory.

For a slow query, two tools help:

sample and profilesql
-- Work out a filter on a random 1% sample, then run it on everything.
SELECT * FROM props USING SAMPLE 1 PERCENT (bernoulli) WHERE absentee LIMIT 20;

-- See where the time goes in a slow query.
EXPLAIN ANALYZE
SELECT state, county, count(*) FROM props WHERE equity_pct >= 50 GROUP BY ALL;

Sampling lets you iterate on a filter quickly before running it on everything. EXPLAIN ANALYZE shows how long each step took, which usually points at either the CSV scan (convert to Parquet) or a large sort (add a LIMIT, or sort only the final result).

Step 8: Write results out

The output of most filters is a list for someone else: a CSV for a CRM, one file per county for a team, a short sample for review. COPY … TO writes any query result:

export resultssql
-- Write a filtered list back out as CSV for a CRM or a mail house.
COPY (
  SELECT owner_name, owner2_first, owner2_last,
         mail_street, mail_city, mail_state, mail_zip,
         address, city, state, zip, county, apn, est_value, equity_pct
  FROM props
  WHERE absentee AND equity_pct >= 50
  ORDER BY mail_zip
) TO 'absentee_high_equity.csv' (HEADER);

-- One file per county, in a folder per state and county name.
COPY (SELECT * FROM props WHERE absentee)
TO 'by_county' (FORMAT csv, HEADER, PARTITION_BY (state, county));

PARTITION_BY writes a folder per value, with one or more files inside each. It is the quickest way to split a statewide result by county without writing a loop. The file identifies counties by State and County name, so partition by both.

The same thing from Python

If the rest of your work is in Python, run the same SQL through the duckdb package and get a pandas DataFrame back. DuckDB does the heavy filtering; pandas only sees the result.

duckdb from pythonpython
import duckdb

con = duckdb.connect("property.duckdb")      # or duckdb.connect() for in-memory
con.sql("SET memory_limit = '4GB'")          # optional; DuckDB spills to disk above it

df = con.sql("""
    SELECT state, county, count(*) AS n, median(est_value) AS median_value
    FROM 'state.parquet'
    WHERE absentee AND equity_pct >= 50
    GROUP BY ALL
    ORDER BY n DESC
""").df()                                     # a pandas DataFrame

print(df.head(10))

Quick reference

TaskDuckDB
Read all export partsread_csv('exports/*.csv', all_varchar = true)
Convert safelyTRY_CAST(col AS BIGINT)
Percentilesquantile_cont(col, [0.25, 0.5, 0.75])
Group by everything selectedGROUP BY ALL
Random sampleUSING SAMPLE 1 PERCENT (bernoulli)
Write CSVCOPY (query) TO 'out.csv' (HEADER)
One file per countyCOPY … (FORMAT csv, PARTITION_BY (state, county))
Faster repeat queriesCOPY … TO 'x.parquet' (FORMAT parquet)
  1. Define the typed view once, in a persistent database file.
  2. Profile categorical columns before filtering on them.
  3. Iterate on a sample, then run on everything.
  4. Convert to Parquet if you will query more than a few times.

When to move to a database

DuckDB is ideal for one analyst and one file. When several people need the same data, when you want to keep monthly history with indexes and access control, or when an application queries it, a server database is the better home. The PostgreSQL loading guide covers that path.