~ / guides / How to Scrape Zillow (Python Guide)

How to Scrape Zillow (Python Guide)

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • Zillow's listing data lives in a __NEXT_DATA__ JSON blob in the page HTML, so a requests + BeautifulSoup scraper can parse it without a headless browser when the request gets through.
  • I ran the code from a clean datacenter IP in June 2026 and got HTTP 200 with 41 real listings on page one (Zillow reported 2,787 total for Seattle). The same script repeated 8/8 times that session.
  • That success is fragile. Zillow runs PerimeterX/HUMAN bot defense, and the same code at volume or from flagged IPs returns a press-and-hold challenge instead of data. Treat single-IP success as luck, not a pipeline.
  • To scale, route through a scraper API. ChocoData has a dedicated Zillow endpoint (/zillow/search by location) plus a universal endpoint that takes any Zillow URL.

Zillow is the obvious target for real-estate data: list price, beds, baths, square footage, lot size, days on market, and the Zestimate, across millions of US listings. It is also wrapped in PerimeterX (HUMAN) bot defense, so the usual question is whether a plain Python scraper stands a chance. This guide builds a real one, runs it against a live Zillow search page, and shows what came back. I ran the code in June 2026 and pasted the real output below, including the parsed listings. Then I cover the route that holds up when one IP is not enough.

You can request a public Zillow page and parse it, and I did exactly that below, but Zillow’s terms prohibit automated access and its bot defense blocks scrapers at scale. The legality splits into two layers worth keeping separate.

On US computer-access law, reading pages anyone can load without logging in sits on the safer side. In hiQ Labs v. LinkedIn, the Ninth Circuit held that scraping public data does not violate the Computer Fraud and Abuse Act, and the Supreme Court’s Van Buren decision narrowed the CFAA toward genuine hacking. Fetching a public listing page with a script is not, by itself, a federal computer crime.

Contract law is the live constraint. Zillow’s Terms of Use prohibit using “any robot, spider, scraper or other automated means to access the Services for any purpose without our express written permission,” and separately bar tools that “access, acquire, copy or duplicate” data similar to Zillow’s. Its robots.txt disallows /api/, /graphql/, and most of /homes/ while allowing a narrow set of for-sale and for-rent search paths. robots.txt carries no legal force on its own, yet ignoring it serves as evidence of intent in a dispute and breaches the terms that reference it. Stay on public pages, never script a logged-in account, avoid scraping personal data such as agent contact details (which pulls in privacy law), and do not redistribute the Zestimate or copyrighted listing photos. I am a tester, not a lawyer, so get advice before a commercial scrape. My is web scraping legal pillar covers the full picture.

What data is available on a Zillow listing?

A for-sale search page carries most of the commercially useful fields, and the good news is they sit in structured JSON, not scattered HTML. Here is what each result object holds and the JSON key it lives under:

FieldJSON keyExample (from my run)
Property IDzpid49142999
Full addressaddress6506 17th Avenue NE, Seattle, WA 98115
List priceprice$1,000,000
Bedroomsbeds3
Bathroomsbaths2
Living area (sq ft)area1930
Home typehdpData.homeInfo.homeTypeSINGLE_FAMILY
Listing statusstatusTypeFOR_SALE
Detail page URLdetailUrl/homedetails/...
Primary photoimgSrcimage CDN URL
Latitude / longitudehdpData.homeInfo.latitude / longitudenumeric

Zillow ships this as a Next.js app, so the search results are embedded in a <script id="__NEXT_DATA__"> JSON blob in the page HTML. That is the key to scraping it cleanly: you parse JSON, not brittle CSS selectors. The list of homes lives at props.pageProps...cat1.searchResults.listResults inside that blob.

How does Zillow block scrapers?

Zillow runs PerimeterX (now HUMAN Security) bot mitigation, which scores every request and serves a press-and-hold challenge when the score is too bot-like. The signals it weighs:

When the score trips, you get a 200 response whose body is a challenge page (look for px-captcha or a press-and-hold prompt), not listings. So checking only the status code misleads you here: a 200 can still be a block. The honest framing from my own test is in the next section.

Method 1: scrape Zillow with requests and BeautifulSoup (what really happened)

A requests GET with a browser User-Agent plus BeautifulSoup to parse the __NEXT_DATA__ JSON is the right first attempt on Zillow, and from a clean IP it worked for me. Here is the exact code I ran against a live Seattle for-sale page:

import json
import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
}

url = "https://www.zillow.com/homes/for_sale/Seattle-WA/"
resp = requests.get(url, headers=HEADERS, timeout=25)
print("STATUS:", resp.status_code)
print("BYTES :", len(resp.content))

soup = BeautifulSoup(resp.text, "html.parser")

# A press-and-hold challenge also returns 200, so check the body, not the code.
if "px-captcha" in resp.text or "Press &amp; Hold" in resp.text:
    raise SystemExit("Blocked: PerimeterX challenge instead of listings")

blob = soup.find("script", id="__NEXT_DATA__")
data = json.loads(blob.string)

# Walk the Next.js tree to the results array (key path shifts between builds,
# so search for listResults rather than hard-coding the full path).
def find_first(obj, key):
    if isinstance(obj, dict):
        if key in obj:
            return obj[key]
        for v in obj.values():
            hit = find_first(v, key)
            if hit is not None:
                return hit
    elif isinstance(obj, list):
        for item in obj:
            hit = find_first(item, key)
            if hit is not None:
                return hit
    return None

listings = find_first(data, "listResults") or []
print("LISTINGS:", len(listings))

for home in listings[:5]:
    print(f"{home['zpid']:>9} | {home['price']:>11} | "
          f"{home.get('beds','?')}bd/{home.get('baths','?')}ba | "
          f"{home['address']}")

This is the real output I got (one representative run; I repeated the request eight times in the same session and all eight returned data):

STATUS: 200
BYTES : 643652
LISTINGS: 41
 49142999 |  $1,000,000 | 3bd/2ba | 6506 17th Avenue NE, Seattle, WA 98115
 48735591 |  $2,995,950 | 4bd/4ba | 12582 Riviera Place NE, Seattle, WA 98125
 48993891 |  $1,075,000 | 3bd/2ba | 7509 42nd Avenue NE, Seattle, WA 98115
 48751263 |    $550,000 | 3bd/1ba | 10615 1st Avenue S, Seattle, WA 98168
 48750135 |    $749,950 | 3bd/2ba | 3511 S Ferdinand Street, Seattle, WA 98118

Page one returned 41 listings, and the JSON also reported totalResultCount: 2787 for Seattle, which is Zillow paginating the rest behind ?page= URLs. The parse is clean because the data is JSON: no fighting CSS selectors, just key lookups.

Now the honest caveat. I ran this from a clean datacenter IP at low volume, and it passed 8/8 that session. That is not a guarantee. PerimeterX is probabilistic, and the same code from a flagged IP, at higher rate, or on a bad day returns the press-and-hold challenge (a 200 with no listResults) instead of homes. I did not get blocked in this test, so I am reporting success, but I would not build a production pipeline on a bare requests call. The moment you page through thousands of results or run from a cloud box doing this repeatedly, the block rate climbs. That is the problem Method 2 solves.

Paging and scaling the DIY version

To collect more than the first 41 results you append ?page=2, ?page=3, and so on, up to the totalResultCount. This is exactly where DIY breaks down: dozens of sequential requests from one IP is the pattern PerimeterX is built to catch. To push the success rate up yourself you would add rotating residential proxies, randomized pacing, and a real browser engine (Playwright) to carry the _pxAppId sensor, which is most of what a scraper API already bundles. Before going down the proxy rabbit hole, read how to scrape without getting blocked.

Method 2: scrape Zillow with a scraper API

When one IP is not enough, a scraper API runs each request through a rotating residential IP and a real browser engine, so Zillow sees a browser-shaped connection and the block rate drops. ChocoData is the service I use for this category. It exposes a universal endpoint (one URL for any site) plus 453 dedicated endpoints across 235 sites, and Zillow is one of the dedicated ones.

Because there is a named Zillow endpoint, the cleanest route is to hand it a location and let it return structured listings, rather than fetching and parsing HTML yourself. Per ChocoData’s docs, the dedicated endpoint is https://api.chocodata.com/api/v1/zillow/search:

import requests

API_KEY = "cd_live_YOUR_KEY"  # free tier: 1,000 requests/month, no card

endpoint = "https://api.chocodata.com/api/v1/zillow/search"
params = {
    "api_key": API_KEY,
    "location": "Seattle, WA",
    "status": "for_sale",   # for_sale | for_rent | sold
    "page": 1,
    "country": "us",        # US residential egress
}

resp = requests.get(endpoint, params=params, timeout=90)
print("STATUS:", resp.status_code)
# resp.json() -> structured listing fields (address, price, beds, baths, zpid, ...)

If you need a property detail page or a search variant the dedicated endpoint does not cover, the universal endpoint takes any Zillow URL and returns the page, and you parse __NEXT_DATA__ from the result exactly as in Method 1:

import requests

API_KEY = "cd_live_YOUR_KEY"
target = "https://www.zillow.com/homes/for_sale/Seattle-WA/"

endpoint = "https://api.chocodata.com/api/v1/universal/get"
params = {
    "api_key": API_KEY,
    "url": target,
    "parse": "html",   # raw rendered HTML; then BeautifulSoup as in Method 1
    "country": "us",
}

resp = requests.get(endpoint, params=params, timeout=90)
print("STATUS:", resp.status_code)

The difference from Method 1 is what happens behind that one call. ChocoData fetches through a country-matched residential IP that rotates automatically and retries through a fresh IP when a block appears, which is the fragility I flagged in Method 1: a single IP that works until PerimeterX scores it. Per ChocoData’s docs, the free tier is 1,000 requests per month (5,000 credits) with no card, and a standard request costs 5 credits against successful responses, so a press-and-hold block does not quietly drain your budget. I did not paste live API output here because it requires a paid key, and I will not fake numbers. The endpoint paths, parameters, and free-tier limits above come from the ChocoData docs. The honest claim is narrow: a rotating-residential API removes the single-IP fragility that makes the DIY path unreliable at scale; I have not independently benchmarked their Zillow success rate, so test it on the free tier.

Which method should you use?

Use DIY for a one-off pull of a few pages; use the API the moment you need volume or reliability. Here is the trade-off based on what I actually observed:

ApproachWhat you getCostReliability at scaleBest for
DIY requests + BS4200 + parsed JSON (my test: 41 listings)FreeLow: one flagged IP and you get challengedA few pages, learning, prototyping
Scraper API (dedicated)Structured listings by locationFree tier, then per requestHigh: rotating residential IPsProduction pulls, many cities/pages
Scraper API (universal)Any Zillow URL, rendered HTMLFree tier, then per requestHighDetail pages, sold pages, gaps

My honest take from the test: a plain Python scraper does work on Zillow today, which is a different result from sites that block at the first request, and the __NEXT_DATA__ JSON makes parsing pleasant. The catch is that the success I measured came from one clean IP at low volume, and PerimeterX is designed to break exactly the scaled, repeated access a real project needs. For a quick comp pull, DIY is fine and free. For anything that pages through thousands of listings or runs on a schedule, route it through a scraper API so a single blocked IP does not stop the job.

To go deeper on the building blocks, see the Python web scraping guide for the language fundamentals, BeautifulSoup for parsing the JSON blob, and Scrapy if you are crawling many cities and want a framework with built-in throttling. The web scraping pillar ties the whole workflow together.

FAQ

Does Zillow have an official API for listing data?

Not for general public listing search. Zillow's Bridge API and the ShowingTime+ data feeds serve MLS and partner data under contract and approval, not a free key for scraping search results. For ad-hoc listing data the practical routes are parsing the public page yourself or a scraper API.

Why does my Zillow scraper work once and then start failing?

PerimeterX (HUMAN) scores each request on IP reputation, TLS fingerprint, and behavior. A first request from a clean IP often passes, but repeated hits, datacenter IP ranges, or a missing browser fingerprint trip the score and Zillow swaps the listing page for a press-and-hold challenge. Rotating residential IPs and pacing requests is what keeps the success rate up.

Can I scrape Zillow sold prices and Zestimates?

Sold-listing pages and the Zestimate render on public property pages, so the same __NEXT_DATA__ parse applies when you can load the page. They sit behind the same bot wall as search, and the Zestimate is Zillow's proprietary estimate, so check the terms before redistributing it.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.