HasData
Back to all posts

Zillow Scraper in Python 2026 (requests, Playwright, and the Zillow API)

Zillow is the largest single source of US real-estate data, a database Zillow itself puts above 160 million homes, active and off-market alike. It also defends that data with one of the most recognizable anti-bot checks on the web, and any Python scraper meets the check before it meets the data. We measured four collection routes against live Zillow pages for this guide, so the advice below comes with numbers, what the Press and Hold wall stops, where the listing fields sit in the page source, and what actually returned data.

The Press and Hold Wall

The first thing a Zillow scraper meets is not HTML. It is a screen that says “Press & Hold to confirm you are a human”, served by PerimeterX (now HUMAN Security), and it fires before any listing data reaches the client. The check scores the IP address, the TLS handshake, and the browser fingerprint, so the outcome depends less on your code than on how those three look to the scoring service.

We ran four collection routes against live Zillow URLs to see which of them returns data at all, and the results table sits further down. The short version is that no local route got a listing through from an ordinary IP address, and the check fired on the first request for two of the three.

That splits the work in two. Getting a real page is an access problem, and parsing one is easy once something has fetched it, so this guide handles them separately.

Setup

The scripts below need requests for the fetching and playwright only for the browser section:

pip install requests playwright
playwright install chromium

The API calls read the key from the environment, so export it once rather than pasting it into the file:

export HASDATA_API_KEY="your-key"

Everything below reads it from there.

Full Zillow Scraper in Python

If you are just looking for the code and do not need the walk-through, here it is. The transport is one call that returns the rendered page through a residential proxy, and the parsing reads Zillow’s own embedded JSON rather than CSS selectors:

import csv
import json
import os
import re

import requests

API_KEY = os.environ.get("HASDATA_API_KEY", "YOUR-API-KEY")

# 1. get real page HTML (swap in any source of Zillow HTML you have)
resp = requests.post(
    "https://api.hasdata.com/scrape/web",
    headers={"Content-Type": "application/json", "x-api-key": API_KEY},
    json={
        "url": "https://www.zillow.com/portland-or/",
        "proxyType": "residential",
        "proxyCountry": "US",
        "jsRendering": True,
        "outputFormat": ["html"],
    },
    timeout=180,
)
resp.raise_for_status()
html = resp.text

# 2. the page state ships as JSON in __NEXT_DATA__, no CSS selectors involved
blob = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.S)
if not blob:
    raise SystemExit("No __NEXT_DATA__ in the response, got a challenge or an error page")
state = json.loads(blob.group(1))
cards = state["props"]["pageProps"]["searchPageState"]["cat1"]["searchResults"]["listResults"]

# 3. flatten the fields you need and save
rows = [
    {
        "address": c.get("address"),
        "price": c.get("price"),
        "beds": c.get("beds"),
        "baths": c.get("baths"),
        "sqft": c.get("area"),
        "broker": c.get("brokerName"),
        "status": c.get("statusText"),
        "url": c.get("detailUrl"),
    }
    for c in cards
]

with open("zillow.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=rows[0].keys())
    writer.writeheader()
    writer.writerows(rows)

print(f"{len(rows)} listings saved, first: {rows[0]['address']} at {rows[0]['price']}")

On our run this saved 41 listings, which is Zillow’s own page size, out of 2,743 matches spread over 20 pages. Swap the url for any Zillow search page, or swap the transport entirely, since the parsing half works on any source of real Zillow HTML. Why the parsing looks like this is the next section.

jsRendering is the cost lever in that call, and it buys reliability rather than data. The blob is server-rendered, so a fetch without rendering (5 credits against 15) returns it too, but on the measured series a third of unrendered fetches (5 of 15) came back as the challenge page instead, which is exactly what the __NEXT_DATA__ guard above catches. Rendering lifted the same list to 13 of 15, so the flag improves the odds rather than guaranteeing the page, and the guard stays either way. The full ladder sits in the results table below.

Where Zillow Keeps the Data

Zillow runs on Next.js, and every page ships its state as JSON inside a <script id="__NEXT_DATA__"> tag. Everything the page renders, and plenty it never shows, sits there in machine-readable form. On a search page the cards live under props.pageProps.searchPageState.cat1.searchResults.listResults, one object per listing, and reading them takes no selectors at all (html here is the page fetched in the full script above):

import json
import re

blob = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.S).group(1)
cards = json.loads(blob)["props"]["pageProps"]["searchPageState"]["cat1"]["searchResults"]["listResults"]

for c in cards[:3]:
    print(c["addressStreet"], "|", c["price"], "|", c["beds"], "bd", c["baths"], "ba", c["area"], "sqft")

The first three cards from our fetch:

1022 SE 71st Ave | $899,000 | 4 bd 3 ba 2806 sqft
8625 SW 10th Ave | $595,000 | 3 bd 2 ba 2144 sqft
9004 SW Lancelot Ln | $900,000 | 4 bd 3 ba 4097 sqft

One card carries far more than the visible tile:

FieldExample from our run
address, addressStreet, addressCity, addressState, addressZipcode1022 SE 71st Ave, Portland, OR 97215, joined and split
price, unformattedPrice$899,000 and 899000
beds, baths, area4, 3, 2806
statusText, statusType, marketingStatusSimplifiedCdActive, FOR_SALE, For Sale by Agent
brokerNameReal Broker
detailUrl, zpidthe listing page URL and its numeric id
latLonglatitude and longitude as a nested object
imgSrc, carouselPhotosComposablecover photo and the carousel template
hdpData.homeInfo31 more keys per card, taxAssessedValue, lotAreaValue, daysOnZillow, homeType
has3DModel, hasVideo, isZillowOwnedbooleans useful for filtering
contentType, flexFieldTextlisting presentation class, Showcase on this card
rawHomeStatusCd, pgapt, sgaptraw status codes behind the display status

The same cat1 branch carries the paging counters next to the cards, searchList.totalResultCount (2,743 on our fetch), totalPages (20), and resultsPerPage (41), so a crawler can size the job before requesting page two.

Listing pages go deeper. The same __NEXT_DATA__ tag holds a gdpClientCache entry, a JSON string with the complete property record, 1,250 leaf fields on the page we measured, against the 7 fields the old selector-table approach used to pull:

import json
import os
import re

import requests

API_KEY = os.environ.get("HASDATA_API_KEY", "YOUR-API-KEY")

resp = requests.post(
    "https://api.hasdata.com/scrape/web",
    headers={"Content-Type": "application/json", "x-api-key": API_KEY},
    json={
        "url": "https://www.zillow.com/homedetails/6914-SE-83rd-Ave-Portland-OR-97266/53861167_zpid/",
        "proxyType": "residential",
        "proxyCountry": "US",
        "jsRendering": True,
        "outputFormat": ["html"],
    },
    timeout=180,
)
resp.raise_for_status()

blob = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', resp.text, re.S)
state = json.loads(blob.group(1))

# the property record hides one level deeper, as a JSON string inside the page state
cache = json.loads(state["props"]["pageProps"]["componentProps"]["gdpClientCache"])
prop = next(v["property"] for v in cache.values() if isinstance(v, dict) and v.get("property"))

print(prop["streetAddress"], prop["city"], prop["state"], prop["zipcode"])
print(prop["price"], prop["currency"], "|", prop["bedrooms"], "bd", prop["bathrooms"], "ba",
      prop["livingArea"], "sqft")
print("record size:", len(json.dumps(prop)), "chars of JSON")

On our run this printed a five-bedroom Portland listing at 420,000 USD with a 43,565-character property record: the resoFacts MLS block, propertyTaxRate, priceChange, and the photo list. Price history, tax history, and school assignments are not in the page source, and the API section below is where those come from. The selector approach ages badly by comparison. The data-test="property-card-*" attributes that older Zillow tutorials were built on are gone from the current markup, zero occurrences on the page we fetched, while the __NEXT_DATA__ blob has kept its shape.

Zillow search results page for Portland, Oregon, with listing cards over the map

Everything on that rendered page, and the record behind it, is reachable through the one script tag.

Selenium and Playwright on Zillow

A browser solves rendering, and rendering is not the gate here. The minimal Playwright fetch looks like this:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page(locale="en-US")
    page.goto("https://www.zillow.com/portland-or/", wait_until="domcontentloaded")
    page.wait_for_timeout(3000)
    html = page.content()
    browser.close()

print("challenge" if "px-captcha" in html else "page", len(html))

From our network it printed challenge 11704, the Press & Hold page instead of listings, on the first request, and adding the stealth plugin changed nothing across twenty attempts. Selenium without masking fared no better, losing the connection before HTML arrived on 16 of 20 tries. A browser pays off on Zillow only from clean residential IPs with believable profiles, and that is proxy infrastructure rather than code. When a browser does get through, the parsing from the previous section applies unchanged, since page.content() carries the same __NEXT_DATA__ tag.

What Got Through

One pass, one IP, no rotation, polite delays:

RouteAttemptsReturned dataChallenge pagesEmpty documents
requests with browser headers20020, from request 10
Selenium headless, no masking2004, first at request 1416
Playwright with the stealth plugin20020, from request 10
Web Scraping API, no rendering151050
Web Scraping API, rendered151320
Zillow Scraper API151500

The three local routes ran the same 20 URLs (5 search pages, 15 listing pages) with a couple of seconds between requests. The three service legs ran the same 15 listing URLs. The Zillow API takes filters rather than page URLs for search and retried once on a 429, and the Web Scraping API ran twice over the list, once without JS rendering at 5 credits a page and once with it at 15.

Per-request outcomes for six routes against Zillow, from plain requests through the Web Scraping API without and with rendering to the Zillow Scraper API, data versus challenge pages versus empty documents

One network, one IP, no rotation, so the local numbers move with IP reputation and the run cannot say which signal did the blocking. The ordering is the finding. No local route returned a single listing, and for requests and Playwright the challenge came on request one. On the service side the same table reads as a reliability ladder: raw fetches without rendering brought back two thirds of the pages, rendering lifted that to 13 of 15, and the parsed endpoint was the only route that returned all 15.

Ready-Made Routes for Zillow Data

Zillow’s public API is retired. Three routes exist today for anyone who does not want to maintain the fetching layer:

RouteWhat it isWhat it needsWhere it fits
pyzillOpen-source Python library for Zillow search and detail pages, 105 stars, releases through 2025Your own proxy pool, passed as argumentsYou have residential proxies and want code you control
Bridge InteractiveZillow Group’s data-licensing platform for MLS and brokerage feedsPartner approval and an MLS relationship, application through Zillow Group’s data and APIs pageLicensed, contractual access to listing feeds
Zillow Scraper APIParsed JSON per listing or search, proxies and rendering on the service sideAn API keyYou want rows without running infrastructure

A Python client for the third route lives in our zillow-api-python package if you prefer a class over raw requests. The same pattern applies across the cluster, and the Redfin and Airbnb guides run the same split of access and parsing on their own sites.

Zillow Data Through the Scraper API

HasData’s Zillow Scraper API takes a Zillow URL or a set of search filters and returns parsed JSON, with the proxies, rendering, and challenge handling on the service side. In the measurement above it returned a record on all 15 property URLs. Two endpoints cover the ground, listing for search results and property for one home. The product page runs a live request without an account, so you can see the response shape before signing up. An account adds 1,000 free credits and the API key the scripts below read.

HasData dashboard on the API Keys page, with the Default key row highlighted

Both endpoints fit in one script. Filters go on the listing call, and the URL of any row it returns goes straight into the property call:

import csv
import json
import os
import time

import requests

API_KEY = os.environ["HASDATA_API_KEY"]
HEADERS = {"Content-Type": "application/json", "x-api-key": API_KEY}


def get(endpoint, params, tries=4):
    """One GET with a backoff on 429, which is what the plan's concurrency limit returns."""
    for attempt in range(tries):
        r = requests.get(f"https://api.hasdata.com/scrape/zillow/{endpoint}",
                         params=params, headers=HEADERS, timeout=120)
        if r.status_code != 429:
            r.raise_for_status()
            return r.json()
        time.sleep(5 * (attempt + 1))
    r.raise_for_status()


# a filtered search page
data = get("listing", {
    "keyword": "Portland, OR",
    "type": "forSale",
    "sort": "newest",
    "price[min]": 300000,
    "price[max]": 900000,
    "beds[min]": 3,
})

rows = [
    {
        "id": p.get("id"),
        "address": p.get("addressRaw"),
        "price": p.get("price"),
        "beds": p.get("beds"),
        "baths": p.get("baths"),
        "sqft": p.get("area"),
        "broker": p.get("brokerName"),
        "status": p.get("status"),
        "url": p.get("url"),
    }
    for p in data.get("properties", [])
]

if rows:
    with open("zillow_listing.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)

print(f"{len(rows)} listings on the page, {data.get('searchInformation', {}).get('totalResults')} total matches")

# one listing in full: nested objects carry the detail
prop = get("property", {"url": rows[0]["url"]})["property"]
addr = prop.get("address", {})
agent = prop.get("agentInfo", {})
print(addr.get("addressRaw"), addr.get("city"), addr.get("state"), "|", prop.get("price"), prop.get("currency"))
print("built", prop.get("yearBuilt"), "|", prop.get("beds"), "bd", prop.get("baths"), "ba",
      prop.get("area", {}).get("livingArea"), "sqft |", agent.get("agentName"))
print(len(prop.get("priceHistory", [])), "price-history entries,",
      len(prop.get("taxHistory", [])), "tax-history entries,",
      len(prop.get("resoData", {})), "MLS fields")

with open("zillow_property.json", "w", encoding="utf-8") as f:
    json.dump(prop, f, ensure_ascii=False, indent=2)

Every field access goes through .get() on purpose. Listing rows drop keys that do not apply, beds was missing from 4 of the 41 rows on our run and brokerName from one, so bracket access crashes partway through a crawl. The run printed:

41 listings on the page, 1015 total matches
6914 SE 83rd Ave Portland OR | 420000 USD
built 1921 | 5 bd 1 ba 2330 sqft | Leilani Powell
3 price-history entries, 26 tax-history entries, 65 MLS fields

The property response nests its detail in objects rather than flat keys, which is worth knowing before you write the field mapping:

{
  "property": {
    "id": 53861167,
    "url": "https://www.zillow.com/homedetails/6914-SE-83rd-Ave-Portland-OR-97266/53861167_zpid/",
    "status": "FOR_SALE",
    "trueStatus": "Coming Soon/No Showing",
    "price": 420000,
    "currency": "USD",
    "lastSoldPrice": 388440,
    "beds": 5,
    "baths": 1,
    "yearBuilt": 1921,
    "homeType": "SINGLE_FAMILY",
    "daysOnZillow": 0,
    "address": { "addressRaw": "6914 SE 83rd Ave", "city": "Portland", "state": "OR", "zipcode": "97266" },
    "area": { "livingArea": 2330, "livingAreaUnits": "Square Feet" },
    "geo": { "latitude": 45.47261, "longitude": -122.57774 },
    "agentInfo": { "agentName": "Leilani Powell", "agentPhoneNumber": "503-804-0587" },
    "schools": { "elementarySchool": { "name": "Woodmere" } },
    "priceHistory": [ { "date": "2020-09-15", "price": 388440, "event": "sold" } ],
    "taxHistory": [ { "taxPaid": 4787.83, "value": 177690 } ],
    "resoData": { "architecturalStyle": "Craftsman,Traditional", "basementYN": true }
  }
}

Price history, tax history, school assignments, and the MLS resoData block are the fields the page source does not carry, which is the practical difference between parsing __NEXT_DATA__ yourself and calling the endpoint. The full parameter list for both endpoints sits in the docs, including agent-email extraction on the property endpoint, and the scripts also run as-is in a Google Colab folder.

Schemas do move. Our own run returned a different property shape than the one this article published in 2024, with address, area, and agentInfo now nested where they used to be flat keys, so pin the fields you read and check them when a crawl starts producing empty columns.

Using the No-Code Zillow Scraper

The same data is available without code. Log into your HasData account, open the No-Code Scrapers tab, and select the Zillow Real Estate Scraper.

HasData no-code Zillow Real Estate Scraper with location and filter fields for a property search

Setting filters is optional. You can simply enter a location and run the scraper right away. Or, if you prefer, adjust any of the filters to fine-tune your search.

Once the scraper is running, just wait for it to finish. When it’s done, you can download the data in a variety of formats:

Download panel of a finished HasData no-code Zillow run, offering CSV, JSON, and Excel exports

For example, a CSV file might look something like this:

Spreadsheet of scraped Zillow listings with address, price, beds, baths, and broker columns

The image shows part of the output, since a run returns more rows than fit on a screen. This route suits one-time pulls, where writing a script costs more than the data is worth.

Conclusion

Zillow in 2026 is a parsing problem stacked on an access problem, and they need different tools. The parsing side is settled by __NEXT_DATA__, every field the page knows in one JSON blob that outlived the CSS selectors. The access side is what our measurement priced. No local route returned a listing from an unmanaged IP, and the challenge fired from the first request, so route the fetching through infrastructure built for it, the Zillow Scraper API, pyzill with your own proxy pool, or Bridge if you qualify for a license, and keep the parsing yours. The API scripts from this article are also in a Google Colab folder, ready to run, and the ImmobilienScout24 guide covers the same ground for the German market.

Valentina Skakun
Valentina Skakun
Valentina is a software engineer who builds data extraction tools before writing about them. With a strong background in Python, she also leverages her experience in JavaScript, PHP, R, and Ruby to reverse-engineer complex web architectures.If data renders in a browser, she will find a way to script its extraction.
Articles

Might Be Interesting