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
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
export TOOLZERHUB_API_KEY=your_key
python zillow_to_csv.py 78704 for_sale 20 78704.csv
Output on 2026-10-12:
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
price0. Itsstatus_textwas "Auction". Filter zeros before averaging. - 12 rows had no
living_areaand one had nobedrooms. Drop them before working out price per square foot. status=soldreturns a sample of recent sales, not all of them; the response marks it withresults_are_capped. Use it for spot checks, not for a full sales history.- Rentals are not in search. Use
/v1/zillow/sitemapwithfamily=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
- ToolzerHub Zillow reference (41 per page, 20-page cap, region formats), https://docs.toolzerhub.com/reference/zillow, checked 2026-10-12
- Zillow Group, Data & APIs, https://www.zillowgroup.com/developers/, read 2026-10-12
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-12