To scrape Zillow listings into JSON with Python, call GET /v1/search/sale through the Real Estate Zillow.com API on RapidAPI, read the listing array at data.listings, normalize the fields you need, and deduplicate records by zpid. The complete script below paginates with a fixed request ceiling and writes the result to zillow_listings.json.
The request and response shape were tested against Austin, Texas on July 30, 2026. Listing inventory changes continuously, so the observed count is evidence of that test, not a promise about future responses.
How do you turn Zillow listings into JSON with Python?
Send a GET request to https://real-estate-zillow-com.p.rapidapi.com/v1/search/sale, authenticate with the two RapidAPI headers, and pass a location plus a page number. A successful response contains a numeric status and a listing array at data.listings. From there, Python can select a stable set of fields and save them as ordinary JSON.
This is an API workflow, not a browser bot. You will not manage Selenium, cookies, proxies, or Zillow page selectors in the script. RapidAPI handles subscription access and request accounting, while the API provider returns the structured response. The final program in this guide uses one lightweight dependency, requests.
| Stage | Input | Output or check |
|---|---|---|
| Request | Location, page, listing filters | GET /v1/search/sale |
| Validation | HTTP response and JSON envelope | HTTP 200, API status=200, data.listings is a list |
| Normalization | Raw listing objects | A small schema with ZPID, address, price, facts, and source URL |
| Export | Unique normalized records | zillow_listings.json encoded as UTF-8 |
What was verified in the live request?
On July 30, 2026, a subscribed request for Austin, Texas returned HTTP 200 and API envelope status 200. Two runs took 12.5 to 13.2 seconds, and the tested page contained 29 listings. The response included all seven fields needed for the original pipeline: zpid, address, unformattedPrice, beds, baths, area, and detailUrl.
The request used location_or_rid=Austin, TX, listing_types=agent, sort=newest, doz=1, and page=1. An attempted bedroom maximum was not reliably enforced in that test, so this guide applies exact bedroom and price rules in Python. That is a deliberate design choice based on an observation, not a claim that every optional filter is broken.
GET https://real-estate-zillow-com.p.rapidapi.com/v1/search/sale?location_or_rid=Austin%2C%20TX&listing_types=agent&sort=newest&doz=1&page=1
X-RapidAPI-Host: real-estate-zillow-com.p.rapidapi.com
X-RapidAPI-Key: [REDACTED]
This is the corresponding sanitized excerpt. The ZPID and street address are redacted, but the container and field names come from the observed response.
{
"description": "OK",
"status": 200,
"data": {
"listings": [
{
"zpid": "[REDACTED]",
"address": "[REDACTED], Austin, TX 78745",
"unformattedPrice": 425000,
"beds": 2,
"baths": 2,
"area": 1137,
"detailUrl": "https://www.zillow.com/homedetails/[REDACTED]/"
}
]
}
}
Do not write code that expects 29 results. Inventory, ordering, and page contents can change between calls. The useful contract is the response envelope and the listing fields. Keep one sanitized response from your own account as a test fixture so schema changes fail loudly before they reach a scheduled job.
How do you keep the RapidAPI key out of your code?
Create a virtual environment, install requests, and place the key in an environment variable named RAPIDAPI_KEY. RapidAPI's official authentication documentation confirms that gateway requests use X-RapidAPI-Key and X-RapidAPI-Host. Its key management guide also explains how to rotate a key if one is exposed.
python -m venv .venv
# macOS or Linux
source .venv/bin/activate
python -m pip install requests
read -s RAPIDAPI_KEY
export RAPIDAPI_KEY
# Windows PowerShell
.venv\Scripts\Activate.ps1
python -m pip install requests
$env:RAPIDAPI_KEY = Read-Host "RapidAPI key" -MaskInput
For deployment, populate the same variable from the host's secret manager. Do not commit a .env file containing the real value, paste the key into screenshots, or log the request headers. See RapidAPI authentication and key rotation for the marketplace rules.
What is the smallest useful Python request?
The minimal useful call checks the environment before doing network work, sets an explicit timeout, validates the HTTP response, validates the API envelope, and reads the observed listing path directly. It does not recursively hunt for an arbitrary array, because a stable integration should fail when the documented container changes.
import os
import requests
API_URL = "https://real-estate-zillow-com.p.rapidapi.com/v1/search/sale"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
api_key = os.environ.get("RAPIDAPI_KEY")
if not api_key:
raise SystemExit("Set RAPIDAPI_KEY before running this script.")
response = requests.get(
API_URL,
headers={
"X-RapidAPI-Key": api_key,
"X-RapidAPI-Host": API_HOST,
},
params={
"location_or_rid": "Austin, TX",
"listing_types": "agent",
"sort": "newest",
"doz": 1,
"page": 1,
},
timeout=(5, 45),
)
response.raise_for_status()
payload = response.json()
if payload.get("status") != 200:
raise RuntimeError(
f"API status {payload.get('status')}: {payload.get('description')}"
)
listings = payload.get("data", {}).get("listings")
if not isinstance(listings, list):
raise RuntimeError("Expected data.listings to be a list.")
print(f"Received {len(listings)} listings on page 1.")
In the July 30 test, the last line printed a count of 29. A later run can legitimately print a different number. The connect timeout is 5 seconds and the read timeout is 45 seconds, which is long enough to accommodate the observed 12.5 to 13.2 second response without allowing a stalled request to hang forever.
Which JSON fields should you keep?
Keep the smallest schema your application can explain and maintain. For a listing feed, ZPID, address, price, bedrooms, bathrooms, living area, and the source URL are a practical start. Preserve missing values as null rather than turning an unknown bedroom count into zero or substituting one price field for another.
| API field | Normalized field | Decision |
|---|---|---|
zpid | zpid | Convert to string and use as the per-run deduplication key |
address | address | Keep nullable because source records can be incomplete |
unformattedPrice | price | Keep numeric for filtering and analysis |
beds, baths, area | Same names | Do not invent zeroes for absent facts |
detailUrl | source_url | Retain provenance for each record |
ZPID is a property identifier, not a timestamp or a listing event. It works well for removing duplicate properties within this export. If your product needs listing history, store dated snapshots instead of overwriting the previous record. A property can keep the same ZPID while its price or listing status changes.
How do you handle errors without leaking the key?
Check transport and payload separately. raise_for_status() handles HTTP failures from the gateway. The API's numeric status handles a valid JSON response that still represents an application error. Log the endpoint name, page, elapsed time, and status code, but never log the key or the complete headers.
| Signal | Likely action | Retry? |
|---|---|---|
400 or API status=400 | Inspect parameter names and values | No, fix the request |
| 401 or 403 | Check the key, app context, subscription, and plan access | No automatic retry |
| 429 | Honor Retry-After when present and reduce request rate | Yes, with a small cap |
| 500, 502, 503, or 504 | Treat as a transient provider or upstream failure | Yes, with backoff and jitter |
| HTTP 200 with unexpected JSON | Save a redacted sample and investigate schema drift | Not until understood |
RapidAPI documents 429 as the response for exceeding a configured rate limit. It also exposes quota and rate information in response headers for APIs that configure those limits. Do not assume every request is free to retry. Each additional call can count against the selected plan. See the official RapidAPI rate-limit behavior.
How do you paginate, deduplicate, and export JSON?
Increment the real page parameter, stop on an empty page, and stop if a page contains no new ZPIDs. A MAX_PAGES ceiling controls the worst-case request count. The script below filters for exactly two bedrooms and a price from $300,000 through $650,000 in Python because that rule was verifiable in the observed response.
import json
import os
from pathlib import Path
from typing import Any
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
API_URL = "https://real-estate-zillow-com.p.rapidapi.com/v1/search/sale"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
OUTPUT_PATH = Path("zillow_listings.json")
MAX_PAGES = 10
def make_session(api_key: str) -> requests.Session:
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods={"GET"},
respect_retry_after_header=True,
)
session = requests.Session()
session.headers.update({
"X-RapidAPI-Key": api_key,
"X-RapidAPI-Host": API_HOST,
})
session.mount("https://", HTTPAdapter(max_retries=retry))
return session
def fetch_page(session: requests.Session, page: int) -> list[dict[str, Any]]:
response = session.get(
API_URL,
params={
"location_or_rid": "Austin, TX",
"listing_types": "agent",
"sort": "newest",
"doz": 1,
"page": page,
},
timeout=(5, 45),
)
response.raise_for_status()
payload = response.json()
if payload.get("status") != 200:
raise RuntimeError(
f"API status {payload.get('status')} on page {page}: "
f"{payload.get('description')}"
)
listings = payload.get("data", {}).get("listings")
if not isinstance(listings, list):
raise RuntimeError(f"Expected data.listings on page {page}.")
return [item for item in listings if isinstance(item, dict)]
def is_match(item: dict[str, Any]) -> bool:
price = item.get("unformattedPrice")
return (
item.get("beds") == 2
and isinstance(price, (int, float))
and 300_000 <= price <= 650_000
)
def normalize(item: dict[str, Any]) -> dict[str, Any]:
return {
"zpid": str(item["zpid"]),
"address": item.get("address"),
"price": item.get("unformattedPrice"),
"beds": item.get("beds"),
"baths": item.get("baths"),
"area": item.get("area"),
"source_url": item.get("detailUrl"),
}
def main() -> None:
api_key = os.environ.get("RAPIDAPI_KEY")
if not api_key:
raise SystemExit("Set RAPIDAPI_KEY before running this script.")
seen_zpids: set[str] = set()
records: dict[str, dict[str, Any]] = {}
with make_session(api_key) as session:
for page in range(1, MAX_PAGES + 1):
listings = fetch_page(session, page)
if not listings:
print(f"Page {page}: no listings, stopping.")
break
page_zpids = {
str(item["zpid"])
for item in listings
if item.get("zpid") is not None
}
new_zpids = page_zpids - seen_zpids
if not new_zpids:
print(f"Page {page}: no new ZPIDs, stopping.")
break
added = 0
for item in listings:
zpid = str(item.get("zpid"))
if zpid in new_zpids and is_match(item):
records[zpid] = normalize(item)
added += 1
seen_zpids.update(page_zpids)
print(
f"Page {page}: received {len(listings)}, "
f"added {added} matching listings."
)
temp_path = OUTPUT_PATH.with_suffix(".tmp")
temp_path.write_text(
json.dumps(list(records.values()), indent=2, ensure_ascii=False),
encoding="utf-8",
)
temp_path.replace(OUTPUT_PATH)
print(f"Saved {len(records)} unique listings to {OUTPUT_PATH}.")
if __name__ == "__main__":
main()
The first page log from the July test would begin with Page 1: received 29. The number added depends on current inventory and the local price and bedroom rule. This distinction matters: the API returns a market page, while your Python code decides which rows belong in the output dataset.
The temporary file is replaced only after serialization succeeds, which avoids leaving a partially written export if the process stops mid-write. For a long-running history, write each collection to a timestamped file or database table instead. The companion guide, Build a Zillow price-drop monitor with Python and SQLite, shows the snapshot approach.
What should change before production?
- Set a budget: choose
MAX_PAGESfrom the plan allowance and the number of scheduled runs, not from an arbitrary large number. - Keep retries bounded: the example permits three retries for transient statuses and respects
Retry-After. Add random jitter when several workers can run together. - Record useful logs: capture job ID, page, elapsed time, status, and counts. Redact keys, headers, exact addresses, and raw bodies unless a protected diagnostic workflow needs them.
- Test schema drift: run the parser against a sanitized fixture in CI and alert when
data.listingsor required identifiers disappear. - Separate snapshots from entities: ZPID identifies the property, while collection time identifies the observation. Store both if price or status history matters.
- Review data use: check the API provider's current documentation, your RapidAPI plan, Zillow's terms, and the rules that apply to your product before redistributing or retaining data.
For a field-by-field contract review, use Zillow API fields, filters and endpoints explained. It is a better place to decide which endpoint and nullable fields fit a product before extending this collector.
What does this guide not prove?
The test proves that the sale search returned the described envelope and fields on July 30, 2026. It does not prove complete market coverage, fixed response times, permanent field availability, or that every optional query filter is enforced. It also does not test the property-details endpoint. A separate test on the same date observed an API-level error there, so this guide intentionally relies only on sale-search data.
This is an integration guide, not legal advice or permission to use data for every purpose. Zillow is a third-party service and trademark owner. Review the current Zillow terms of use and obtain professional advice when a planned use raises licensing, privacy, or regulatory questions.
Where can you take the pipeline next?
You now have a bounded collector with a known input, an observed response path, local filtering, ZPID deduplication, retries, and an atomic JSON export. Review the Real Estate Zillow.com API page for the current product overview and plans. Then choose whether your next step is a one-off dataset, a searchable application, or a dated monitoring history.