How to Scrape Zillow Data: Listings, Prices, Zestimate
Learn how to scrape Zillow data: pull listings, prices, the Zestimate, and property details from Zillow's hidden JSON API, and beat its anti-bot defenses.

To scrape Zillow data at any real scale, the hard part isn't parsing HTML. It's two things most guides skip. Zillow ships its listings, prices, the Zestimate, and full property details as JSON buried in the page and behind an undocumented search endpoint, and it runs one of the strictest bot walls on the public web. This guide stays on the keyboard. You'll see exactly where Zillow keeps each field, how to hit its hidden search API for clean JSON instead of scraping cards, how to beat the 500-result cap, how to get past the "Press and Hold" challenge, and working Python against SparkProxy's Scraping API. It's the Zillow-specific companion to our general guide on scraping real estate listings, so here we go deep on what makes Zillow different.
What you'll build
- A field map for price, Zestimate, beds, baths, sqft, price history, tax history, and location
- A parser that reads Zillow's embedded
__NEXT_DATA__JSON instead of fighting selectors- Direct calls to Zillow's
GetSearchPageStateendpoint that return listing JSON, no HTML- A price-band tiler that beats Zillow's 500-result search cap
- An anti-bot escalation ladder so a "Press and Hold" wall doesn't end the run
Scrape responsibly: public data, ToS, and the Zestimate caveat
Set the boundary before any code. Scraping listing data that any anonymous visitor can see on a public Zillow page sits on defensible ground, and US courts have repeatedly declined to treat access to public web pages as unauthorized access. That is not blanket permission. A few rules keep you on the right side:
- Public pages only. Don't scrape anything behind a login, an agent portal, or Zillow's Premier Agent tooling. Bypassing authentication is a different legal category.
- Read robots.txt and the ToS as signals. Zillow's terms prohibit automated collection, and its data feeds are governed by MLS and RESO licensing. That rarely creates criminal exposure for public data, but it tells you the site will fight you technically, so behave: go slow, keep a small footprint.
- The Zestimate is an estimate, not a fact. It's Zillow's proprietary valuation model, with a published median error that varies by market. Store it as a signal, label it clearly, and never present it as an appraisal or a sale price.
- Don't collect agent PII in bulk or rebuild the portal. Using price and inventory data for internal market analysis is a very different risk profile from harvesting agent contact details or republishing MLS-sourced listings as a competing site.
The rest of this article assumes you're collecting public listing data for internal analysis. For the broader legal framework on MLS licensing and Fair Housing when scraped data feeds automated decisions, see the compliance section of our real estate data aggregation guide. For anything you plan to publish or resell, get legal sign-off first.
What a Zillow listing exposes
Decide your schema up front. Retrofitting a field after you've scraped 200,000 pages means re-scraping. Here's the field reference most Zillow property data pipelines converge on, with the type and the gotcha that bites people.
| Field | Data type | Example value | Notes |
|---|---|---|---|
| `zpid` | integer | `48749425` | Zillow's stable property id. It's in the URL (`/homedetails/.../48749425_zpid/`) and every JSON blob. Your primary key. |
| `price` | integer | `459000` | Current list price. `0` or missing on off-market homes. |
| `zestimate` | integer | `472100` | Zillow's estimated market value. Present even on off-market homes. Label it as an estimate. |
| `rentZestimate` | integer | `2650` | Estimated monthly rent. Useful for cap-rate math. |
| `bedrooms` / `bathrooms` | int / float | `3` / `2.5` | Keep the decimal on baths. Half baths matter. |
| `livingArea` | integer | `1840` | Interior sqft. Do not confuse with `lotSize`. |
| `homeType` | string | `SINGLE_FAMILY` | `SINGLE_FAMILY`, `CONDO`, `TOWNHOUSE`, `MANUFACTURED`, `LOT`. Already normalized by Zillow. |
| `homeStatus` | string | `FOR_SALE` | `FOR_SALE`, `PENDING`, `SOLD`, `OTHER` (off-market). Drives whether the row is current. |
| `yearBuilt` | integer | `1998` | Missing on some land and new construction. |
| `priceHistory` | array | `[{date, price, event}]` | Sale, listing, and price-cut events. One of the most valuable fields for demand signals. |
| `taxHistory` | array | `[{time, taxPaid, value}]` | Annual assessed value and tax paid. Great for valuation models. |
| `resoFacts` | object | `{lotSize, heating, ...}` | The RESO-standard fact bag: lot size, HVAC, parking, HOA, appliances, flooring, and more. |
| `latitude` / `longitude` | float | `30.242, -97.769` | Exact coordinates, straight from the JSON. No geocoding needed. |
| `schools` | array | `[{name, rating, level}]` | Assigned schools with GreatSchools-style ratings. |
Two of these carry more weight than the rest. Price history plus tax history together tell you seller motivation and a defensible value anchor, which the current list price alone never will. A home with two price cuts over 60 days and an assessed value 15% under asking is a different negotiation than a fresh listing at assessed value, even at the same sticker.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Where Zillow keeps its data: __NEXT_DATA__ and the GraphQL cache
Here's the thing that trips up first-timers. A Zillow home detail page is a Next.js React app. The visible HTML is a shell, and the real fields (price, Zestimate, resoFacts, coordinates) arrive as a large JSON object embedded in a tag, hydrated client-side from Zillow's internal GraphQL API. You do not need to reverse-engineer that GraphQL API. The stable target is the JSON Zillow itself bakes into the page.
That changes your extraction strategy. Instead of writing brittle CSS selectors against a DOM that may not even contain the Zestimate, you pull one script tag and parse it. Grab __NEXT_DATA__:
import json
from bs4 import BeautifulSoup
def extract_next_data(html):
"""Zillow ships detail-page data inside <script id='__NEXT_DATA__'>."""
soup = BeautifulSoup(html, "html.parser")
tag = soup.find("script", id="__NEXT_DATA__")
if not tag or not tag.string:
return None
return json.loads(tag.string)
The property object is nested inside a client-side Apollo cache. The catch that costs people an hour: gdpClientCache is a JSON string stored inside the outer JSON, so you parse twice. The cache is keyed by a long query name that embeds the zpid, so walk the values instead of hard-coding a key:
def gdp_property(next_data):
"""Return the property object from the double-encoded gdpClientCache."""
props = next_data["props"]["pageProps"]["componentProps"]
cache_str = props.get("gdpClientCache")
if not cache_str:
return None
cache = json.loads(cache_str) # note: string inside JSON, parse again
for value in cache.values():
prop = value.get("property")
if prop:
return prop
return None
Some older or A/B-tested pages ship the same data under a tag instead. Keep a fallback that checks for it, and archive one raw blob during development so you can diff it when a key name changes. This approach survives Zillow reshuffling its component tree: when a field moves, you inspect one saved blob, find the new key, and update a string. That's a five-minute fix, not a rewrite. The same pattern powers our broader guide to scraping hidden JSON API endpoints.
Quickstart: one detail page through the Scraping API
Zillow combines three hard problems at once: JavaScript-rendered content, aggressive per-IP rate limits, and PerimeterX-style behavioral fingerprinting. A managed Scraping API handles proxy rotation, headless Chromium, and anti-bot behind one call, so you send a URL and get back rendered HTML. If you want the build-versus-buy math, see web scraping API vs self-managed proxies.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and it authenticates with an X-API-Key header. The simplest call, rendering a home detail page through a US IP:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.zillow.com/homedetails/48749425_zpid/" \
--data-urlencode "render_js=true" \
--data-urlencode "country_code=US" \
--data-urlencode "wait_for=script#__NEXT_DATA__"
The same thing in Python, where the rest of this guide lives:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": "https://www.zillow.com/homedetails/48749425_zpid/",
"render_js": "true", # Zillow hydrates the page via JS
"country_code": "US", # US-only inventory, pin a US exit IP
"wait_for": "script#__NEXT_DATA__", # hold until the JSON blob exists
},
timeout=90,
)
resp.raise_for_status()
html = resp.text
The wait_for selector is the parameter people forget. Zillow builds the shell first and hydrates the state script a beat later. Without wait_for, you capture the shell and your Zestimate comes back missing on a random fraction of pages. Point it at the __NEXT_DATA__ script and the API waits for it before capturing.
Pulling the Zestimate, price history, and resoFacts
With the property object in hand, mapping fields is direct. The Zestimate and rent Zestimate sit at the top level, and the rich facts live in nested arrays and the resoFacts bag:
def parse_property(prop):
facts = prop.get("resoFacts") or {}
record = {
"zpid": prop.get("zpid"),
"price": prop.get("price"),
"zestimate": prop.get("zestimate"),
"rent_zestimate": prop.get("rentZestimate"),
"beds": prop.get("bedrooms"),
"baths": prop.get("bathrooms"),
"sqft": prop.get("livingArea"),
"year_built": prop.get("yearBuilt"),
"home_type": prop.get("homeType"),
"home_status": prop.get("homeStatus"),
"lot_size": facts.get("lotSize"),
"hoa_fee": facts.get("hoaFee"),
"parking": facts.get("parkingCapacity"),
"lat": prop.get("latitude"),
"lng": prop.get("longitude"),
"street": (prop.get("address") or {}).get("streetAddress"),
"city": (prop.get("address") or {}).get("city"),
"state": (prop.get("address") or {}).get("state"),
"zip": (prop.get("address") or {}).get("zipcode"),
}
# price history: list, sale, and price-cut events
record["price_history"] = [
{"date": h.get("date"), "price": h.get("price"),
"event": h.get("event"), "change_rate": h.get("priceChangeRate")}
for h in (prop.get("priceHistory") or [])
]
# tax history: assessed value and tax paid per year
record["tax_history"] = [
{"year_ts": t.get("time"), "tax_paid": t.get("taxPaid"),
"assessed": t.get("value")}
for t in (prop.get("taxHistory") or [])
]
return record
A note on the Zestimate specifically. It's present even when homeStatus is OTHER (off-market), which is exactly why Zillow is worth scraping over a plain MLS mirror: you get a modeled value on homes that aren't for sale. Treat it as a feature in your own model, not ground truth. Pair it with taxHistory[*].assessed and the most recent priceHistory sale, and you have three independent value anchors per property. That triangulation is the analysis payload most Zillow scrapers never bother to assemble.
Beating Zillow's 500-result search cap
Now the wall. A single searchQueryState returns at most 500 results, roughly 20 pages of 40, no matter how many homes actually match. Page 21 returns nothing. A dense metro can hold 8,000 active listings, so one search sees 6% of them.
The fix is not more pages, it's narrower queries. You have two knobs, and combining them gets you full coverage:
- Price bands. Split the price axis into ranges (
0-250k,250-400k,400-600k, and so on) and run one search per band. Each band returns under the cap. - Bounding-box tiling. If a single price band still hits 500 in a hot metro, split the map box into quadrants and search each. Recurse until every tile returns under the cap.
Price bands are usually enough and cheaper to run, so start there and only tile the boxes that still overflow:
def sweep_area(bbox, bands):
"""Union results across price bands; tile any band that still hits the cap."""
seen = {}
for lo, hi in bands:
page = 1
while True:
rows, total_pages = fetch_search(bbox, page, price_min=lo, price_max=hi)
for row in rows:
seen[row["zpid"]] = row # dedupe by zpid
# a band that pins the cap needs geographic splitting
if page == 1 and len(rows) >= 40 and total_pages >= 20:
for quad in split_bbox(bbox):
for r in sweep_area(quad, [(lo, hi)]).values():
seen[r["zpid"]] = r
break
if page >= total_pages:
break
page += 1
return seen
def split_bbox(b):
mlat = (b["north"] + b["south"]) / 2
mlng = (b["east"] + b["west"]) / 2
return [
{"west": b["west"], "east": mlng, "south": mlat, "north": b["north"]},
{"west": mlng, "east": b["east"], "south": mlat, "north": b["north"]},
{"west": b["west"], "east": mlng, "south": b["south"], "north": mlat},
{"west": mlng, "east": b["east"], "south": b["south"], "north": mlat},
]
Deduping by zpid matters because bands and tiles overlap at the edges. This quadtree-plus-bands approach is the map cousin of the "slice the query" trick from our general real estate scraping guide, and on Zillow it's the difference between a partial sample and the whole metro.
Getting past Zillow's anti-bot (Press and Hold)
Zillow runs PerimeterX (now HUMAN Security). When it flags you, you don't get a plain 403, you get a page with a "Press and Hold to confirm you are a human" button and a px-captcha element. Datacenter IPs trip it fast, and the GetSearchPageState endpoint is guarded harder than detail pages. The mistake is reaching for the most expensive proxy tier on every request. The right move is an escalation ladder that starts cheap and only upgrades what gets blocked.
Cheapest to strongest:
- Detail pages: rotating datacenter with JS render and stealth. Many
homedetailspages clear withrender_js=trueandstealth=true. Start here. - Search endpoint: go straight to residential.
GetSearchPageStatealmost always needspremium_proxy=true. Don't waste datacenter attempts on it. - Add
stealth=true. It layers fingerprint hardening and realistic timing for pages that sniff headless browsers. - Vary
device. Rotatedesktopandmobileso repeated hits don't share one fingerprint.
The core discipline is rate, not just IP quality. Even with rotation, 200 requests a minute at Zillow looks nothing like a person browsing. Hold each IP to roughly 10 to 20 requests an hour, spread the load, and add jitter. Our full playbook is how to avoid getting your proxy blocked, and the PerimeterX-specific tactics live in how to bypass PerimeterX. Map symptoms to fixes so you're not guessing mid-run:
| Symptom | Likely cause | Fix |
|---|---|---|
| `px-captcha` / "Press and Hold" page | Datacenter IP flagged by PerimeterX | Escalate to `premium_proxy=true`; add `stealth=true` |
| `403` on `GetSearchPageState` | Endpoint hit from datacenter or thin headers | Residential IP plus `forward_headers` with a Referer |
| `429` after N requests | Per-IP rate too high | Slow down, widen delays, lean on rotation |
| Empty Zestimate or missing fields | Captured before hydration | `render_js=true` plus `wait_for=script#__NEXT_DATA__` |
| `__NEXT_DATA__` present but no `property` | Page uses the older Apollo blob | Fall back to `script#hdpApolloPreloadedData` |
The insight most guides skip: let the block response decide when to escalate, instead of paying for residential everywhere. Send detail pages on datacenter first and flip premium_proxy on only for the exact URLs that return a captcha. Reserve residential wholesale for the search endpoint, which genuinely needs it. On a mixed run that keeps most detail requests on the cheap tier.
A complete production Zillow scraper
Here's the whole thing wired together: a session, a request helper with retry, backoff, and datacenter-to-residential escalation, the search sweep, the __NEXT_DATA__ detail extractor, and a runner that writes clean rows to CSV.
import requests, json, time, csv, random
from bs4 import BeautifulSoup
from urllib.parse import urlencode
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})
def api_get(url, render=False, premium=False, wait_for=None, headers=None):
"""One call with retry, backoff, and datacenter->residential escalation."""
params = {"url": url, "render_js": "true" if render else "false",
"country_code": "US", "stealth": "true"}
if wait_for:
params["wait_for"] = wait_for
if headers:
params["forward_headers"] = json.dumps(headers)
if premium:
params["premium_proxy"] = "true"
for attempt in range(4):
try:
r = session.get(API, params=params, timeout=90)
except requests.RequestException:
time.sleep(2 ** attempt); continue
blocked = r.status_code in (403, 429) or "px-captcha" in r.text[:5000]
if r.status_code == 200 and not blocked:
return r.text
if blocked and not params.get("premium_proxy"):
params["premium_proxy"] = "true" # escalate this URL only
time.sleep((2 ** attempt) + random.uniform(0, 1.5))
return None
# ---- search endpoint ----
def search_state_url(bbox, page, price_min=None, price_max=None):
qs = {
"pagination": {"currentPage": page},
"mapBounds": {"west": bbox["west"], "east": bbox["east"],
"south": bbox["south"], "north": bbox["north"]},
"filterState": {"sortSelection": {"value": "globalrelevanceex"},
"isForSaleByAgent": {"value": True},
"isForSaleByOwner": {"value": True}},
"isListVisible": True,
}
if price_min is not None or price_max is not None:
qs["filterState"]["price"] = {"min": price_min, "max": price_max}
params = {
"searchQueryState": json.dumps(qs, separators=(",", ":")),
"wants": json.dumps({"cat1": ["listResults"], "cat2": ["total"]},
separators=(",", ":")),
"requestId": page + 1,
}
return "https://www.zillow.com/search/GetSearchPageState.htm?" + urlencode(params)
def fetch_search(bbox, page, price_min=None, price_max=None):
text = api_get(search_state_url(bbox, page, price_min, price_max),
render=False, premium=True,
headers={"Referer": "https://www.zillow.com/homes/",
"Accept": "application/json"})
if not text:
return [], 1
try:
data = json.loads(text)
results = data["cat1"]["searchResults"]["listResults"]
total_pages = data["cat1"]["searchList"].get("totalPages", 1)
except (KeyError, json.JSONDecodeError):
return [], 1
return results, total_pages
def collect_zpids(bbox, bands):
seen = {}
for lo, hi in bands:
page, total = 1, 1
while page <= total:
results, total = fetch_search(bbox, page, lo, hi)
for r in results:
if r.get("zpid"):
seen[r["zpid"]] = r.get("detailUrl")
time.sleep(random.uniform(1.5, 3.5))
page += 1
return seen
# ---- detail pages ----
def extract_next_data(html):
tag = BeautifulSoup(html, "html.parser").find("script", id="__NEXT_DATA__")
return json.loads(tag.string) if tag and tag.string else None
def gdp_property(next_data):
props = next_data.get("props", {}).get("pageProps", {}).get("componentProps", {})
cache_str = props.get("gdpClientCache")
if not cache_str:
return None
for value in json.loads(cache_str).values():
if value.get("property"):
return value["property"]
return None
def scrape_detail(url):
if url and url.startswith("/"):
url = "https://www.zillow.com" + url
html = api_get(url, render=True, wait_for="script#__NEXT_DATA__")
if not html:
return None
nd = extract_next_data(html)
prop = gdp_property(nd) if nd else None
if not prop:
return None
return {
"zpid": prop.get("zpid"),
"price": prop.get("price"),
"zestimate": prop.get("zestimate"),
"rent_zest": prop.get("rentZestimate"),
"beds": prop.get("bedrooms"),
"baths": prop.get("bathrooms"),
"sqft": prop.get("livingArea"),
"year_built": prop.get("yearBuilt"),
"type": prop.get("homeType"),
"status": prop.get("homeStatus"),
"lat": prop.get("latitude"),
"lng": prop.get("longitude"),
"url": url,
}
def run(bbox, bands, out="zillow.csv"):
listings = collect_zpids(bbox, bands)
print(f"Found {len(listings)} listings")
cols = ["zpid", "price", "zestimate", "rent_zest", "beds", "baths",
"sqft", "year_built", "type", "status", "lat", "lng", "url"]
with open(out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols); w.writeheader()
for i, (zpid, url) in enumerate(listings.items(), 1):
row = scrape_detail(url)
if row:
w.writerow(row)
if i % 25 == 0:
print(f" {i}/{len(listings)} scraped")
time.sleep(random.uniform(2.0, 5.0)) # conservative per-IP pacing
if __name__ == "__main__":
AUSTIN = {"west": -97.94, "east": -97.56, "south": 30.15, "north": 30.52}
PRICE_BANDS = [(0, 300000), (300000, 500000),
(500000, 800000), (800000, None)]
run(AUSTIN, PRICE_BANDS, out="austin.csv")
This survives the failure modes that stop naive scrapers. It reads the search JSON directly, escalates only blocked requests to residential, detects the px-captcha wall in the body rather than trusting the status code alone, dedupes by zpid, waits for __NEXT_DATA__ before capture, and paces itself. Swap the bounding box and price bands for your metro and it runs.
Cost per request: pick the cheapest mode that works
The Scraping API bills in credits, and the mode you pick per request is the biggest lever on cost. Rendering JavaScript and routing through residential IPs cost more, so match the mode to what Zillow actually forces rather than defaulting to the strongest option.
| Request mode | Credits | Use it for |
|---|---|---|
| Rotating datacenter, no JS | 1 | The `GetSearchPageState` JSON if it clears on datacenter (rare, but cheap when it does) |
| Rotating datacenter + JS render | 5 | Detail pages that don't hard-block; your default first attempt |
| Premium (residential), no JS | 10 | The search endpoint, which needs residential but no rendering |
| Premium (residential) + JS render | 25 | Detail pages that hit the Press-and-Hold wall |
| Add-on: `stealth`, `country_code` | +5 each | Layer only when a target requires it |
The math at scale: a metro of 8,000 detail pages costs 40,000 credits at the datacenter-plus-JS rate (5 each), but 200,000 if you blindly send premium-plus-JS (25 each) on every page. The escalation ladder is what keeps you near the low end. Because the search endpoint returns 40 listings per residential call at 10 credits, discovering all 8,000 zpids costs only about 2,000 credits total. The detail fetches dominate the bill, which is exactly why escalating only the blocked ones matters. Run detail pages on datacenter first, upgrade the captcha'd minority, and you pay the 25-credit rate only where the page won't yield any other way.
Frequently asked questions
FAQ
Scraping publicly displayed listing data that any anonymous visitor can view is generally defensible under current US case law, which has declined to treat access to public web pages as unauthorized access. The limits come from elsewhere: don't log in or bypass authentication, don't harvest agent personal data in bulk, and don't republish MLS-sourced listings as a competing portal. Zillow's terms prohibit automated collection, so the safest footing is internal analysis of public data, and you should get legal sign-off before publishing or reselling anything you collect.
Zillow is a Next.js React app that embeds detail-page data as JSON inside a tag, then hydrates the page from it. The property object lives in a double-encoded gdpClientCache string, so you parse the outer JSON, then parse that cache string again, then walk its values for the property key. Some pages use an older hdpApolloPreloadedData script, so keep a fallback for it.
The Zestimate and rent Zestimate are top-level fields (zestimate, rentZestimate) on the property object inside __NEXT_DATA__, present even on off-market homes. Extract the property object from the gdpClientCache, then read those keys directly. Store the Zestimate as an estimate with its date, never as an appraisal, because it's Zillow's model output with a known median error that varies by market.
Yes, with the right setup, though Zillow runs PerimeterX behavioral detection that shows a "Press and Hold" challenge, not a plain 403. Detail pages often clear on rotating datacenter IPs with JavaScript rendering and stealth mode, while the GetSearchPageState search endpoint almost always needs residential IPs. The bigger lever is rate: hold each IP to roughly 10 to 20 requests an hour with high-variance delays, not just proxy quality.
A single searchQueryState query returns at most 500 results, about 20 pages of 40, regardless of how many homes match. To cover a full metro that holds thousands of listings, split the search by price bands, and if a band still pins the 500 cap in a hot area, tile the map bounding box into quadrants and recurse. Dedupe by zpid because bands and tiles overlap at the edges.
Not for everything. Many homedetails pages return correctly on rotating datacenter IPs with JavaScript rendering and stealth enabled, so start there and escalate only the URLs that hit the captcha. The GetSearchPageState endpoint is the exception: it hard-blocks datacenter ranges, so route search calls through residential (premium) IPs from the start. The cost-efficient pattern is datacenter-first on detail pages, residential wholesale on search.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
