Understanding data

Asking Price vs. Zestimate: Compare the Gap Without Calling It a Bargain

Compare a sale listing's asking price with Zillow's Zestimate, handle missing values and non-sale statuses, and review each gap with a checklist instead of labelling homes as deals.

7 min read

Two different numbers that look comparable

An asking price is what a seller is asking for a home listed for sale. A Zestimate is Zillow's estimate of the home's value, shown when Zillow shows one. Putting them side by side is a natural first question for anyone researching listings, and the gap between them can point you to homes worth a closer look.

The gap is not a verdict. A listing below its Zestimate is not necessarily a bargain, and a listing above it is not necessarily overpriced. The estimate may not reflect a renovation, damage, a view, a busy road or anything else the listing photos and description reveal. Sellers price for many reasons, including speed. Treat the gap as a prompt for research, and describe it in those terms wherever you show it.

Compare only sale listings with both values present

The comparison is meaningful only when price is an asking price. For a rental, price is a monthly rent, and comparing it with a home value produces nonsense. For a sold home, price is a sale price, which is a different question: what the home sold for, not what is being asked. For a home that is not listed, there is no asking price at all.

Both values must also be present. If Zillow shows no Zestimate, the gap is unavailable, not zero, and a missing asking price is not a free home. The script below explains every row it cannot compare, so the reason is visible in the output.

SituationResult
For sale, pending, coming soon or foreclosure, with both valuesCompared.
RentalNot compared: price is a monthly rent.
Recently soldNot compared here: price is a sale price.
No asking price shownNot compared.
No Zestimate shownNot compared; never treated as zero.
Currency other than USDNot compared.

The calculation

The difference is the asking price minus the Zestimate. The percentage divides that difference by the Zestimate, so a negative value means the home is listed below the estimate and a positive value means above it. Keeping the same denominator for every home keeps the direction consistent across a list.

Formula
difference     = asking price - Zestimate
difference_pct = difference / Zestimate x 100

# Arithmetic check, not a market observation:
# asking 490,000, Zestimate 500,000  ->  -10,000 / 500,000 x 100 = -2.00%

Run the comparison on a saved batch response

Look up your listings with the batch endpoint and save the response. The script reads that file, compares each eligible row with Decimal arithmetic, and writes a CSV that keeps failed lookups and ineligible rows with a reason. It adds an empty review_note column for the checklist below.

Every response also carries data.calculated.price_vs_zestimate_pct, calculated by PropertiesAPI from the same two fields; it is not a Zillow figure. The script prints it next to its own result as a cross-check. Note one difference in scope: the API calculates it whenever price is a sale or asking price, so it also appears for sold homes, where it compares a sale price with the current estimate. This worksheet deliberately limits itself to asking prices.

price_gap.py
"""Compare asking prices with Zillow's Zestimate for sale listings, without ranking them.

Usage: python price_gap.py response.json > gaps.csv   (a saved POST /v1/zillow/batch response)
"""
import csv
import json
import sys
from decimal import Decimal

SALE_LISTED = {"FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"}


def gap(data):
    status, price, estimate = data.get("homeStatus"), data.get("price"), data.get("zestimate")
    if status not in SALE_LISTED:
        return None, None, f"not a sale listing ({status or 'status unknown'})"
    if data.get("currency") not in (None, "USD"):
        return None, None, "currency is not USD"
    if price is None:
        return None, None, "no asking price shown"
    if not estimate:
        return None, None, "no Zestimate shown"
    difference = Decimal(str(price)) - Decimal(str(estimate))
    percent = (difference / Decimal(str(estimate)) * 100).quantize(Decimal("0.01"))
    return difference, percent, "compared"


def main(response_path):
    results = json.load(open(response_path, encoding="utf-8"))["data"]
    out = csv.writer(sys.stdout)
    out.writerow(["index", "address", "home_status", "asking_price", "zestimate",
                  "difference", "difference_pct", "api_price_vs_zestimate_pct",
                  "result", "fetched_at", "review_note"])
    for item in results:
        if not item.get("ok"):
            out.writerow([item["index"], "", "", "", "", "", "", "",
                          f"lookup failed: {item['error']['code']}", "", ""])
            continue
        data, meta = item["data"], item["meta"]
        difference, percent, result = gap(data)
        api_pct = (data.get("calculated") or {}).get("price_vs_zestimate_pct")
        out.writerow([item["index"], data.get("address"), data.get("homeStatus"),
                      data.get("price"), data.get("zestimate"), difference, percent, api_pct,
                      result, meta.get("fetched_at"), ""])


if __name__ == "__main__":
    main(sys.argv[1])

Read the output row by row

The sample below comes from synthetic fixtures built on the documentation's illustrative record. The first row is compared and matches the API's figure. The rental is excluded. The sold home is excluded here even though the API reports a figure for it. The home without a Zestimate is excluded with that reason, and the failed lookup keeps its error code.

Output from synthetic fixtures (illustrative)
index,address,home_status,asking_price,zestimate,difference,difference_pct,api_price_vs_zestimate_pct,result,fetched_at,review_note
0,"10 Example Ave, Austin, TX 78701",FOR_SALE,525000,512000,13000,2.54,2.54,compared,2026-10-20T14:00:00.000Z,
1,"22 Sample St APT 4, Austin, TX 78702",FOR_RENT,2400,410000,,,,not a sale listing (FOR_RENT),2026-10-20T14:00:00.000Z,
2,"5 Test Rd, Austin, TX 78703",RECENTLY_SOLD,498000,505000,,,-1.39,not a sale listing (RECENTLY_SOLD),2026-10-20T14:00:00.000Z,
3,"8 Null Ln, Austin, TX 78704",FOR_SALE,525000,,,,,no Zestimate shown,2026-10-20T14:00:00.000Z,
4,,,,,,,,lookup failed: not_found,,

Track the gap over time, not just once

Both numbers move. A seller can cut the asking price, and Zillow can update the estimate. If you keep a list of listings under review, store each comparison with its collection time and compare runs, rather than overwriting yesterday's result. A gap that narrows after a price cut tells a different story from one that has been stable for weeks.

When you rerun the comparison, remember that repeat lookups of the same home within 24 hours come from our cache and are free, and they return the earlier collection. Compare fetched_at between runs before concluding that nothing changed. The Postgres guide on this blog shows a schema that keeps each distinct collection as its own row.

A review checklist for every gap you act on

Sort by the size of the gap if you like, but review each home you intend to act on. Write what you found in review_note. The questions below catch most of the reasons an asking price and an estimate disagree.

  • Is the returned address the home you meant, including the unit?
  • When was the home collected? An older collection may predate a price change.
  • Has the asking price changed recently? data.calculated.last_price_change and priceHistory show the listing's price events.
  • How long has the home been listed? A long time on the market can explain a price below the estimate.
  • Do the listing description and photos show condition, renovation or damage the estimate may not reflect?
  • Is the home type unusual for the area, such as a large lot or a multi-family building?
  • Would you reach the same view without the estimate? If not, the estimate is doing too much work.

Describe the gap honestly in your product

Label the figure as listed above or below Zillow's Zestimate, with the percentage, the collection date and a link to the listing. Avoid words such as deal, steal, undervalued or bargain, which state a conclusion the data cannot support. Do not present the Zestimate as an appraisal or as your own valuation.

Missing values deserve their own state in the interface. The guide to null Zestimates covers rendering and calculations when the estimate is absent, and the rental comparison guide covers the different question of monthly asking rents against rent estimates.

Validation scope: the script was run against synthetic batch responses and its gap function was checked against hand arithmetic. No live properties were collected for this article, and no claim about how often listings sit above or below their estimates follows from it.

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.