How to scrape Zillow listings into JSON with Python

How to scrape Zillow listings into JSON with Python

Turn a verified Zillow search request into a clean local JSON dataset. This hands-on Python guide covers safe RapidAPI authentication, response validation, field normalization, pagination, ZPID-based deduplication, and production safeguards.

Key Takeaways

  • Call the documented GET /zillow/v1/search endpoint with the required RapidAPI headers and an environment-based key.
  • Validate both HTTP and JSON status before extracting documented listing fields.
  • Paginate with the real page parameter and deduplicate properties by stable zpid.
  • Export a normalized UTF-8 JSON file and add bounded retries, rate-limit handling, timeouts, and redacted logs for production.

What you will build

This guide builds a small, practical data pipeline: request Zillow for-sale listings through the LetsScrape API on RapidAPI, validate the JSON response, keep the fields an application usually needs, remove duplicate properties, and save the normalized records to a local JSON file. The result is suitable for a prototype search index, an analytics notebook, or the input to a larger property-data workflow.

The code uses Python and the lightweight requests package. LetsScrape does not execute the calls on this website: access, authentication, quotas, and billing are handled by RapidAPI. At the time of verification, the provider documentation specifies GET /zillow/v1/search on real-estate-zillow-com.p.rapidapi.com, authenticated with the standard X-RapidAPI-Key and X-RapidAPI-Host headers.

Prerequisites and safe API-key setup

You need Python 3.10 or newer, a RapidAPI account subscribed to the Real Estate Zillow.com API, and the requests package. Install the dependency in a virtual environment so the project remains isolated:

python -m venv .venv
# macOS or Linux
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
python -m pip install requests

Never paste a real key into source code, a screenshot, or a committed configuration file. Store it in an environment variable named RAPIDAPI_KEY. The value below is deliberately not an API-key placeholder; the command reads the secret interactively and keeps it out of shell history:

# macOS or Linux
read -s RAPIDAPI_KEY
export RAPIDAPI_KEY

# Windows PowerShell
$env:RAPIDAPI_KEY = Read-Host "RapidAPI key" -MaskInput

For a deployed application, use the secret manager supplied by the hosting platform. Environment variables are a good local interface, but they should still be populated from a protected secret store in production.

Confirm the subscription in RapidAPI before debugging Python. The marketplace gateway supplies the credentials and enforces the selected plan; this public guide and the LetsScrape API Page are editorial surfaces, not an API console. A missing subscription can look like a code failure even when the URL, host header, and Python syntax are correct.

Make one verified Zillow search request

The search endpoint accepts a Zillow region ID, or rid. The API documentation uses 12700 for Miami, Florida; the companion autocomplete endpoint can resolve other city, ZIP, neighborhood, or address fragments to a region ID. This first request asks for two-or-more-bedroom homes for sale below $500,000, ordered newest first.

import os
import requests

API_URL = "https://real-estate-zillow-com.p.rapidapi.com/zillow/v1/search"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
API_KEY = os.environ["RAPIDAPI_KEY"]

headers = {
    "X-RapidAPI-Key": API_KEY,
    "X-RapidAPI-Host": API_HOST,
}
params = {
    "rid": 12700,
    "type": "sale",
    "page": 1,
    "beds_min": 2,
    "price_max": 500000,
    "sort": "newest",
}

response = requests.get(
    API_URL,
    headers=headers,
    params=params,
    timeout=30,
)
response.raise_for_status()
payload = response.json()
print(payload)

requests URL-encodes the query parameters, while the API key remains in a header rather than appearing in the URL. The explicit timeout prevents a stalled upstream request from blocking the process indefinitely. raise_for_status() turns non-success HTTP responses into exceptions instead of letting the program quietly process an error page as listing data.

Keep the region lookup separate from the repeated listing search. Region IDs are better configuration values than free-form city strings because they remove ambiguity between cities, neighborhoods, and ZIP codes with similar names. Resolve and review a region once, store its ID with a human-readable label, and send that stable value in scheduled collection jobs.

Understand the search parameters

rid identifies the Zillow region and is required unless a full Zillow search url is supplied. type selects sale, rent, or sold; sale is the documented default. page is a one-based page number and is the API's pagination contract. The remaining values are optional filters: beds_min sets the bedroom floor, price_max caps the list price, and sort=newest requests the newest listings first.

The endpoint also documents filters for bathrooms, square footage, lot size, year built, property and listing types, days on Zillow, HOA cost, amenities, views, and rental-specific features. Start with the smallest useful filter set. It is easier to validate coverage and avoid accidentally excluding listings before adding more constraints.

Filter meanings depend on the selected listing type. A maximum price is a sale price for sale but a monthly amount for rent. Rental searches also expose pet, laundry, furnishing, utilities, access, and move-in filters, while sale and sold searches expose lot, construction, HOA, garage, pool, basement, and listing-type controls. Do not blindly reuse a filter dictionary across all three modes; build and test one request profile for each business question.

Read the JSON response without assuming undocumented containers

The current provider overview guarantees a top-level numeric status and a data object. It documents listing fields including zpid, address, price, beds, baths, area, zestimate, daysOnZillow, detailUrl, and coordinates. A live subscribed request was not used while preparing this guide, so the compact response below is representative rather than a captured result. Values are illustrative and the listing collection's container is abbreviated instead of inventing a production key:

{
  "status": 200,
  "data": {
    "...": [
      {
        "zpid": "19977945",
        "address": "Example address, Miami, FL",
        "price": 450000,
        "beds": 3,
        "baths": 2,
        "area": 1450,
        "statusType": "FOR_SALE",
        "daysOnZillow": 4,
        "detailUrl": "https://www.zillow.com/homedetails/19977945_zpid/"
      }
    ]
  }
}

Because RapidAPI's overview does not name the nested collection container, the extraction helper below locates the list by a documented invariant: listing objects contain a zpid. That makes the assumption visible and keeps the rest of the pipeline independent of presentation wrappers. If your subscribed response exposes a stable named path, replace this helper with direct indexing and cover that path with a fixture-based test.

The representative address and numeric values are deliberately not presented as a live home. Real listings change continuously, and copying one captured result into documentation creates a false promise that the same property will still be available. What matters for integration is the verified field vocabulary and wrapper. During development, save one sanitized response from your own subscribed call, remove personal or commercially sensitive fields, and use it as the contract fixture for your application.

Handle success, HTTP errors, and invalid JSON

Successful transport is only the first check. Validate the response wrapper before walking the listing data, and report failures without logging headers. In particular, never include the request object's headers in an exception log because that can disclose the RapidAPI key.

import json
import os
import requests

API_URL = "https://real-estate-zillow-com.p.rapidapi.com/zillow/v1/search"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
API_KEY = os.environ["RAPIDAPI_KEY"]

headers = {
    "X-RapidAPI-Key": API_KEY,
    "X-RapidAPI-Host": API_HOST,
}
params = {"rid": 12700, "type": "sale", "page": 1}

try:
    response = requests.get(
        API_URL,
        headers=headers,
        params=params,
        timeout=30,
    )
    response.raise_for_status()
    payload = response.json()
except requests.Timeout:
    raise SystemExit("The Zillow API request timed out.")
except requests.HTTPError as exc:
    status_code = exc.response.status_code if exc.response else "unknown"
    raise SystemExit(f"The Zillow API returned HTTP {status_code}.")
except requests.RequestException as exc:
    raise SystemExit(f"The Zillow API request failed: {type(exc).__name__}.")
except json.JSONDecodeError:
    raise SystemExit("The Zillow API response was not valid JSON.")

if payload.get("status") != 200 or not isinstance(payload.get("data"), dict):
    raise SystemExit("The Zillow API returned an unexpected response wrapper.")

HTTP 401 or 403 usually points to authentication, subscription, or plan access. HTTP 429 means the application has exceeded a RapidAPI limit and should wait rather than retry immediately. Provider documentation also lists 400 for invalid parameters, 404 for a missing region or property, 500 for a scraping failure, and 504 when Zillow takes too long to respond.

There are two layers of status to inspect: the HTTP response from the gateway and the JSON status supplied by the API. A transport-level 200 does not justify accepting a malformed payload, and a valid JSON error is not listing data. Keep both checks. When reporting an error, include a correlation ID from your own job, the HTTP status, the page number, and elapsed time. Exclude the API key, complete headers, and raw response bodies from routine logs.

Extract and normalize useful listing fields

Keep raw responses while developing, but give downstream code a smaller contract. The helper recursively finds a list containing at least one dictionary with a non-empty zpid. The normalizer then selects documented fields and preserves absent values as None instead of fabricating defaults.

from typing import Any

def find_listings(node: Any) -> list[dict[str, Any]]:
    if isinstance(node, list):
        dictionaries = [item for item in node if isinstance(item, dict)]
        if any(item.get("zpid") for item in dictionaries):
            return dictionaries
        for item in dictionaries:
            found = find_listings(item)
            if found:
                return found
    elif isinstance(node, dict):
        for value in node.values():
            found = find_listings(value)
            if found:
                return found
    return []

def normalize_listing(item: dict[str, Any]) -> dict[str, Any]:
    return {
        "zpid": str(item["zpid"]),
        "address": item.get("address"),
        "price": item.get("price"),
        "beds": item.get("beds"),
        "baths": item.get("baths"),
        "area": item.get("area"),
        "lot_area_value": item.get("lotAreaValue"),
        "lot_area_unit": item.get("lotAreaUnit"),
        "zestimate": item.get("zestimate"),
        "rent_zestimate": item.get("rentZestimate"),
        "listing_type": item.get("listingType"),
        "status_type": item.get("statusType"),
        "days_on_zillow": item.get("daysOnZillow"),
        "broker_name": item.get("brokerName"),
        "image_url": item.get("imgSrc"),
        "detail_url": item.get("detailUrl"),
        "latitude": item.get("latitude"),
        "longitude": item.get("longitude"),
    }

zpid is the stable Zillow Property ID documented by the API, so it is the correct deduplication key. Convert it to a string: an identifier should not be treated as a number used in arithmetic, and a string remains safe if an upstream system ever adds leading zeroes.

Normalization is also the right boundary for type and naming decisions. The API uses camelCase field names, while the example output uses snake_case to match common Python conventions. Preserve monetary values as numbers and keep the source URL alongside each record. Avoid converting a missing bedroom count to zero or an absent Zestimate to the list price: those substitutions hide data-quality differences that downstream users may need to see.

Paginate, deduplicate, and export to JSON

Pagination is performed by incrementing the real page query parameter. Stop when a page contains no listings or contributes no new ZPIDs. The second condition prevents an endless loop if an upstream page is repeated. A configurable page ceiling also makes usage and cost predictable.

import json
import os
from pathlib import Path
from typing import Any

import requests

API_URL = "https://real-estate-zillow-com.p.rapidapi.com/zillow/v1/search"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
API_KEY = os.environ["RAPIDAPI_KEY"]
OUTPUT_PATH = Path("zillow_listings.json")
MAX_PAGES = 10

def find_listings(node: Any) -> list[dict[str, Any]]:
    if isinstance(node, list):
        dictionaries = [item for item in node if isinstance(item, dict)]
        if any(item.get("zpid") for item in dictionaries):
            return dictionaries
        for item in dictionaries:
            found = find_listings(item)
            if found:
                return found
    elif isinstance(node, dict):
        for value in node.values():
            found = find_listings(value)
            if found:
                return found
    return []

def normalize(item: dict[str, Any]) -> dict[str, Any]:
    return {
        "zpid": str(item["zpid"]),
        "address": item.get("address"),
        "price": item.get("price"),
        "beds": item.get("beds"),
        "baths": item.get("baths"),
        "area": item.get("area"),
        "status_type": item.get("statusType"),
        "days_on_zillow": item.get("daysOnZillow"),
        "detail_url": item.get("detailUrl"),
    }

headers = {
    "X-RapidAPI-Key": API_KEY,
    "X-RapidAPI-Host": API_HOST,
}
base_params = {
    "rid": 12700,
    "type": "sale",
    "beds_min": 2,
    "price_max": 500000,
    "sort": "newest",
}

by_zpid: dict[str, dict[str, Any]] = {}
with requests.Session() as session:
    for page in range(1, MAX_PAGES + 1):
        response = session.get(
            API_URL,
            headers=headers,
            params={**base_params, "page": page},
            timeout=30,
        )
        response.raise_for_status()
        payload = response.json()

        if payload.get("status") != 200:
            raise RuntimeError(f"Unexpected API status on page {page}.")

        listings = find_listings(payload.get("data", {}))
        if not listings:
            break

        before = len(by_zpid)
        for item in listings:
            if item.get("zpid"):
                normalized = normalize(item)
                by_zpid[normalized["zpid"]] = normalized
        if len(by_zpid) == before:
            break

OUTPUT_PATH.write_text(
    json.dumps(list(by_zpid.values()), indent=2, ensure_ascii=False),
    encoding="utf-8",
)
print(f"Saved {len(by_zpid)} unique listings to {OUTPUT_PATH}")

The dictionary assignment deliberately keeps the newest copy encountered for a ZPID. With sort=newest, that is a sensible default for a single run. For a historical pipeline, append timestamped snapshots instead and deduplicate only within each collection batch.

The loop does not depend on an invented totalPages property. It follows the documented page input and derives completion from actual results. That is useful when inventory changes between requests: a property may move from one page to another while the job is running. Deduplication absorbs the overlap, the empty-page stop prevents unnecessary calls, and MAX_PAGES bounds the worst-case request count. If completeness matters more than freshness, collect during a quiet window and record the start and finish timestamps with the exported batch.

Write to a temporary file and rename it atomically if another process reads the export continuously. The compact example writes directly to zillow_listings.json, which is appropriate for a local one-shot script. A scheduled service should also include a collection timestamp, region ID, filters, and schema version in a small manifest so a future reader can reproduce what the file represents.

Production checklist

  • Timeouts: keep an explicit connect/read timeout and make it shorter than the surrounding job deadline.
  • Retries: retry transient 429, 500, and 504 responses with capped exponential backoff and jitter; do not retry invalid parameters or authentication failures.
  • Rate limiting: honor the subscribed RapidAPI plan, cap MAX_PAGES, and avoid parallel bursts that consume the allowance unexpectedly.
  • Logging: record endpoint name, page, status code, duration, and result count, but redact API keys, request headers, and sensitive query input.
  • Validation: store a sanitized response fixture and test the wrapper, ZPID extraction, normalization, and empty-page behavior before deploying.
  • Observability: alert on repeated empty results, schema drift, elevated latency, and a sustained increase in 429 or 5xx responses.

Retries deserve special care because every additional request can consume quota. Use a small maximum attempt count, honor a Retry-After header when present, and add random jitter so multiple workers do not retry simultaneously. Make retry policy observable: a job that succeeds only after repeated 500 responses is operationally different from a clean first-attempt success.

Build on the Zillow API Page

You now have a bounded Python collector that authenticates safely, follows the documented page contract, deduplicates by ZPID, and produces a compact local JSON file. Review filters, current plans, and the API's broader property and market-data capabilities on the Zillow Real Estate API Page, then adapt the normalized schema to the application you are building.