ZillowHow-to

Export Zillow search results to CSV or Excel with Python

Zillow search stops at 820 homes. This Python script exports every for-sale listing in a ZIP code to CSV: 338 homes, 9 calls.

4 min read

Short answer

  • Zillow search shows at most 20 pages of 41 homes, so a city search stops at 820 listings even when thousands match.
  • Search ZIP by ZIP instead: our script exported all 338 for-sale homes in Austin's 78704 to CSV in 17 seconds.
  • 1 credit per page of 41 listings, about 25 credits per 1,000 homes. The CSV is UTF-8 with a header row.
On this page

To get Zillow search results into a spreadsheet, you need an API or a script. This guide uses both: a 60-line Python script that pages through Zillow search results and writes one CSV row per home. We ran it on 2026-10-12 and exported every for-sale listing in Austin's 78704 ZIP code, 338 homes.

The 820-listing ceiling

A Zillow search returns 41 homes per page and stops at page 20. Our search for austin-tx on 2026-10-12 reported 5,642 homes for sale but only 20 reachable pages, so at most 820 of them. The cap comes from Zillow, not from us.

The fix is to search smaller areas. ZIP 78704 had 338 matches across 9 pages, and ZIP 78745 had 352 across 9, both well under the cap. Split a city into ZIP codes and each one stays under the cap unless it has more than 820 matches. Check total_pages: 20 means the search was capped.

What you need

  • Python 3 and requests.
  • An API key for our Zillow API (we make ToolzerHub) from the dashboard. Zillow's own APIs are for businesses and invited partners (which ones exist). The free tier's 100 credits a month cover about 4,100 listings.

The script

python
import csv
import os
import sys
import time

import requests

URL = "https://api.toolzerhub.com/v1/zillow/search"
HEADERS = {"x-api-key": os.environ["TOOLZERHUB_API_KEY"]}
FIELDS = ["zpid", "address", "city", "state", "zipcode", "price", "bedrooms",
          "bathrooms", "living_area", "status_text", "broker_name", "latitude",
          "longitude", "url"]


def cell(value):
    # Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
        return "'" + value
    return value


def get(params, tries=5):
    # 429 and 503 are retryable; the error body says how long to wait.
    for attempt in range(tries):
        r = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        if r.status_code not in (429, 503) or attempt == tries - 1:
            r.raise_for_status()
            return r.json()["data"]
        wait = r.json().get("error", {}).get("details", {}).get("retryAfter", 5 * (attempt + 1))
        time.sleep(min(wait, 60))


def listings(region, status, max_pages):
    page = 1
    while page <= max_pages:
        data = get({"region": region, "status": status, "page": page})
        yield data, data["results"]
        if not data["has_more"]:
            break
        page = data["next_page"]


def main(regions, status="for_sale", max_pages=20, out="listings.csv"):
    seen = set()
    with open(out, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for region in regions.split(","):
            for data, rows in listings(region, status, int(max_pages)):
                if data["page"] == 1:
                    print(f"{region}: {data['total_results']} matches, {data['total_pages']} pages reachable")
                for home in rows:
                    if home["zpid"] not in seen:
                        seen.add(home["zpid"])
                        writer.writerow({k: cell(home.get(k)) for k in FIELDS})
    print(f"wrote {len(seen)} listings to {out}")


if __name__ == "__main__":
    main(*sys.argv[1:])

region takes a ZIP code, a Zillow URL slug like austin-tx, or a state like ca. Pass several ZIPs separated by commas; seen drops homes that show up in two of them. FIELDS picks 14 of the 30-odd fields each listing has; add image_url, has_open_house or marketing_status if you need them.

cell() puts an apostrophe in front of any text that starts with =, +, - or @, so a spreadsheet shows it as text instead of running it as a formula.

Run it

bash
export TOOLZERHUB_API_KEY=your_key
python zillow_to_csv.py 78704 for_sale 20 78704.csv

Output on 2026-10-12:

text
78704: 338 matches, 9 pages reachable
wrote 338 listings to 78704.csv

It took 17 seconds and 9 calls. The first two rows:

address price beds baths sq ft broker
1709 Brackenridge St, Austin, TX 78704 $1,645,000 4 3 2,301 AustinRealEstate.com
606 Josephine St, Austin, TX 78704 $2,250,000 4 3 2,483 Moreland Properties

The file is UTF-8 with a header row. We did not open it in Excel; if accented characters look wrong there, change encoding="utf-8" to "utf-8-sig". The median asking price, leaving out the one $0 row, was $749,900.

Data quirks in the 78704 export

  • One row had price 0. Its status_text was "Auction". Filter zeros before averaging.
  • 12 rows had no living_area and one had no bedrooms. Drop them before working out price per square foot.
  • status=sold returns a sample of recent sales, not all of them; the response marks it with results_are_capped. Use it for spot checks, not for a full sales history.
  • Rentals are not in search. Use /v1/zillow/sitemap with family=for_rent.

For one home's price and tax history, pass its zpid to /v1/zillow/history.

What it costs

Each search page is 1 credit for up to 41 listings, so 1,000 listings is about 25 credits: 2 to 5 cents at our rates of $0.99 to $1.90 per 1,000 credits. The 338-home export cost 9 credits.

To compare this route with free Zillow scrapers, read best Zillow scrapers, tested on 10 homes.

We read public Zillow listing pages and are not affiliated with Zillow Group.

Sources

Get this data from your own code

The Zillow API returns it as JSON with one x-api-key header. 100 free credits monthly.