Skip to content

ChocoData-com/zillow-scraper

Repository files navigation

Zillow Scraper

Zillow Scraper

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.py

Those 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:

Retrieved Zillow property data

That is the whole point of this repo. The rest of this page is the free script, then the API reference.


Contents


Free Zillow Scraper

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:

Free Zillow scraper run

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.

Avoid getting blocked when scraping Zillow

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.


Using the Chocodata Zillow Scraper API

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.


Zillow Scraper API reference

Below is the Zillow Scraper API reference to get you started.

Quickstart

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 3

After running the command, your terminal should look something like this:

Running the Zillow Scraper API search endpoint

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model.

Global parameters

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).

Errors

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:

Zillow Scraper API error handling

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.

Rate limits and concurrency

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))

1. Search: property listings, prices, beds and baths

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_results is the number of rows we returned, not Zillow's match count. It equals results_count. Pass limit=3 and total_results becomes 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: zestimate came back on 11, broker_name on 39, latitude/longitude on 39, has_open_house on 8. photos, lot_area, builder_name and provider_listing_id were 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) has sqft: 0, not null. It is a falsy placeholder rather than a missing value, so a price / sqft calculation over this array divides by zero rather than skipping the row. Guard on if not sqft, not on if 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, so thumbnail and street_view_url on that row point at maps.googleapis.com rather 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


2. Property: full property data, photos and listing agent

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:

Zillow property endpoint output

A currently-listed home returns the full record, an off-market one returns a thinner one.

Field coverage: for-sale vs off-market property record

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


Alert on new Zillow listings in a market

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:

Zillow new-listing monitor output

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.


Measured latency

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.


License

MIT. See LICENSE.