Understanding data

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.

7 min read

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.

QuestionWhere the answer comes fromStarts 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
"""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
"""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.

Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.