Zillow Data Scraper GitHub: How to Export Bulk Property Records to CSV with Python
The fastest way to build a local real estate dataset from public Zillow listing pages without committing to a paid brokerage feed is to combine a runnable open-source scraper repository with a small Python normalization layer that exports clean CSV files. This article is for real estate analysts, PropTech engineers, independent investors, market researchers, and AI-agent builders who need a repeatable bulk property export they can refresh, version, and feed into downstream analysis tools.
The data-scrape/zillow-data-scraper repository provides a runnable Python reference for collecting public property records, and the same query and output pattern can be paired with data-scrape/zillow-scraper-api when you also want a long-running monitor that watches the same listings for changes.
TL;DR
- Use
data-scrape/zillow-data-scraperto collect public Zillow property records for a bounded geography and listing type. - Define a narrow target list up front: city, ZIP, neighborhood, or property type. Broad "all listings" queries produce noisy, hard-to-maintain CSVs.
- Normalize each raw record into a stable CSV schema with
captured_at,property_url,address,price,beds,baths,sqft,lot_size,year_built,property_type, andstatus. - Aggregate to one row per listing so joins with public county assessor data or in-house CRM records are easy.
- Refresh daily or weekly for market analysis; treat the output as a snapshot, not a live stream.
- Combine with
data-scrape/zillow-scraper-apiwhen you also want change-detection over time on the same address set.
Why Bulk Property Export Is Harder Than It Looks
Public real estate listings look simple until you try to turn them into a reliable CSV. A handful of problems recur:
- Selector drift. Listing cards, price badges, status labels, and address blocks change frequently, and an outdated selector silently drops columns or rows from your export.
- Mixed property types. Single-family rentals, condos, new construction, auctions, and "off market" pre-listings share one search URL but use different card structures.
- Address and unit formatting. Unit numbers, fractional addresses, and city/zip combinations are inconsistent across listings, so a naive join by address fails on roughly five to fifteen percent of rows in real datasets.
- Price and status freshness. A price change on the live page may not propagate to the cached version served to scrapers, so two captures of the same URL can disagree on price within the same hour.
- Rate limits and bot detection. Aggressive polling hits throttling quickly, and aggressive retries can get an IP blocked long enough to lose a daily refresh window.
- Legal and policy scope. Bulk export of public listing pages is a different posture from harvesting private agent notes, owner portals, or authenticated user data; respect the relevant terms of service and applicable law.
This article takes the middle path: start with an open-source reference repository, then add a small, auditable Python layer for normalization and CSV export.
What the Verified Repositories Provide
The data-scrape/zillow-data-scraper repository is a Python reference for collecting public Zillow property records. It ships a scraper.py entry point, a requirements.txt, an examples/ folder, and a README. Read the README for the current setup steps, supported query parameters, and environment variables before running it.
The data-scrape profile hosts related repositories that share a similar pattern. data-scrape/zillow-scraper-api targets the same listings but is shaped for ongoing monitoring use cases, where you want to detect new listings, price drops, or status changes on an address set you already track. The two repositories overlap in raw field names, so the normalization layer you write for bulk export can be reused for monitoring with only the source adapter swapped.
Pipeline Design
A maintainable bulk export pipeline has five stages: capture raw JSON for a bounded geography and listing type, normalize fields into a stable schema, deduplicate by property_url and address, coerce numeric and date fields into typed columns, and write a single CSV with one row per listing.
Setup and Configuration
Clone the repository and install dependencies inside a virtual environment so packages do not collide with system Python:
git clone https://github.com/data-scrape/zillow-data-scraper.git
cd zillow-data-scraper
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
Set configuration through environment variables instead of hard-coding geographies or pacing limits. The example below forwards a target list, output path, and request budget to the scraper's CLI. Adjust variable names to match the current version of scraper.py; never commit a real target list or proxy configuration to source control.
export ZILLOW_TARGETS_FILE="targets.txt"
export ZILLOW_OUTPUT_FILE="raw_listings.json"
export ZILLOW_MAX_REQUESTS=400
export ZILLOW_REQUEST_DELAY=2.5
python scraper.py \
--input "$ZILLOW_TARGETS_FILE" \
--output "$ZILLOW_OUTPUT_FILE" \
--max-requests "$ZILLOW_MAX_REQUESTS" \
--delay "$ZILLOW_REQUEST_DELAY"
Keep targets.txt narrow: one city or ZIP per line, optionally annotated with property type or listing status. A first useful set is three to five geographies you already understand.
Normalization: From Raw JSON to a Stable CSV
The script below reads raw_listings.json, normalizes each record, deduplicates by property_url, coerces numeric fields, and writes a CSV. The output schema is stable across runs, so downstream tools can rely on the column names without per-run discovery.
import csv
import datetime as dt
import json
import os
import pathlib
import re
RAW_PATH = pathlib.Path(os.environ.get("ZILLOW_OUTPUT_FILE", "raw_listings.json"))
CSV_PATH = pathlib.Path(os.environ.get("ZILLOW_CSV_FILE", "listings.csv"))
NUMERIC_FIELDS = ("price", "beds", "baths", "sqft", "lot_size", "year_built")
COLUMNS = [
"captured_at", "property_url", "address", "city", "state", "zip",
"price", "beds", "baths", "sqft", "lot_size", "year_built",
"property_type", "status",
]
def parse_int(value):
if value is None:
return ""
digits = re.sub(r"[^\d]", "", str(value))
return int(digits) if digits else ""
def normalize_record(raw, captured_at):
address = raw.get("address") or {}
return {
"captured_at": captured_at,
"property_url": raw.get("url") or "",
"address": " ".join(
part for part in [
address.get("street"),
address.get("unit"),
] if part
).strip(),
"city": address.get("city") or "",
"state": address.get("state") or "",
"zip": address.get("zip") or "",
"price": parse_int(raw.get("price")),
"beds": parse_int(raw.get("beds")),
"baths": parse_int(raw.get("baths")),
"sqft": parse_int(raw.get("sqft")),
"lot_size": parse_int(raw.get("lot_size")),
"year_built": parse_int(raw.get("year_built")),
"property_type": raw.get("property_type") or "",
"status": raw.get("status") or "",
}
def main():
captured_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
raw_records = json.loads(RAW_PATH.read_text(encoding="utf-8"))
seen_urls = set()
rows = []
for raw in raw_records:
url = (raw.get("url") or "").strip()
if not url or url in seen_urls:
continue
seen_urls.add(url)
rows.append(normalize_record(raw, captured_at))
with CSV_PATH.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=COLUMNS)
writer.writeheader()
for row in rows:
writer.writerow(row)
print(f"Wrote {len(rows)} rows to {CSV_PATH}")
if __name__ == "__main__":
main()
The script keeps the schema stable across runs, drops duplicates by property_url, and uses parse_int to strip currency symbols, commas, and ranges from price and square footage. Adjust the column list if you want to add days_on_zillow, hoa_fee, or price_per_sqft.
Representative Output
After running the script above against a small target set, listings.csv looks like the example below. Treat this as a representative shape, not real-time data; verify fields against the current page structure of the source pages.
captured_at,property_url,address,city,state,zip,price,beds,baths,sqft,lot_size,year_built,property_type,status
2026-09-09T10:00:19+00:00,https://www.zillow.com/homedetails/123-Main-St/0000_rid/,123 Main St,Asheville,NC,28801,425000,3,2,1480,7200,1998,SingleFamily,ForSale
2026-09-09T10:00:19+00:00,https://www.zillow.com/homedetails/456-Oak-Ave/0001_rid/,456 Oak Ave,Asheville,NC,28801,389000,2,1,1080,5400,1962,SingleFamily,ForSale
2026-09-09T10:00:19+00:00,https://www.zillow.com/homedetails/789-Pine-Rd/0002_rid/,789 Pine Rd,Asheville,NC,28803,512000,4,3,2120,9100,2005,SingleFamily,Pending
A stable schema means downstream tools such as pandas, DuckDB, or a CSV-to-Parquet job can read the file without per-run discovery logic.
Use Cases
A bulk property export supports several analyst workflows:
- Market snapshots for a neighborhood. Capture every active listing in three to five ZIPs once per week and compute median list price, price per square foot, and days-on-market distribution.
-
Comparable sales research. Pair each in-contract or sold record with public county assessor data using
addresspluszipas the join key. -
Investor outreach lists. Filter for
property_type = SingleFamilyandprice < 400000, then enrich with public parcel data before mailing. -
Rental yield modeling. Combine the
pricecolumn with publicly listed rent ranges and standard expense assumptions to model gross yield by ZIP. - AI-agent context packs. Wrap the CSV as a versioned context file that an agent can quote, cite, and re-read on demand instead of refetching the live site each time.
Build, Buy, and Tool Comparison
Most teams pick one of three paths for bulk property data. Use the same evaluation criteria for each:
- Self-hosted open-source scraper. Best for engineers who want full control over query shape, refresh cadence, and CSV schema. Maintenance burden falls on the team when the source page changes.
- Managed scraper API. Best for teams that want a hosted endpoint, a normalized response, and a quota model. Cost and quota must be verified against the provider's current pricing page; do not assume numbers from older blog posts.
- Manual CSV download. Best for one-off research where a single CSV from the source site is enough. Does not scale to recurring analysis.
A reasonable first step is to use data-scrape/zillow-data-scraper for the first export, validate the CSV against your internal assumptions, and only then evaluate a managed endpoint for ongoing refresh.
Checklist Before Production
Run through these checks before you publish or share a CSV:
- Schema is stable and documented, including units for
sqftandlot_size. -
captured_atis recorded in UTC so joins across time zones are reproducible. - Numeric columns are coerced; no embedded currency symbols or ranges remain.
- Duplicates are removed by
property_url, not just byaddress. - Refresh cadence matches the analytical question: daily for active campaigns, weekly for market snapshots.
- Output file size and row count are recorded in a small manifest so a broken run is visible.
- Refresh runs are idempotent and can be re-run from scratch.
- Access to the CSV is scoped to the team that needs it, and sensitive columns are removed if the file is shared externally.
Compliance and Maintenance
Bulk export of public listing pages is acceptable for personal research, internal market analysis, and academic study, but you must respect the relevant terms of service, applicable privacy law, and any contractual limits in your jurisdiction. Do not collect owner names, agent contact details, or any field that is not visible on the public listing page. Do not bypass authentication, CAPTCHA, rate limits, or any other access control. If a target page asks you to stop automated access, stop and remove the captured records.
For long-running use, schedule a monthly review of the source page structure, re-validate a sample of rows against the live page, and pin the scraper to a known-good commit so a bad update does not silently corrupt your downstream dataset.
FAQ
Is there an official Zillow bulk export API?
Zillow does not publish a general-purpose bulk listing export API for third-party developers. The public search pages remain the most common source, which is why open-source scrapers are widely used.
What columns should a Zillow export contain?
At minimum: captured_at, property_url, address, city, state, zip, price, beds, baths, sqft, property_type, and status. Add lot_size, year_built, hoa_fee, and days_on_zillow when you need them.
How often should I refresh the export?
Weekly is a good default for market snapshots. Daily is reasonable for active campaigns. Hourly refresh is usually wasteful and increases the risk of being throttled.
What happens when the source page layout changes?
The scraper may silently drop columns or rows. Compare a sample of listings.csv against the live page on every release of the scraper, and add a small validation job that flags missing fields.
Can I feed the CSV to an AI agent or a notebook?
Yes. CSV is one of the cleanest formats for pandas, DuckDB, and CSV-aware LLM tools. Add a short header comment that documents the capture time and the source repository.
What is the difference between zillow-data-scraper and zillow-scraper-api?
The data scraper is shaped for bulk snapshot exports to CSV or JSON. The scraper API is shaped for ongoing monitoring of a known address set. They share enough of the field schema that the same normalization layer can serve both.
How do I avoid being blocked?
Keep request rates modest, set a real ZILLOW_REQUEST_DELAY, respect any robots directives, and stop immediately if you see a CAPTCHA or a 429 response. Treat the public pages as a shared resource.
Next Steps
If you want to extend the same pipeline to ongoing monitoring, pair this export with data-scrape/zillow-scraper-api and reuse the normalization layer to detect new listings, price drops, and status changes on the same address set. For a broader cross-vertical pattern that also covers hotels, marketplaces, and local businesses, browse the data-scrape profile for the full list of public repositories and their READMEs.
Top comments (0)