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

Building a Comps Report: Zillow Recently Sold Data for Accurate CMAs

How to pull recently-sold Zillow comps by ZIP, filter by beds and square footage, and calculate price-per-sqft for a defensible comparative market analysis.

Try the scraper

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

View the scraper →

A comparative market analysis is only as good as the comps behind it. Pull the wrong set — too few sales, too wide a date window, mismatched square footage — and the valuation drifts. Pull a tight, recent, well-matched set and the number holds up to scrutiny from a buyer, a lender, or an investor’s own model.

The hard part has never been the math. Price per square foot on five recent sales is a spreadsheet formula. The hard part is getting the sales data itself. Zillow’s public site shows recently-sold homes for any ZIP, but there is no supported way to pull that list programmatically — the old Zillow API (GetDeepSearchResults, GetUpdatedPropertyDetails) was shut down years ago, and current partner APIs are gated to lenders and portals, not to someone running comps for a single deal.

The Zillow Recently Sold Scraper fills that gap. It queries Zillow’s own search backend for sold homes in a location and date window, and returns sold price, sold date, beds, baths, square footage, lot size, Zestimate, and coordinates — the fields a CMA actually needs — with no login and no API key.

What a CMA needs from a comps set

A defensible CMA needs comps that are:

  • Recent — sold within a window that reflects the current market, not a snapshot from a year ago
  • Geographically close — same ZIP or a tight radius, not “somewhere in the metro”
  • Similar in size — within a reasonable band of the subject property’s square footage and bed count
  • Enough of them — three sales is thin; eight to fifteen gives you a real distribution

The Zillow Recently Sold Scraper’s input maps directly onto those requirements. location sets the geography (a city, or a ZIP code like 78704), soldInLast sets the recency window (30, 90, 180, or 365 days), and minBeds, minPrice, maxPrice, and propertyType narrow the size and type filters.

Pulling comps in Python

import os
from apify_client import ApifyClient

apify = ApifyClient(os.environ["APIFY_TOKEN"])

run = apify.actor("themineworks/zillow-recently-sold").call(run_input={
    "location": "78704",
    "maxItems": 100,
    "soldInLast": 90,
    "minBeds": 3,
    "propertyType": "SINGLE_FAMILY",
})

comps = [item for item in apify.dataset(run["defaultDatasetId"]).iterate_items()]
print(f"Pulled {len(comps)} sold comps in the last 90 days")

Each record comes back as a flat JSON object — soldPrice, soldDate, sqft, beds, baths, zestimate, latitude, longitude, and the listing’s detailUrl and zpid. No parsing HTML, no dealing with Zillow’s client-rendered search UI.

Filtering to a tight comp set

Pulling 90 days of sold homes in a ZIP often returns more variety than you want — a mix of condos, fixer-uppers, and larger homes alongside properties that actually match your subject. Filter down in Python before calculating anything:

subject_sqft = 1720
tolerance = 0.20  # +/- 20%

tight_comps = [
    c for c in comps
    if c.get("sqft") and c.get("soldPrice")
    and abs(c["sqft"] - subject_sqft) / subject_sqft <= tolerance
]

print(f"{len(tight_comps)} comps within {int(tolerance*100)}% of subject sqft")

Calculating price per square foot

With a tight comp set, the core CMA number is straightforward:

price_per_sqft = [c["soldPrice"] / c["sqft"] for c in tight_comps]
avg_ppsf = sum(price_per_sqft) / len(price_per_sqft)

estimated_value = avg_ppsf * subject_sqft

print(f"Average $/sqft across {len(tight_comps)} comps: ${avg_ppsf:,.2f}")
print(f"Estimated value for {subject_sqft} sqft subject: ${estimated_value:,.0f}")

For a more resilient estimate, use the median instead of the mean — a single outlier sale (a distressed sale, or a renovated flip) can skew an average of five to eight comps more than you’d like.

import statistics

median_ppsf = statistics.median(price_per_sqft)
print(f"Median $/sqft: ${median_ppsf:,.2f}")

Cross-checking against Zestimate

Every sold record includes Zillow’s own zestimate field where Zillow published one at the time of the listing. It is worth pulling as a sanity check — not as the valuation itself, since Zestimates are Zillow’s automated model and diverge from actual sale price by varying amounts depending on the market, but as a second signal that your comps-based number is in a reasonable range.

zestimates = [c["zestimate"] for c in tight_comps if c.get("zestimate")]
if zestimates:
    avg_zestimate_vs_sold = statistics.mean(
        c["zestimate"] / c["soldPrice"] for c in tight_comps
        if c.get("zestimate") and c.get("soldPrice")
    )
    print(f"Zestimate averaged {avg_zestimate_vs_sold:.2%} of actual sold price in this comp set")

That ratio — Zestimate versus actual sold price — is itself useful. If Zestimates in a ZIP are running 8% high relative to sold price, that tells you something about how much to discount Zillow’s own valuation for that specific market.

Running it as a scheduled pull

A CMA is often a one-off exercise for a single deal, but for an agent or investor running comps regularly, scheduling the actor on Apify keeps a comps dataset current. Point a weekly or monthly schedule at the ZIP codes you track, and each run appends fresh sold data without you re-triggering it manually.

Wiring it into an MCP agent

For a faster, more conversational workflow — pulling comps for a one-off address without writing a script — the actor is available as an MCP server:

https://mcp.apify.com/?tools=themineworks/zillow-recently-sold

Connected to Claude or any MCP client, you can ask “pull recently-sold 3-bed homes in 78704 from the last 90 days and estimate price per square foot” and get comps back in the conversation.

Where this fits in a larger data stack

The Recently Sold Scraper answers one question: what actually sold, and for how much. It pairs naturally with the Zillow Search Scraper for active for-sale inventory (to compare asking price against recent sold comps), and with the Zillow Property Details Scraper when you need full price history, tax history, and school data for a specific subject property rather than just the comp set.

For a walkthrough of building the Zillow side of a data stack end to end, see Building a Real Estate Data Stack: Zillow, Redfin, and Realtor.com. For the Python fundamentals of working with Zillow’s search data more broadly, see Zillow Scraper in Python: A 2026 Guide.

Pricing

Results cost $1 per 1,000 ($0.001 each), with nothing charged on empty or failed pulls. Pulling 90 days of sold comps for a busy ZIP — typically 50-150 records — costs a few cents. Running the same pull monthly across a dozen ZIP codes a real estate team tracks costs a few dollars a month, well below what a comps subscription service charges per seat.

Frequently Asked Questions

How far back can I pull sold comps?

The soldInLast parameter accepts 30, 90, 180, or 365 days. For an active market, 90 days usually gives a large enough sample without going so far back that the comps no longer reflect current pricing. In slower markets, 180 days may be necessary to get enough sales.

Does this replace a licensed appraisal?

No. This is comps data for informal valuation — pricing a listing, sizing an offer, sanity-checking a lender’s number, or feeding a larger investor model. A licensed appraisal involves inspection and adjustments a scraped dataset cannot replicate.

Can I get the full sale history of a specific property, not just recent comps?

Yes, but that requires the separate Zillow Property Details Scraper, which takes a detailUrl or zpid and returns full price history, tax history, and more. The Recently Sold Scraper is built for pulling many comps across a location, not deep-diving one address.

Related Actor

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