The Mine Works
← All posts
use-case July 11, 2026 · 6 min read

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.

Try the scraper

The actor referenced in this article. Pay only for results delivered.

View the scraper →

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 Actor

Explore the scraper referenced in this article — see inputs, outputs, and pricing, then run it on Apify.