Build a Real Estate Comps Worksheet from Properties You Select
Build a comparison worksheet for a subject home and the comparable properties you chose: sale status, dates, size, price meaning and a column for your inclusion decision.
You choose the comparables; the worksheet organizes them
A comps worksheet puts a subject property next to a handful of similar homes so you can reason about price. The hard part is choosing which homes belong in the set, and that judgment stays with you. This guide does not find comparable sales for you, rank them, adjust them or produce a value. It takes the homes you selected, looks each one up, and lays out the facts you need to decide whether each one belongs.
That boundary is also a data limitation. PropertiesAPI looks up properties you name by link, zpid or address; it does not search a market or list recent sales near a home. If you need a defensible comparison set, build it from sources you can cite, such as your own market knowledge, an agent's recommendations or public records, then bring the links here.
Write the selection file first, with your reasons
Create selection.csv with one subject row and one row per comparison. The why column is not decoration. Writing down why each home is in the set, before you see its numbers, makes it harder to keep only the homes that support the answer you hoped for.
Use Zillow links rather than addresses where you can; a link names one exact property, while an address must be matched first and can land on a neighboring unit.
role,url,why
subject,https://www.zillow.com/homedetails/<subject>_zpid/,The home we are researching
comparison,https://www.zillow.com/homedetails/<comp-a>_zpid/,"Same subdivision, sold this quarter"
comparison,https://www.zillow.com/homedetails/<comp-b>_zpid/,Similar size and age; sold in July
comparison,https://www.zillow.com/homedetails/<comp-c>_zpid/,Active listing two streets awayWhat each column means, and what it does not
The most important column is price_meaning. The same price field holds an asking price for a listing, a sale price for a recently sold home and a monthly rent for a rental. Mixing them in one average is the most common comps mistake, so the worksheet labels each row, and only asking or sale prices get a price per square foot.
Sale evidence is partial by design. For a sold home, price is the sale price, or missing when the sale price is not disclosed, which happens in some states. last_sale_date and last_sale_price come from the most recent Sold event in the listing's price history, through data.calculated. A missing sale price is shown as missing; nothing fills the gap with an estimate.
| Column | Source | Read it as |
|---|---|---|
| price and price_meaning | price with homeStatus | Asking price, sale price, monthly rent, or none. |
| last_sale_date, last_sale_price | calculated, from priceHistory | The latest Sold event, when the price is disclosed. |
| beds, baths, living_area, lot_size, year_built | The listing's facts | Missing when the page does not list them. |
| price_per_sqft | Calculated in the script | Only for asking or sale prices with a living area. |
| miles_from_subject | Calculated from coordinates | Straight-line distance, not travel distance. |
| fetched_at | meta.fetched_at | When the source was collected. |
| include, reviewer_note | You | Your decision and the reason for it. |
The worksheet script
The script reads your selection and a saved batch response, checks that there is exactly one subject and one result per row, and writes a CSV. Failed lookups stay in the sheet with their error code so a missing comparison is visible instead of silently dropped. Text cells that a spreadsheet could read as a formula are prefixed so they display as text.
It uses only the Python standard library and makes no API calls itself, so you can rerun it, change the columns or add your own adjustments without spending lookups.
"""Build a comparison worksheet for properties YOU selected.
Usage: python comps_worksheet.py selection.csv response.json > comps.csv
selection.csv has role,url,why columns (role is subject or comparison) in the same
order as the items you sent to POST /v1/zillow/batch; response.json is that response.
"""
import csv
import json
import math
import sys
SALE_LISTED = {"FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"}
COLUMNS = ["role", "url", "why_selected", "lookup", "address", "home_type", "home_status",
"price", "price_meaning", "last_sale_date", "last_sale_price", "beds", "baths",
"living_area", "lot_size", "year_built", "price_per_sqft", "miles_from_subject",
"fetched_at", "include", "reviewer_note"]
def price_meaning(data):
status, price = data.get("homeStatus"), data.get("price")
if price is None:
return "sale price not disclosed" if status == "RECENTLY_SOLD" else "no price shown"
if status in SALE_LISTED:
return "asking price"
if status == "RECENTLY_SOLD":
return "sale price"
if status == "FOR_RENT":
return "monthly rent: not comparable to sale prices"
return "unclear: review the listing"
def miles(a, b):
if None in (a.get("latitude"), a.get("longitude"), b.get("latitude"), b.get("longitude")):
return None
lat1, lon1, lat2, lon2 = map(math.radians, (a["latitude"], a["longitude"],
b["latitude"], b["longitude"]))
h = (math.sin((lat2 - lat1) / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2)
return round(3958.8 * 2 * math.asin(math.sqrt(h)), 2)
def cell(value):
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@"):
return "'" + value
return value
def row(selection, result, subject):
out = {"role": selection["role"], "url": selection["url"],
"why_selected": selection["why"], "include": "", "reviewer_note": ""}
if not result.get("ok"):
return {**out, "lookup": result["error"]["code"]}
data, calc = result["data"], result["data"].get("calculated") or {}
meaning = price_meaning(data)
per_sqft = None
if meaning in ("asking price", "sale price") and data.get("livingArea"):
per_sqft = round(data["price"] / data["livingArea"], 2)
return {**out, "lookup": "ok", "address": data.get("address"),
"home_type": data.get("homeType"), "home_status": data.get("homeStatus"),
"price": data.get("price"), "price_meaning": meaning,
"last_sale_date": calc.get("last_sale_date"),
"last_sale_price": calc.get("last_sale_price"),
"beds": data.get("beds"), "baths": data.get("baths"),
"living_area": data.get("livingArea"), "lot_size": data.get("lotSize"),
"year_built": data.get("yearBuilt"), "price_per_sqft": per_sqft,
"miles_from_subject": miles(subject, data) if subject else None,
"fetched_at": result["meta"].get("fetched_at")}
def main(selection_path, response_path):
with open(selection_path, newline="", encoding="utf-8") as f:
selection = list(csv.DictReader(f))
results = json.load(open(response_path, encoding="utf-8"))["data"]
if len(results) != len(selection) or [r["role"] for r in selection].count("subject") != 1:
raise SystemExit("Need one subject row and one result per selected row")
by_index = {r["index"]: r for r in results}
subject_index = next(i for i, s in enumerate(selection) if s["role"] == "subject")
subject = by_index[subject_index].get("data") if by_index[subject_index].get("ok") else None
writer = csv.DictWriter(sys.stdout, fieldnames=COLUMNS)
writer.writeheader()
for i, selected in enumerate(selection):
writer.writerow({k: cell(v) for k, v in row(selected, by_index[i], subject).items()})
if __name__ == "__main__":
main(*sys.argv[1:3])Collect the data once, then build the sheet
Send the selected links to the batch endpoint in the same order as the selection file and save the response. A comps set fits comfortably in one request, since the endpoint takes up to 50 links per request. Then run the script against the saved file.
# 1. Send the same links, in the same order, to the batch endpoint and save the response.
python3 -c "import csv, json; print(json.dumps({'items': [r['url'] for r in csv.DictReader(open('selection.csv'))]}))" > request.json
curl -s https://api.propertiesapi.com/v1/zillow/batch \
-H "x-api-key: $PROPERTIESAPI_KEY" -H "content-type: application/json" \
-d @request.json -o response.json --max-time 330
# 2. Build the worksheet from the saved response (no further lookups).
python comps_worksheet.py selection.csv response.json > comps.csvReview every row before drawing a conclusion
Fill in include and reviewer_note for each comparison. A reason to exclude a home is as useful as a reason to include it. Then, and only then, look at the price columns together. Keep the review notes with the sheet; they are what make the comparison defensible to someone else.
- Is the returned address the home you meant, including the unit?
- Is the comparison the same kind of property, with a similar size, age and lot?
- Is the sale recent enough for your purpose, and was its price disclosed?
- Are active listings kept apart from closed sales? An asking price is not a sale price.
- Was each home collected recently enough for the question you are answering?
- Does anything in the listing description, such as condition or a major renovation, make the home a poor match?
What this worksheet is not
This is a research aid, not an appraisal and not a valuation model. It does not claim that your set contains every relevant sale, and it does not weight, adjust or score comparisons. If a lender, a court or a client needs a valuation, they need a qualified professional who can stand behind the method.
Validation scope: the script was run against synthetic batch responses covering an active listing, a disclosed sale, an undisclosed sale, a missing living area and a failed lookup. No live properties were collected for this article.
Sources and documentation
Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.
Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.