Zillow Scraper for extracting property addresses, prices, beds, baths, sqft, photos, listing agents and Zestimates from Zillow.com. This repo has a free Zillow web scraping script you can run right now, and a Zillow real estate data API with 2 endpoints returning real structured JSON.
Last updated: 2026-07-17. Working against Zillow.com as of July 2026, and re-verified whenever Zillow changes their markup.
Every JSON block on this page was captured from the live API on 2026-07-16. Long arrays are trimmed to the first item or two, and each block says exactly what was cut. Full uncut samples are committed in zillow_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from zillow_scraper_api_codes/.
One exception, disclosed so the diff still lines up: where Zillow has no listing photo it substitutes a Google Street View image, and the query string on those URLs is redacted in the committed samples. That is the only alteration anywhere in this repo.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python zillow_scraper_api_codes/search.pyThose three lines return this, live from Zillow.com (10 of the 32 fields on the first of 41 listings):
{
"title": "705 Partridge Dr, Schaumburg, IL 60193",
"zpid": "3438699",
"price": 550000,
"beds": 3,
"baths": 3,
"sqft": 2351,
"home_type": "SINGLE_FAMILY",
"status": "FOR_SALE",
"broker_name": "RE/MAX Liberty",
"zestimate": null
}zestimate is null there and that is not a bug, it is the first thing worth knowing about this data: Zillow only publishes a Zestimate for some homes, and on this search it came back on 11 of the 41 listings. We return what Zillow renders and leave the rest null rather than estimate a number for you. The nulls stay in every block on this page for the same reason.
...multiplied by 41 listings, with 32 fields each:
That is the whole point of this repo. The rest of this page is the free script, then the API reference.
- Free Zillow Scraper
- Avoid getting blocked when scraping Zillow
- Zillow Scraper API reference
- Alert on new Zillow listings in a market
- Measured latency
- License
Zillow server-renders its for-sale results as JSON, so you can extract structured real estate data without a headless browser or JavaScript rendering. It does not ship that JSON in a plain <script> tag: it hides it inside an HTML comment, which is Zillow's first and mildest line of defence against naive scrapers:
<!-- ...{"queryState":...,"cat1":{"searchResults":{"listResults":[...]}}}... -->No key, no cost:
python free_scraper/zillow_free_scraper.py "Schaumburg, IL"Source: free_scraper/zillow_free_scraper.py. It pulls the comment bodies, JSON-parses each, walks to cat1.searchResults.listResults (falling back to __NEXT_DATA__), and emits zpid, address, price, beds, baths, sqft, url. It tries to parse before it decides anything is a block, and when it is refused it prints the raw measurements instead of just asserting. Add --default-ua to send the stock requests User-Agent instead of a browser one.
Run on 2026-07-17 against Schaumburg, IL it returned 41 listings:
That is 7 fields per listing, which is what the search results page carries. The next section is what stands between you and those 41 rows on a bad day.
Zillow runs a PerimeterX bot check. The useful thing to know about it is that it is inconsistent: the same script, from the same residential IP, gets different answers on different days.
Measured on 2026-07-17 from a consumer ISP connection, requests paced about 5 seconds apart:
| Request | HTTP | Response | Listings parsed |
|---|---|---|---|
| Browser User-Agent (the script's default) | 200 |
~606 KB | 41, on 7 of 7 attempts |
Stock requests User-Agent (--default-ua) |
403 |
5,840 bytes | 0, on 5 of 5 attempts |
The 403 is the challenge page: its <title> is Access to this page has been denied, the body carries px-captcha and Press & Hold, and there is no listing JSON in it at all. The script measures and prints every one of those rows itself rather than asserting "blocked", so you can reproduce them in about ten seconds and check us.
Read that table as one vantage point, not a survey: one residential IP, one afternoon. The same script, from the same machine, was refused on every attempt on 2026-07-16, browser User-Agent included. It works today. That is the whole character of this problem: a run that works is a sample, not a guarantee.
| What bites you | Why | What it costs you |
|---|---|---|
| The check is not deterministic | Served on 7 of 7 attempts today, refused on every attempt the day before, same script and same connection. | You cannot tell from one green run that a pipeline is stable. You need retries, and alerting that can tell "blocked" from "no results". |
A bare requests.get(url) is refused |
The stock requests User-Agent drew a 5,840-byte challenge on 5 of 5 attempts, while the browser one on the same URL was served. |
The naive DIY scraper gets zero listings, and no warning if it does not check. |
| The JSON path moves | The listing payload sits in an HTML comment under cat1.searchResults.listResults, and that shape changes. A parser walking it returns [] rather than raising. |
Silent empties, ongoing maintenance, and the alerting you need to notice either. |
Before you go shopping for a free alternative: the two most-starred pure Zillow scrapers on GitHub are both long dormant. scrapehero/zillow_real_estate (159 stars) was last pushed 2018-02-26, three days after it was created, and ChrisMuir/Zillow (170 stars) last moved in June 2019 and drives Selenium. We did not re-run either, so that is a statement about their maintenance, not their behaviour. The script in this repo was run on the date at the top of this page.
The managed option, and the one this repo is built around. Two endpoints for Zillow real estate data extraction at scale (for-sale search listings and full property pages), a ~99% success rate against the bot check, and no proxy management.
The difference on the same search is the payload: 32 fields per listing against the free script's 7, plus a property endpoint that returns the full photo set and the named listing agent. Keeping that consistent against the bot check is the part we run so you do not have to, and we do not publish the specifics.
Free for the first 1,000 requests, one-time: Chocodata Zillow Scraper API.
Below is the Zillow Scraper API reference to get you started.
curl "https://api.chocodata.com/api/v1/zillow/search?api_key=YOUR_KEY&location=Schaumburg,%20IL"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/zillow/search",
params={"api_key": "YOUR_KEY", "location": "Schaumburg, IL"},
timeout=90,
)
top = r.json()["results"][0]
print(top["title"], top["price"], top["beds"], top["baths"])
# 705 Partridge Dr, Schaumburg, IL 60193 550000 3 3After running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
Accepted by every endpoint below:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
country |
string (ISO-2) | no | us |
Egress location for the request. |
And one that is not global:
| Param | Type | Endpoint | Default | Description |
|---|---|---|---|---|
add_html |
boolean | /property only |
false |
Also return the raw upstream HTML alongside the parsed JSON, under an html key (debugging). On a property page that added ~533 KB to the response. /search does not accept it: pass it there and it is silently dropped, with no html key and no error. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or the wrong type. Body lists the exact issue and path. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade at chocodata.com. |
404 |
item_not_found |
The target returned 404: the zpid/URL does not exist. retryable: false. |
no | Fix the zpid. Retrying will not help. |
429 |
RATE_LIMITED |
Over 120 requests/minute on this key. | no | Back off; see Rate limits. |
429 |
CONCURRENCY_LIMIT |
Over your plan's in-flight cap. Body carries used and limit. |
no | Lower your worker count; see Rate limits. |
502 |
target_unreachable |
Zillow refused every attempt for this request. retryable: true. |
no | Retry. |
Two response shapes exist: auth, billing and limit errors nest under error.code (uppercase), while validation and scrape-layer errors are flat with a lowercase error string. Scrape-layer errors also carry retryable, which tells you whether a retry is worth attempting. One of each is shown below.
The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/zillow/search?api_key=totally_invalid_key_123&location=Seattle,%20WA"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A missing required param, verbatim. It names the exact field rather than making you guess:
{"error":"invalid_params","issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["location"],"message":"Required"}]}A dead zpid returns 404 with error: "item_not_found" and retryable: false, and the message says so in as many words: "The target returned 404 for this request - the item or identifier does not exist. Check the id or URL. You were not charged." Retrying will not help, and nothing is billed.
Two independent limits, and they fail with the same status but different codes:
| Limit | Value | Code on breach |
|---|---|---|
| Requests per key | 120 per 60s (sliding window) | 429 RATE_LIMITED with retry_after_ms |
| Concurrent requests in flight | Free 10, Vibe 30, Pro 50, Custom 100+ | 429 CONCURRENCY_LIMIT with used and limit |
Rate-limited responses carry Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Neither breach is billed.
Every endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. Requests can take several seconds when Zillow's bot check forces us to re-attempt (see Measured latency), which is why the examples use timeout=90.
Sizing: the 120/minute ceiling binds before concurrency does on every plan, so 2 requests/second sustained is the realistic planning number regardless of tier; concurrency decides how bursty you can be within that. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(loc):
r = requests.get("https://api.chocodata.com/api/v1/zillow/search",
params={"api_key": KEY, "location": loc}, timeout=90)
return loc, (r.json().get("results", []) if r.ok else [])
locations = ["Schaumburg, IL", "Seattle, WA", "Austin, TX", "Denver, CO", "Boise, ID"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for loc, results in pool.map(one, locations):
print(loc, len(results))Zillow's for-sale results for a location: address, price, beds, baths, sqft, home type, broker and coordinates per listing.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
location |
string | yes | - | The place to search: city/state (Schaumburg, IL), ZIP, or neighborhood. |
page |
int (1-20) | no | 1 |
Result page. Pages do not overlap: in a run on 2026-07-16, page 1 and page 2 of Seattle, WA returned 41 distinct listings each with zero shared zpids. |
limit |
int (1-500) | no | 100 |
Cap the rows returned. Applied after the fetch, so it saves you parsing, not credits. A page holds ~41, so the default rarely binds. |
curl "https://api.chocodata.com/api/v1/zillow/search?api_key=YOUR_KEY&location=Schaumburg,%20IL"Real response. results is cut to 1 of 41; the result object itself is complete, all 32 fields verbatim (full sample):
{
"query": "Schaumburg, IL",
"location": "Schaumburg, IL",
"page": 1,
"total_results": 41,
"results_count": 41,
"results": [
{
"position": 1,
"id": "3438699",
"title": "705 Partridge Dr, Schaumburg, IL 60193",
"url": "https://www.zillow.com/homedetails/705-Partridge-Dr-Schaumburg-IL-60193/3438699_zpid/",
"price": 550000,
"thumbnail": "https://photos.zillowstatic.com/fp/515529ca40b496fe155ab0dd6da7c0a3-p_e.jpg",
"beds": 3,
"baths": 3,
"sqft": 2351,
"zpid": "3438699",
"address_street": "705 Partridge Dr",
"address_city": "Schaumburg",
"address_state": "IL",
"address_zip": "60193",
"zestimate": null,
"broker_name": "RE/MAX Liberty",
"status": "FOR_SALE",
"status_text": "New",
"home_type": "SINGLE_FAMILY",
"lot_area": null,
"latitude": 42.003365,
"longitude": -88.06319,
"photos": [],
"has_image": true,
"has_video": false,
"has_open_house": null,
"open_house_start": null,
"open_house_end": null,
"open_house_description": null,
"builder_name": null,
"provider_listing_id": null,
"street_view_url": null
}
]
}zpid is the field most people come for: it is Zillow's stable property identifier, it is what the property endpoint takes, and it is the join key that lets you track one home across time even as its price and status change. status_text ("New") is Zillow's own freshness badge and is the cheapest new-listing signal in the payload.
Reading the fields. This endpoint reads Zillow's for-sale results page, and the shape of that page shows through:
total_resultsis the number of rows we returned, not Zillow's match count. It equalsresults_count. Passlimit=3andtotal_resultsbecomes 3. It is not the "1,247 homes for sale" figure from Zillow's own header, so do not build a market-size metric on it.- Field coverage, verbatim. On these 41 rows:
zestimatecame back on 11,broker_nameon 39,latitude/longitudeon 39,has_open_houseon 8.photos,lot_area,builder_nameandprovider_listing_idwere null or empty on all 41: the search surface does not carry them. Photos live on the property endpoint below. - Undisclosed addresses are real, and they are where the nulls cluster. Row 7 of this capture is literally
(undisclosed Address), Schaumburg, IL 60193. That is Zillow's own text, passed through, not a parse failure, and rows 7 and 21 are exactly the two with no coordinates: Zillow withholds the lat/long when it withholds the address. - One field lies about being empty. Row 40 (
2845 Meadow Ln #1V) hassqft: 0, notnull. It is a falsy placeholder rather than a missing value, so aprice / sqftcalculation over this array divides by zero rather than skipping the row. Guard onif not sqft, not onif sqft is None. - A missing photo becomes a Google image. Row 23 has
has_image: false, and Zillow fills the gap with a Google static map, sothumbnailandstreet_view_urlon that row point atmaps.googleapis.comrather than at the home. Those two URLs are the one place the committed sample is redacted (see the top of this page).
Runnable: zillow_scraper_api_codes/search.py
Full property record by zpid or URL: address, price, price per sqft, living area, year built, description, every listing photo, and the named listing agent.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
zpid |
string (digits) | one of zpid/url |
- | Zillow property id (e.g. 3438699). Builds /homedetails/{zpid}_zpid/. |
url |
string (URL) | one of zpid/url |
- | Full Zillow /homedetails/ URL. |
curl "https://api.chocodata.com/api/v1/zillow/property?api_key=YOUR_KEY&zpid=3438699"Real response. images cut to 2 of 44 and description truncated where marked; every field and key is verbatim, none removed (full sample):
{
"name": "705 Partridge Dr, Schaumburg, IL, 60193",
"address": {
"address_country": null,
"address_locality": "Schaumburg",
"address_region": "IL",
"postal_code": "60193",
"raw": "705 Partridge Dr, Schaumburg, IL, 60193",
"street_address": "705 Partridge Dr"
},
"location": { "city": "Schaumburg", "region": "IL" },
"area": { "raw": "2,351 sqft", "value": 2351, "unit_code": "sqft" },
"trade_info": [
{
"currency": "USD",
"price": 550000,
"price_per_area_unit": 234,
"trade_type": "sale"
}
],
"property_type": "house",
"listing_type": "sale",
"availability_status": "available",
"description": "Lovely 3-bedroom, 2.1-bath home with the potential to create a 4th bedroom by converting the sit...",
"year_built": 1983,
"number_of_rooms": 3,
"rooms": [
{ "count": 3, "room_type": "bedroom" },
{ "count": 3, "room_type": "bathroom" }
],
"images": [
"https://photos.zillowstatic.com/fp/515529ca40b496fe155ab0dd6da7c0a3-p_d.jpg",
"https://photos.zillowstatic.com/fp/1f5fcb8f5e562b990c9a1fb31937dcea-p_d.jpg"
],
"main_image": "https://photos.zillowstatic.com/fp/515529ca40b496fe155ab0dd6da7c0a3-p_d.jpg",
"listing_agent": "Denise D'Amico, RE/MAX Liberty",
"url": "https://www.zillow.com/homedetails/705-Partridge-Dr-Schaumburg-IL-60193/3438699_zpid/",
"zpid": "3438699",
"home_status": "FOR_SALE",
"parcel_id": null,
"lot_size": 8712,
"latitude": 42.003365,
"longitude": -88.06319
}trade_info[0].price_per_area_unit (234) is the field that does the most work: it is Zillow's own $/sqft for this home, which is what you actually compare across comps rather than raw price. One gotcha worth catching before it bites: the free-text description says "2.1-bath" while the structured baths field says 3. Both are Zillow's, and they are counting differently (2 full + 1 half). We report each verbatim rather than reconciling them for you.
Running it:
A currently-listed home returns the full record, an off-market one returns a thinner one.
Both zpids return 200 OK and the same 22 keys. A live FOR_SALE zpid fills them: 785 characters of description, 44 listing photos, a named agent. An off-market zpid (home_status: "OTHER") returns the address, the price, the sqft and the year built, and then description: null, listing_agent: null, and a single image that is not a photo of the home at all but a Google Street View shot of the street. Zillow stops publishing listing content once a home is off-market, so there is nothing there to return.
Both samples are committed, so you can diff this claim yourself: property.json (for sale) and property_off_market.json (off market).
home_status is the field to branch on. It carries Zillow's own listing state, FOR_SALE against OTHER, and that is what separates a full record from a thin one. Check it before you trust a record, and treat a thin one as expected rather than as a failed request. The example script branches on it and prints which of the two it got.
Runnable: zillow_scraper_api_codes/property.py
Watching a market for new inventory and price movement is the main reason people scrape Zillow (agent lead flow, investor deal flow, comps tracking), so that use case is in the repo end to end rather than as a snippet. new_listings.py polls a location, stores every observation as a local dataset in SQLite (export it to CSV with one sqlite3 command), and prints what changed since the last run:
That is a real first run: the first pass has nothing to compare against, so it seeds the database and says so. NEW is Zillow's own freshness badge (status_text), FIRST-SEEN is a listing that is not new to the market but is new to your database, and the distinction matters if you are chasing genuinely fresh inventory. Run it again after prices move and each change prints as a diff line (CUT <address> $550,000 -> $525,000 (-4.5%)).
One API call per run, per location. Your one-time 1,000 free requests cover about 41 days of hourly checks on a single market (720 requests is 30 days), and they do not reset.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-16. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/zillow/search |
2.3s | 1.8 to 6.7s | 5 |
/zillow/property |
3.8s | 1.4 to 4.3s | 5 |
Read the ranges, not just the medians. The spread is what Zillow's bot check does to a request: the high end is a call that took more than one attempt upstream before it came back with parsed data, and absorbing that is what the timeout=90 in every example is for. Small sample (n=5), measured from one machine on one afternoon; reproduce it yourself with the scripts here.
MIT. See LICENSE.







