How to Pull Zillow Sold Comps for CMA Valuation Reports
Pull recently sold Zillow comps for any ZIP code and price a subject property for a CMA on real price-per-sqft evidence instead of a Zestimate screenshot.

Thirdwatch's Zillow Suite Scraper exports recently sold Zillow comparables for any ZIP code, city, neighbourhood or county as clean numeric rows, with
date_sold,price_per_sqft,living_area_sqft,lot_size_sqftandtax_assessed_valueon every record. Built for appraisers, valuation analysts, brokerage research teams and housing economists who need a defensible comp set rather than a screenshot of an automated estimate. Transparent pay-per-result pricing, no login or Zillow API key required.
Why pull Zillow sold comps for valuation work
A comparative market analysis stands or falls on the evidence behind it. Fannie Mae's Selling Guide requires an appraisal report to include a minimum of three closed comparable sales, and review appraisers reject reports where those sales are poorly matched on size, date or property type. That standard is about closed transactions, not listings and not automated estimates, which is why the sold search is a different job from the for-sale search.
The manual version of this job is a browser tab, Zillow's sold filter, and copy-paste into a spreadsheet, repeated for every subject property. It is slow, it is unrepeatable, and the resulting sheet has prices as strings and dates as whatever the browser showed. The analyst then eyeballs price per square foot rather than computing it.
The structured version is one query per ZIP code. An appraiser pulls every house sold in the subject ZIP in the last six months, filters to properties within twenty percent of the subject's living area, computes median price per square foot across the surviving set, and multiplies. A brokerage research desk runs the same pull weekly across a farm area to keep listing-presentation numbers current. A housing economist pulls sold rows across fifty ZIP codes to measure how price per square foot moved quarter over quarter. All three need the same thing: closed sales with area, date and price as numbers.
How does this compare to the alternatives?
There are four realistic ways to get closed-sale evidence into a spreadsheet or a model.
| Approach | Cost | Reliability | Setup time | Maintenance |
|---|---|---|---|---|
| Manual Zillow copy-paste | Analyst hours | Transcription errors, no audit trail | Minutes per property | Repeated by hand every time |
| DIY Python scraper | Engineering time plus infrastructure | Breaks when Zillow's payload shape changes | Days to weeks | Ongoing, and it fails silently |
| MLS or paid data vendor licence | Annual contract, board membership | Highest, and legally cleanest | Weeks of onboarding | Vendor manages it |
| Thirdwatch Zillow Suite Scraper | Pay per result | Validated payloads, session rotation, retries | 5 minutes | Thirdwatch tracks Zillow changes |
If you hold an MLS licence, use it — MLS data is the authoritative record and includes off-market history Zillow never publishes. The Zillow Suite Scraper actor page is for the cases where you do not: markets outside your board, pre-listing research, portfolio-wide analysis, academic work, and any pipeline that needs a repeatable public-data baseline. Everything it returns is public listing data, normalised.
How to pull Zillow sold comps in 5 steps
Step 1: How do I authenticate against Apify?
Sign in at apify.com, open Settings then Integrations, and copy your personal API token. Every example below reads it from the environment.
export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"Step 2: How do I pull every recent sale in a ZIP code?
Set searchMode to sold and pass the subject ZIP as a single entry in queries. Keep the run narrow: one ZIP, one property type, a bedroom floor that brackets the subject.
import os
import requests
ACTOR = "thirdwatch~zillow-suite-scraper"
TOKEN = os.environ["APIFY_TOKEN"]
resp = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={
"queries": ["95818"],
"searchMode": "sold",
"maxResults": 300,
"sortBy": "newest",
"homeTypes": ["houses"],
"minBeds": 2,
"includeDetails": False,
},
timeout=1800,
)
records = resp.json()
print(f"{len(records)} sold rows for 95818")Leave proxyConfiguration at its default. Zillow only serves listing data to US traffic and the default is already set correctly. sortBy: "newest" matters here — it puts the most recent closings at the top, so even a truncated run gives you the comps you actually want.
Step 3: How do I cut the raw sold set down to a defensible comp set?
Filter on recency, property type and living area, then rank by how close each sale is to the subject's size. Every numeric field arrives as a number, so this is plain pandas.
import pandas as pd
SUBJECT = {"living_area_sqft": 1850, "bedrooms": 3, "home_type": "SINGLE_FAMILY"}
SIZE_TOLERANCE = 0.20 # +/- 20% of subject living area
WINDOW_DAYS = 180 # closed in the last six months
df = pd.DataFrame(records)
df["date_sold"] = pd.to_datetime(df["date_sold"], utc=True, errors="coerce")
cutoff = pd.Timestamp.utcnow() - pd.Timedelta(days=WINDOW_DAYS)
lo = SUBJECT["living_area_sqft"] * (1 - SIZE_TOLERANCE)
hi = SUBJECT["living_area_sqft"] * (1 + SIZE_TOLERANCE)
comps = df[
(df.search_mode == "sold")
& df.price.notna()
& df.living_area_sqft.notna()
& (df.date_sold >= cutoff)
& (df.home_type == SUBJECT["home_type"])
& df.living_area_sqft.between(lo, hi)
& df.bedrooms.between(SUBJECT["bedrooms"] - 1, SUBJECT["bedrooms"] + 1)
].copy()
comps["size_delta_pct"] = (
(comps.living_area_sqft - SUBJECT["living_area_sqft"]).abs()
/ SUBJECT["living_area_sqft"] * 100
)
comps = comps.sort_values(["size_delta_pct", "date_sold"], ascending=[True, False]).head(8)
indicated_value = comps.price_per_sqft.median() * SUBJECT["living_area_sqft"]
print(comps[["address", "date_sold", "price", "living_area_sqft",
"price_per_sqft", "bedrooms", "bathrooms", "property_url"]])
print(f"Indicated value: ${indicated_value:,.0f} "
f"from {len(comps)} comps, median ${comps.price_per_sqft.median():,.0f}/sqft")price_per_sqft is computed by the Actor as price / living_area_sqft, so you are not re-deriving it from two strings. Use the median rather than the mean — one estate sale or one intra-family transfer will drag a mean badly on a set of eight.
Step 4: How do I attach MLS ID, price history and tax history to the shortlist?
Feed the shortlisted property_url values back in as queries with includeDetails on. A /homedetails/ URL is scraped as one fully detailed row.
detail = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={
"queries": comps.property_url.tolist(),
"searchMode": "sold",
"includeDetails": True,
"maxDetailPages": 10,
},
timeout=1800,
).json()
for row in detail:
print(row["address"], "|", row.get("mls_id"), "|", row.get("year_built"),
"| APN", row.get("parcel_number"),
"| tax", row.get("annual_tax_amount"))
for event in row.get("price_history", []):
print(" ", event["date"], event["event"], event["price"])This is the block that turns a spreadsheet into a report exhibit: mls_id, parcel_number, year_built, annual_tax_amount, property_tax_rate, the full price_history array and the tax_history array. Cap it with maxDetailPages — detail mode costs one extra page load per listing, so enrich eight comps, not three hundred.
Step 5: How do I handle non-disclosure states where the sold price is blank?
Twelve states do not publish sale prices, so price comes back null on sold rows there. Detect that before you build a report on top of it.
sold = df[df.search_mode == "sold"]
blank_price = sold.price.isna().mean()
NON_DISCLOSURE = {"TX", "UT", "ID", "KS", "LA", "MS",
"MO", "MT", "NM", "ND", "WY", "AK"}
state = sold.state.mode().iat[0]
if state in NON_DISCLOSURE or blank_price > 0.5:
print(f"{state}: {blank_price:.0%} of sold rows carry no price.")
proxy_set = sold[sold.tax_assessed_value.notna()].copy()
proxy_set["assessed_per_sqft"] = (
proxy_set.tax_assessed_value / proxy_set.living_area_sqft
)
print(proxy_set[["address", "date_sold", "living_area_sqft",
"tax_assessed_value", "assessed_per_sqft", "zestimate"]].head(10))In a non-disclosure state the sold rows still give you the transaction set: which houses closed, when, at what size, with what assessed value and what Zestimate. That is enough to establish turnover and to bracket the subject on physical characteristics, but the price column has to come from your MLS or the county. Say so in the report rather than substituting zestimate silently.
Sample output
Two sold rows, one from a disclosure state and one from Texas, showing exactly what changes.
[
{
"zpid": "24817302",
"property_url": "https://www.zillow.com/homedetails/2417-Portola-Way-Sacramento-CA-95818/24817302_zpid/",
"source_query": "95818",
"search_mode": "sold",
"listing_status": "SOLD",
"status_text": "Sold",
"home_type": "SINGLE_FAMILY",
"address": "2417 Portola Way, Sacramento, CA 95818",
"city": "Sacramento",
"state": "CA",
"zip_code": "95818",
"latitude": 38.554871,
"longitude": -121.482233,
"price": 812000,
"price_formatted": "$812,000",
"currency": "USD",
"price_per_sqft": 438.38,
"bedrooms": 3,
"bathrooms": 2,
"living_area_sqft": 1852,
"lot_area_value": 6098,
"lot_area_unit": "sqft",
"lot_size_sqft": 6098,
"zestimate": 803400,
"rent_zestimate": 3150,
"tax_assessed_value": 611240,
"date_sold": "2026-05-14T00:00:00+00:00",
"days_on_zillow": 27,
"scraped_at": "2026-07-28T06:41:11+00:00"
},
{
"zpid": "29441856",
"property_url": "https://www.zillow.com/homedetails/1908-Ford-St-Austin-TX-78704/29441856_zpid/",
"source_query": "78704",
"search_mode": "sold",
"listing_status": "SOLD",
"status_text": "Sold",
"home_type": "SINGLE_FAMILY",
"address": "1908 Ford St, Austin, TX 78704",
"city": "Austin",
"state": "TX",
"zip_code": "78704",
"price": null,
"price_per_sqft": null,
"bedrooms": 3,
"bathrooms": 2,
"living_area_sqft": 1744,
"lot_size_sqft": 7405,
"zestimate": 968700,
"tax_assessed_value": 874523,
"date_sold": "2026-06-02T00:00:00+00:00",
"scraped_at": "2026-07-28T06:41:19+00:00"
}
]date_sold is ISO 8601 UTC, so recency filters are a date comparison rather than a string parse. price_per_sqft is precomputed from price and living_area_sqft and is null wherever either input is missing, which is the correct behaviour — it is never guessed. lot_area_value plus lot_area_unit preserve what Zillow displayed, while lot_size_sqft normalises acres to square feet so lot adjustments are comparable across rows. zpid is your join key: stable, unique per property, and what the Actor deduplicates on within a run.
Common pitfalls
Non-disclosure states are the big one. Texas, Utah, Idaho, Kansas, Louisiana, Mississippi, Missouri, Montana, New Mexico, North Dakota, Wyoming and Alaska publish no sale prices, so price and price_per_sqft are null there. Everything else on the row survives. Detect the condition with the check in step 5 instead of discovering it in a review.
Sparse fields on individual rows. New construction and agent-withheld listings routinely omit Zestimate, lot size or bathroom counts. Missing values come back as null rather than being filled in, so always guard with .notna() before an arithmetic filter.
The pagination ceiling. Zillow serves 41 listings per page and stops at roughly 980 per search area regardless of how many matches it reports. That is irrelevant at ZIP level but will bite a whole-city query. Split the metro into ZIP codes; rows are deduplicated on zpid within a run, so overlapping areas are safe.
Deduplication is per run, not across runs. If you are appending to a database, key on zpid yourself.
A minority of requests get challenged. Zillow rate-limits aggressively. The Actor validates responses by shape rather than status code, rotates sessions with backoff and retries, which recovers nearly all of them, but an unusually aggressive run can still finish a little short of maxResults on some queries. Check row counts before treating a pull as complete.
Related use cases
- Screen rental yield with the Zillow Rent Zestimate — forward-looking cash-flow screening rather than backward-looking valuation.
- Monitor Zillow price drops and new listings daily — the same searches on a schedule, diffed day over day.
- Build a Zillow API alternative for a property app — ZIP-level sharding and a normalised property table.
- Build real-estate agent lead lists from Zillow — listing agents and brokerages from active inventory.
- Find India real-estate comparables with SquareYards — the same comp method outside the US.
- All Thirdwatch use-case guides
Frequently asked questions
How many sold comps do I need for a defensible CMA?
Fannie Mae's Selling Guide sets the floor at three closed comparable sales for an appraisal report. In practice, pull 100 to 300 sold rows for the ZIP code, then filter down to the six or eight closest matches on living area, bedroom count and sale recency.
Why is the sold price empty on some rows?
Twelve US states are non-disclosure states and do not publish sale prices. Texas, Utah, Idaho, Kansas, Louisiana, Mississippi, Missouri, Montana, New Mexico, North Dakota, Wyoming and Alaska return sold date, address, beds, baths, area, Zestimate and tax assessed value, but no sale price.
How far back do sold comparables go?
Zillow's sold search covers roughly the last two years of recorded sales for most areas. Filter on the date_sold field to whatever window your valuation standard requires, typically three to six months for an active market and up to twelve for a thin one.
Can I get the price history and MLS ID for each comp?
Yes. Set includeDetails to true and the Actor visits each property page for year built, MLS ID, parcel number, annual tax amount, price history, tax history and assigned schools. Use maxDetailPages to enrich only the shortlisted comps rather than the whole result set.
How many sold listings can one search return?
Zillow serves 41 listings per page and stops paginating at roughly 980 per search area, however many matches it reports. For CMA work that ceiling is never binding at ZIP level. For a whole metro, pass many ZIP codes and let the Actor deduplicate on zpid.
Is the Zestimate a substitute for sold comps?
No. A Zestimate is an automated valuation model output, not market evidence, and no review appraiser accepts one as support. Use the zestimate field as a sanity check against your comp-derived indicated value, and cite the sold rows themselves in the report.
Related
100 free credits, no credit card.
About 30 real searches. Add the MCP to Claude or Cursor in two minutes.