How to Build a Zillow API Alternative for a Property App
Zillow publishes no listings API. Build a sharded ZIP-level ingestion pipeline that dedupes on zpid and lands a normalised property table your app can query.

Zillow has no public listings API, so a property app has to build one. The pattern that works: shard each metro into ZIP-level queries to beat the roughly 980-listings-per-search ceiling, run the Zillow Suite Scraper once per ZIP and per
searchMode, deduplicate onzpid, and upsert into one normalised property table. This guide shows the sharding logic, theapify-clientintegration, the incremental-load key and the schema you persist.
Why a Zillow API alternative is an ingestion problem, not a request problem
Zillow retired its public property-search API endpoints years ago. What remains for developers is the Zillow Group partner programme, which is gated behind MLS credentials, licensing agreements and a use case Zillow approves. If you are building a buyer-alert product, an investor screener, a valuation tool or an internal brokerage dashboard, that door is usually closed.
The public listing pages are still there, and that is what everyone actually builds on. But treating this as "write a request and parse the HTML" produces a pipeline that dies in week two. The real constraints are structural: Zillow caps how deep it will paginate any single search area, listing data is fragmented across three separate search modes, and the same home shows up in overlapping queries. Those are ingestion-architecture problems.
They matter because search is where housing demand actually happens. The National Association of Realtors reports that 95% of home buyers used online tools during their search, which means your app is competing against the portal your users already know. Stale or partial inventory is immediately obvious to them.
So the job is to design a repeatable loader: shard, fetch, dedupe, normalise, upsert. Get that right once and the data layer stops being the interesting part of your product.
How does this compare to the alternatives?
Three paths get you a property table. Writing your own crawler gives you total control and a permanent maintenance obligation — Zillow changes its payload shape, adds challenges, and your on-call rotation absorbs it. A generic scraping API returns raw HTML or a generic DOM blob, which means you still write and maintain every extraction rule and every normalisation step yourself. A purpose-built Actor returns typed rows with stable field names, so the only code you own is the sharding loop and the upsert.
| Approach | Cost model | Reliability | Setup time | Maintenance |
|---|---|---|---|---|
| DIY Python crawler | Your servers, your proxies | You own every block and payload change | 2-4 weeks | Continuous |
| Generic scraping API | Per request, HTML only | Fetch works, parsing is still yours | 1-2 weeks | You own all extraction rules |
| Zillow Suite Scraper | Transparent pay-per-result | Anti-bot handling and retries built in | Under an hour | Handled upstream |
How to build a Zillow API alternative in 4 steps
How do I shard a metro into ZIP-level queries?
Shard by ZIP code, because Zillow's pagination ceiling applies per search area, not per run. A search for "Austin, TX" reports tens of thousands of matches but hands over roughly 980 rows and stops. Twenty Austin ZIP codes give you twenty independent ceilings.
Build the shard list once and keep it in your own config. Census ZCTA files, an MLS coverage list, or a hand-drawn set of neighbourhoods all work — the Actor accepts ZIP codes, City, ST, neighbourhoods like Williamsburg, Brooklyn, NY, counties like Travis County, TX, full Zillow search URLs, and single /homedetails/ URLs.
# metros.py — your shard registry, versioned in git
METROS = {
"austin": [
"78701", "78702", "78703", "78704", "78705",
"78717", "78719", "78721", "78722", "78723",
"78727", "78728", "78729", "78730", "78731",
"78735", "78739", "78741", "78744", "78745",
"78746", "78748", "78749", "78750", "78751",
],
"beverly_hills": ["90210", "90211", "90212"],
}
def shards(metro: str, batch_size: int = 25) -> list[list[str]]:
"""Zillow caps each search area, so one query per ZIP, batched per run."""
zips = METROS[metro]
return [zips[i:i + batch_size] for i in range(0, len(zips), batch_size)]How do I call the Actor from Python with apify-client?
Use the Apify Python client and pass your ZIP batch as queries. Every input field below comes straight from the Actor's input schema — queries, searchMode, maxResults, sortBy, minPrice, maxPrice, minBeds, minBaths, homeTypes, includeDetails and maxDetailPages.
maxResults is per entry in queries, not per run. Twenty-five ZIPs at 500 each is a 12,500-row ceiling for that batch.
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
def fetch(zip_batch: list[str], mode: str = "forSale") -> list[dict]:
run = client.actor("thirdwatch/zillow-suite-scraper").call(run_input={
"queries": zip_batch,
"searchMode": mode, # forSale | forRent | sold
"maxResults": 500, # per entry in `queries`
"sortBy": "newest",
"minBeds": 2,
"homeTypes": ["houses", "townhomes", "condos"],
"includeDetails": False, # keep the base load cheap and fast
})
return list(client.dataset(run["defaultDatasetId"]).iterate_items())
rows = fetch(["78704", "78745", "78748"])
print(len(rows), rows[0]["zpid"], rows[0]["price_per_sqft"])Keep includeDetails off for the bulk load. It costs one extra property-page visit per listing. Turn it on only for a shortlist, and bound it with maxDetailPages.
How do I cover forSale and forRent in the same pipeline?
Run the same shard list once per searchMode and land everything in one table. The three modes are separate Zillow searches, not filters on one search, so a rental never appears in a forSale response. Because every row carries search_mode and listing_status, one table stays queryable without splitting it.
MODES = ("forSale", "forRent", "sold")
def load_metro(metro: str) -> dict[str, list[dict]]:
out = {}
for mode in MODES:
rows = []
for batch in shards(metro):
rows.extend(fetch(batch, mode=mode))
out[mode] = rows
print(f"{metro} {mode}: {len(rows)} rows")
return outOne caveat worth designing around: apartment complexes come back as a single building row with is_building: true, a rent_min/rent_max range and a units array of {beds, price, price_formatted} — not one row per unit. If your app needs unit-level rentals, expand units on write rather than expecting them as top-level rows.
How do I dedupe on zpid and land a normalised property table?
Make zpid your primary key and upsert. Deduplication inside a single run is already handled — overlapping ZIP codes and neighbourhoods never bill you for the same home twice — but it is per run, so cross-run identity is yours to own.
zpid is stable across price changes, status changes and re-listings, which makes it the correct join key for your own tables and the correct conflict target for incremental loads.
CREATE TABLE properties (
zpid TEXT PRIMARY KEY,
property_url TEXT NOT NULL,
search_mode TEXT NOT NULL,
listing_status TEXT,
home_type TEXT,
address TEXT, city TEXT, state TEXT, zip_code TEXT,
latitude DOUBLE PRECISION, longitude DOUBLE PRECISION,
price NUMERIC, currency TEXT, price_per_sqft NUMERIC,
bedrooms NUMERIC, bathrooms NUMERIC,
living_area_sqft NUMERIC, lot_size_sqft NUMERIC,
zestimate NUMERIC, rent_zestimate NUMERIC,
tax_assessed_value NUMERIC,
days_on_zillow NUMERIC, listed_date TIMESTAMPTZ, date_sold TIMESTAMPTZ,
price_change NUMERIC, price_change_date TIMESTAMPTZ,
source_query TEXT,
scraped_at TIMESTAMPTZ NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON properties (zip_code, search_mode, listing_status);
CREATE INDEX ON properties (scraped_at DESC);How do I run incremental daily loads instead of refetching everything?
Schedule the same shard list daily and upsert on zpid, writing the previous values into a history table before you overwrite. Nothing needs a full refresh — the delta is whatever the upsert changed.
UPSERT = """
INSERT INTO properties (zpid, property_url, search_mode, listing_status,
price, price_per_sqft, zestimate, rent_zestimate, days_on_zillow,
price_change, price_change_date, zip_code, source_query, scraped_at)
VALUES (%(zpid)s, %(property_url)s, %(search_mode)s, %(listing_status)s,
%(price)s, %(price_per_sqft)s, %(zestimate)s, %(rent_zestimate)s,
%(days_on_zillow)s, %(price_change)s, %(price_change_date)s,
%(zip_code)s, %(source_query)s, %(scraped_at)s)
ON CONFLICT (zpid) DO UPDATE SET
listing_status = EXCLUDED.listing_status,
price = EXCLUDED.price,
price_change = EXCLUDED.price_change,
days_on_zillow = EXCLUDED.days_on_zillow,
scraped_at = EXCLUDED.scraped_at
WHERE properties.price IS DISTINCT FROM EXCLUDED.price
OR properties.listing_status IS DISTINCT FROM EXCLUDED.listing_status
RETURNING zpid, price;
"""The WHERE clause on the update turns your upsert into a change feed: anything it returns is a price move or a status change worth notifying on. That is the whole mechanism behind daily price-drop and new-listing monitoring.
What does the output look like?
Rows are normalised before they reach you: prices and areas are numbers rather than strings, timestamps are ISO 8601 UTC, URLs are absolute, lot sizes are converted to square feet, and price_per_sqft is computed. Missing values come back as null instead of a guess.
[
{
"zpid": "20534384",
"property_url": "https://www.zillow.com/homedetails/1740-Carla-Rdg-Beverly-Hills-CA-90210/20534384_zpid/",
"source_query": "90210",
"search_mode": "forSale",
"listing_status": "FOR_SALE",
"status_text": "House for sale",
"home_type": "SINGLE_FAMILY",
"address": "1740 Carla Rdg, Beverly Hills, CA 90210",
"city": "Beverly Hills", "state": "CA", "zip_code": "90210",
"latitude": 34.107277, "longitude": -118.398026,
"price": 10495000, "currency": "USD", "price_per_sqft": 2395.57,
"bedrooms": 4, "bathrooms": 5,
"living_area_sqft": 4381, "lot_size_sqft": 20721,
"zestimate": 9744000, "rent_zestimate": 23360,
"tax_assessed_value": 2875317,
"days_on_zillow": 45, "price_change": -1255000,
"scraped_at": "2026-07-28T04:11:20+00:00"
},
{
"zpid": "2077331185",
"property_url": "https://www.zillow.com/b/the-quincy-austin-tx-Bd7Xqp/",
"source_query": "78704",
"search_mode": "forRent",
"listing_status": "FOR_RENT",
"is_building": true,
"building_name": "The Quincy",
"rent_min": 1495, "rent_max": 3250,
"units_available": 12,
"units": [{ "beds": 1, "price": 1495, "price_formatted": "$1,495/mo" }],
"city": "Austin", "state": "TX", "zip_code": "78704",
"scraped_at": "2026-07-28T04:12:02+00:00"
}
]source_query is the shard that produced the row — keep it, because it tells you which ZIP codes are under-returning. days_on_zillow and price_change are your freshness and movement signals. zestimate and rent_zestimate are what a rental-yield screen divides against price.
Common pitfalls
The ~980-per-search ceiling is real and silent if you ignore it. Zillow returns 41 listings per page and stops after about 24 pages per search area. A single city query will quietly under-deliver against a large maxResults. Shard into ZIPs, neighbourhoods or counties; the Actor logs a warning when a query hits the ceiling, so alert on that log line.
Sold prices are absent in non-disclosure states. Texas, Utah, Idaho, Kansas, Louisiana, Mississippi, Missouri, Montana, New Mexico, North Dakota, Wyoming and Alaska do not publish sale prices, so price is empty on sold rows there. Sold date, address, beds, baths, area, lot size, Zestimate and tax assessed value still arrive — plan your valuation logic around price_per_sqft availability, not around assuming a price.
Field coverage is not uniform. New construction, off-market and agent-withheld listings routinely omit Zestimate, lot size, bathroom counts or photos. Make those columns nullable and never NOT NULL a field you did not verify across a full metro.
Detail mode is a separate cost class. includeDetails: true is one extra page load per listing. Use maxDetailPages to enrich only a shortlist.
A minority of sessions get challenged. The Actor retries on a fresh session with backoff and recovers almost all of them, but an aggressive run can finish a query short of maxResults. Compare returned counts per shard against expectation and re-run the stragglers rather than assuming the ZIP is empty. Inventory is US-only.
Related use cases
- Pull Zillow sold comps for CMA valuation reports — the
soldmode as an appraisal input. - Screen rental yield with Zillow Rent Zestimate — rank hundreds of listings by
rent_zestimateoverprice. - Monitor Zillow price drops and new listings daily — the scheduled diff on top of this pipeline.
- Build real-estate agent lead lists from Zillow — what detail mode unlocks for prospecting.
- Guide to scraping real-estate data — the category pillar.
- All Thirdwatch engineering posts and the Zillow Suite Scraper page.
Frequently asked questions
Is there an official Zillow API for property listings?
No. Zillow retired its public property-search API endpoints and now offers data only through MLS-credentialed partner programs. Public listing pages remain accessible, which is why most property apps build an ingestion pipeline over the public site instead.
Why can I only get about 980 listings per search?
Zillow serves 41 listings per page and stops paginating after roughly 24 pages per search area, regardless of match count. The fix is sharding: pass many narrow queries such as ZIP codes or neighbourhoods instead of one broad city query.
What should I use as the primary key for a property table?
Use zpid, Zillow's stable property identifier. It survives price changes, status changes and re-listings, so it is the correct key for upserts, joins against your own tables and incremental daily loads.
Can one pipeline handle for-sale and rental inventory together?
Yes. Run the same Actor once per searchMode value and land the rows in one table. Every row carries search_mode, so forSale, forRent and sold inventory stay distinguishable inside a single normalised schema.
Related
100 free credits, no credit card.
About 30 real searches. Add the MCP to Claude or Cursor in two minutes.