Track Zestimate Changes by Saving Your Own Property Snapshots
The API returns the current Zestimate, not its full history. Record timestamped observations, chart the changes you actually saw, and keep them apart from listing events.
A lookup returns the estimate shown at collection time
When Zillow shows a Zestimate for a home, a PropertiesAPI lookup returns it as zestimate, alongside rentZestimate when that is shown. Both describe the page at the moment it was collected, which the response reports as meta.fetched_at. The response does not include a complete historical series of past Zestimates, and nothing in it lets you reconstruct one after the fact.
That shapes the whole project. If you want to know how the estimate for a set of homes moves over time, the history starts with your first observation. This guide builds a small observation table, a collector that adds a row each time it runs, and a report that lists and charts the changes you recorded. It does not backfill, interpolate or estimate anything.
The Zestimate is Zillow's estimate, returned when Zillow shows one. PropertiesAPI does not produce valuations of its own, and the change figures below are arithmetic on values you collected, not a forecast.
Recorded estimates are not price history
The response also includes priceHistory, an array of source-reported events such as listings, price changes and sales. It is easy to treat that array as a value history, but it records something different: what happened to listings, with the price attached to each event. A home that has not been listed for years can have an empty or short event list while its estimate changes repeatedly.
Keep the two in separate tables and separate chart series. Your observation table answers what the estimate was each time you looked. priceHistory answers what the source says happened to the listing. Mixing them in one line chart implies that an asking price and an estimate measure the same thing.
| Question | Where the answer comes from | Starts when |
|---|---|---|
| What was the Zestimate when we checked? | Your observation table, keyed by zpid and fetched_at. | Your first successful observation. |
| When was the home listed or sold, and at what price? | priceHistory events, as reported by the source. | Whatever the source returned at collection. |
| What does the current listing ask? | price, read together with homeStatus. | The current collection only. |
| How has the assessment changed? | taxHistory, by tax year. | Whatever the source returned at collection. |
Design the observation table
Each row is one observation of one property. The primary key is the zpid together with meta.fetched_at, the time the source page was collected. That choice matters because of the response cache: a repeat lookup inside the cache window returns the earlier collection with the same fetched_at, and the key turns that repeat into a no-op instead of a second row that pretends to be new evidence.
Store the time your script asked as a separate requested_at column, plus the cached flag, so you can tell a fresh collection from a reused one when you audit the table. Keep zestimate nullable. If Zillow stops showing an estimate, the row records null, which is information; writing zero would create a fake collapse in the chart.
Store price and homeStatus too. An estimate that changes on the day a listing appears is worth reading alongside that listing, even though the two stay in separate series.
Collect an observation on a schedule
The collector reads a list of zpids, looks each one up by zpid, and inserts a row. It refuses to record a row when the response carries meta.warning or a different zpid from the one requested, because an estimate attached to the wrong home is worse than a gap. Failed lookups are printed and skipped; the next run tries again.
Run it from cron, a scheduled job, or by hand. How often is a product decision with a cost attached: each successful live lookup consumes a credit, while a repeat inside the cache window is free and adds no new information. Running more often than the cache window cannot observe changes faster than the collections you receive.
Keep the key in an environment variable and the database on storage you back up. If you later move the table into a shared database, keep the same primary key.
"""observe.py: record the current Zestimate for each zpid in watch.txt."""
import os
import sqlite3
import sys
from datetime import datetime, timezone
import requests
API = "https://api.propertiesapi.com/v1/zillow/property"
SCHEMA = """
CREATE TABLE IF NOT EXISTS observations (
zpid TEXT NOT NULL,
fetched_at TEXT NOT NULL, -- meta.fetched_at: when the source page was collected
requested_at TEXT NOT NULL, -- when this script asked
cached INTEGER NOT NULL,
zestimate REAL, -- NULL stays NULL; never store 0 for missing
rent_zestimate REAL,
price REAL,
home_status TEXT,
PRIMARY KEY (zpid, fetched_at)
)"""
def observe(db, session, zpid):
requested_at = datetime.now(timezone.utc).isoformat()
response = session.get(API, params={"zpid": zpid}, timeout=(10, 140), allow_redirects=False)
if response.status_code != 200:
print(zpid, "no observation:", response.status_code)
return
body = response.json()
data, meta = body["data"], body["meta"]
if meta.get("warning") or data["zpid"] != zpid:
print(zpid, "no observation: identity needs review")
return
db.execute(
"INSERT OR IGNORE INTO observations VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(zpid, meta["fetched_at"], requested_at, int(meta["cached"]),
data["zestimate"], data["rentZestimate"], data["price"], data["homeStatus"]),
)
def main(watch_file, db_path="observations.db"):
db = sqlite3.connect(db_path)
db.execute(SCHEMA)
session = requests.Session()
session.headers["x-api-key"] = os.environ["PROPERTIESAPI_KEY"]
for zpid in open(watch_file, encoding="utf-8").read().split():
observe(db, session, zpid)
db.commit()
db.close()
if __name__ == "__main__":
main(*sys.argv[1:])List and chart the changes you observed
The report reads one property's observations in collection order, skips null estimates, and prints each change between consecutive observations with both collection times. The wording is deliberate: the estimate changed somewhere between two observations. You do not know the day it changed unless you observed both sides of that day.
The chart uses markers only. A line between two points would suggest you saw every value in between, and you did not. If you prefer a connected chart for readability, use a dashed line and say in the caption that lines join separate observations. Title the chart as Zillow's Zestimate recorded by your collector, so nobody mistakes it for a series published by Zillow.
"""changes.py: list and chart the Zestimate changes you observed for one zpid."""
import sqlite3
import sys
from datetime import datetime
import matplotlib.pyplot as plt
zpid = sys.argv[1]
db = sqlite3.connect("observations.db")
rows = db.execute(
"SELECT fetched_at, zestimate FROM observations "
"WHERE zpid = ? AND zestimate IS NOT NULL ORDER BY fetched_at",
(zpid,),
).fetchall()
for (t0, z0), (t1, z1) in zip(rows, rows[1:]):
if z1 != z0:
print(f"changed between {t0} and {t1}: {z0:,.0f} -> {z1:,.0f} ({(z1 - z0) / z0:+.2%})")
if rows:
times = [datetime.fromisoformat(t.replace("Z", "+00:00")) for t, _ in rows]
plt.plot(times, [z for _, z in rows], "o") # markers only: nothing was observed between points
plt.title(f"Zillow Zestimate for {zpid}, as recorded by our collector")
plt.ylabel("Zestimate (USD)")
plt.gcf().autofmt_xdate()
plt.savefig(f"zestimate-{zpid}.png", dpi=150, bbox_inches="tight")Handle gaps, nulls and identity changes
Expect gaps. A lookup can fail, a scheduled job can skip a night, and an estimate can be absent for a while. Show gaps as gaps. If a null appears between two numbers, report that the estimate was not shown on that observation rather than drawing through it.
Percent changes need a nonzero starting value and two real observations. The report skips nulls before computing them, and the stored table never contains zero for a missing estimate, so the division is safe. If you add your own derived metrics, apply the same rule.
If a property's returned identity changes, for example a warning appears on a later lookup, stop adding rows for that zpid until someone checks it. Merging two homes into one series produces a change that never happened.
Label the history for what it is
A good label names the source, the collector and the window: Zillow Zestimate as recorded by our collector, from the date of your first observation. Avoid titles like Zestimate history or value over time without that qualifier. Readers will assume a complete series, and you only have the points you collected.
If you publish anything from the table, publish the observation count and the date range with it. Do not use recorded estimates as completed sale prices or as appraisals, and check the terms that apply to your use before sharing the data outside your team.
Validation scope: both scripts were checked against a local fixture database containing repeated cache hits, null estimates and an identity warning. Field names follow the published response contract. No live sample was used, and nothing here measures how often Zillow shows an estimate.
Sources and documentation
Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.
- Property endpoint fields and response metadata
- Plans, credits and cache policy
- Python sqlite module and placeholders
- Matplotlib plot reference
Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.