The Real Estate Data Stack: Combining Zillow, Redfin, and Realtor.com for Full Market Coverage
No real estate portal has complete listing coverage or every field you need. Here's how to architect a pipeline combining Zillow, Redfin, and Realtor.com data.
The actor referenced in this article. Pay only for results delivered.
Ask three different real estate data vendors for “all active listings in Austin, TX” and you’ll get three different counts. Zillow, Redfin, and Realtor.com each pull from MLS feeds with different sync timing, different market-by-market partnership terms, and different internal filtering — plus each site publishes a different subset of fields on its public pages. There is no single portal with complete coverage and every field you’d want for serious analysis.
If you’re building anything that depends on real estate data at scale — investor deal sourcing, a valuation tool, a lead gen product, a market research dashboard — treating any one portal as ground truth will leave gaps you don’t know about. This post lays out a concrete architecture for combining all three sources into one pipeline.
What each source is actually good for
This isn’t “pick the best one.” Each portal has a genuine strength based on what it publishes, and the right architecture plays to those strengths rather than picking a favorite.
Zillow is the deepest single source for property detail. The Zillow Search Scraper returns for-sale listings by location with price, beds, baths, sqft, Zestimate, rent Zestimate, and days-on-Zillow. Feed its detailUrl or zpid into the Zillow Property Details Scraper and you get price history, tax history, school ratings, HOA fee, parking, heating/cooling, appliances, and up to 10 photos — none of which appear on the search results. No other portal in this stack publishes tax history or school ratings at the property level.
Zillow is also the only source here with dedicated sold-comps and rental actors. The Zillow Recently Sold Scraper filters to homes sold within a chosen window (30/90/180/365 days) and returns soldPrice and soldDate alongside the standard property fields — built specifically for comps and CMA work. The Zillow Rental Listings Scraper covers the for-rent side: monthly rent, availability date, and rental type, useful for underwriting a property’s income potential alongside its sale comps.
Redfin is the fastest-updating source for MLS status fields. The Redfin Scraper returns daysOnMarket, MLS ID, MLS status, and a soldWithinDays filter for its own sold comps, plus listing agent and broker name. Redfin’s MLS integrations tend to reflect status changes (price cuts, pending, sold) quickly, which matters if your use case is time-sensitive market monitoring rather than a point-in-time snapshot.
Realtor.com fills in the two things neither Zillow nor Redfin’s search actor reliably surfaces: county, and a consistently-populated listing agent + brokerage office pair on nearly every listing. The Realtor.com Scraper also has its own soldMode for sold homes with last-sold price and date.
Architecture: pull, normalize, dedupe, cross-reference
A real pipeline for this looks like four stages.
1. Pull
Run all three search-level actors against the same target location on the same day, so you’re comparing inventory snapshots from a consistent point in time rather than drifting dates.
import os
from apify_client import ApifyClient
apify = ApifyClient(os.environ["APIFY_TOKEN"])
LOCATION = "Austin, TX"
zillow_run = apify.actor("themineworks/zillow-search-scraper").call(run_input={
"location": LOCATION, "maxItems": 1000,
})
redfin_run = apify.actor("themineworks/redfin-scraper").call(run_input={
"location": LOCATION, "maxItems": 1000,
})
realtor_run = apify.actor("themineworks/realtor-scraper").call(run_input={
"location": LOCATION, "maxItems": 1000, "soldMode": False,
})
zillow_items = list(apify.dataset(zillow_run["defaultDatasetId"]).iterate_items())
redfin_items = list(apify.dataset(redfin_run["defaultDatasetId"]).iterate_items())
realtor_items = list(apify.dataset(realtor_run["defaultDatasetId"]).iterate_items())
2. Normalize
Each source uses different field names for the same concept (Zillow: zpid/price/sqft, Redfin: propertyId/price/sqft, Realtor.com: propertyId/price/sqft). Map each source’s raw record into one common schema before you do anything else.
def normalize_address(addr, city, state, zipc):
"""Crude normalization: strip unit numbers, lowercase, collapse whitespace."""
import re
base = f"{addr} {city} {state} {zipc}".lower()
base = re.sub(r'[^a-z0-9 ]', '', base)
base = re.sub(r'\s+', ' ', base).strip()
return base
def norm_zillow(r):
return {
"source": "zillow", "key": normalize_address(r.get("addressLine1", ""), r.get("city",""), r.get("state",""), r.get("zipCode","")),
"price": r.get("price"), "beds": r.get("beds"), "baths": r.get("baths"), "sqft": r.get("sqft"),
"zestimate": r.get("zestimate"), "days_on_market": r.get("daysOnZillow"), "url": r.get("detailUrl"),
}
def norm_redfin(r):
return {
"source": "redfin", "key": normalize_address(r.get("address", ""), r.get("city",""), r.get("state",""), r.get("zip","")),
"price": r.get("price"), "beds": r.get("beds"), "baths": r.get("baths"), "sqft": r.get("sqft"),
"mls_id": r.get("mlsId"), "days_on_market": r.get("daysOnMarket"), "url": r.get("url"),
"agent": r.get("listingAgentName"), "broker": r.get("listingBrokerName"),
}
def norm_realtor(r):
return {
"source": "realtor", "key": normalize_address(r.get("address", ""), r.get("city",""), r.get("state",""), r.get("zip","")),
"price": r.get("price"), "beds": r.get("beds"), "baths": r.get("baths"), "sqft": r.get("sqft"),
"county": r.get("county"), "url": r.get("url"),
"agent": r.get("agentName"), "broker": r.get("officeName"),
}
normalized = (
[norm_zillow(r) for r in zillow_items] +
[norm_redfin(r) for r in redfin_items] +
[norm_realtor(r) for r in realtor_items]
)
Address normalization is the weak point here — unit numbers, abbreviations (“St” vs “Street”), and directional prefixes (“N Lamar” vs “North Lamar”) will cause false negatives. A regex-based normalizer like the one above catches the common cases; for production use, a proper address-parsing library will reduce mismatches further.
3. Dedupe and merge
Group normalized records by the address key. A property with entries from all three sources gives you a merged record with the union of fields — Zillow’s Zestimate, Redfin’s days-on-market and MLS status, Realtor’s county and agent.
from collections import defaultdict
by_address = defaultdict(list)
for row in normalized:
if row["key"]:
by_address[row["key"]].append(row)
merged = []
for key, rows in by_address.items():
sources = {r["source"] for r in rows}
record = {"address_key": key, "sources": sorted(sources), "source_count": len(sources)}
for r in rows:
for field, value in r.items():
if field not in ("source", "key") and value is not None:
record.setdefault(field, value)
merged.append(record)
print(f"Total unique properties: {len(merged)}")
print(f"Confirmed on all 3 sources: {sum(1 for m in merged if m['source_count'] == 3)}")
print(f"Single-source only: {sum(1 for m in merged if m['source_count'] == 1)}")
4. Cross-reference for discrepancy detection
Once merged, compare the same field across sources for the same property. Price mismatches usually mean one source has a stale cache or the listing changed between scrapes — both are useful signals, not noise.
discrepancies = []
for key, rows in by_address.items():
prices = {r["source"]: r["price"] for r in rows if r.get("price")}
if len(prices) > 1 and len(set(prices.values())) > 1:
discrepancies.append({"address_key": key, "prices": prices})
print(f"Properties with price discrepancies across sources: {len(discrepancies)}")
for d in discrepancies[:5]:
print(d)
A property listed at $725,000 on Zillow and $712,000 on Redfin, scraped the same day, means one of them hasn’t caught the price cut yet — flag it and re-check before quoting the price downstream.
Layering in sold comps and rentals for full analysis
For a CMA or investor underwriting workflow, extend the pipeline with the Zillow Recently Sold Scraper for comps and the Zillow Rental Listings Scraper for income modeling, both filtered to the same target ZIP:
sold_run = apify.actor("themineworks/zillow-recently-sold").call(run_input={
"location": LOCATION, "soldInLast": 90, "maxItems": 200,
})
rentals_run = apify.actor("themineworks/zillow-rental-listings").call(run_input={
"location": LOCATION, "maxItems": 200,
})
Combine sold comps (actual clearing prices, not asking prices) with active listings to value a subject property, then cross-check against rental comps in the same ZIP to model gross rental yield if the property is being evaluated as an investment.
What this buys you that no single source does
- Coverage. Properties that sync slowly to one portal still show up if they’re live on another.
- Field completeness. Tax history and schools only come from Zillow’s detail actor; agent/broker data is most consistently populated by Redfin and Realtor.com; MLS status timing favors Redfin.
- Data quality signals. Cross-source agreement (or disagreement) on price and status is itself useful information — a discrepancy is often the first sign a listing changed.
- Resilience. If one portal changes its page structure or rate-limits a run, the pipeline degrades to two sources instead of failing outright.
Related reading
Explore the scraper referenced in this article — see inputs, outputs, and pricing, then run it on Apify.
I Automated My AI Job Hunt: 8 Tools, One Claude Agent, Under $1
Most job hunting is spent on the wrong problem. Here is the exact end-to-end flow — find who is actually hiring, rank by real fit, research the company, find the human, and write an application grounded in facts — run entirely from Claude via MCP.
Ask an AI for a Company's SEC CIK and It Will Lie to You. Here Is the Fix.
An AI that remembers is not an AI that verifies. This is the exact diligence flow — legal identity from GLEIF, filings from EDGAR, funding, engineering activity and hiring — run from Claude via MCP, grounded in real registries, for about fifteen cents a company.
Stop Cold Pitching: Find Local Businesses With a Problem You Can Actually Fix — Under $4
Cold outreach fails because it is generic. Here is the exact flow — build a prospect universe, diagnose the real problem from their own reviews, qualify who can pay, find the human, and pitch with evidence — run entirely from Claude via MCP. No code.