Data workflows

Prepare a Property Dataset for Model Training Without Leaking Future Information

Build a training table from timestamped property observations, split it by time, and audit every feature for information from after the prediction date. Check permitted use first.

8 min read

A lookup today describes the property today

Temporal leakage happens when a model is trained with information that would not have been available at the moment it is supposed to make a prediction. Property data invites it. A record you look up now reflects the page as it is now: the latest estimate, the current status, the most recent sale, and an event list that may include things that happened after the date you are trying to predict.

Suppose you want to predict sale outcomes for homes that sold last spring. If you look those homes up today and use the returned fields as features, the zestimate may already reflect the sale, lastSoldPrice may be the very target you are predicting, and homeStatus may say the home has sold. The model will look excellent in validation and then fail on the next listing, because in production it never gets to see the answer in advance.

The rule this guide follows is simple: retrospectively collected current fields are not historical snapshots. A field can be used as a feature only if you observed it, with its collection time, before the moment the prediction would have been made.

Check permitted use before you train

Before building a dataset, confirm that training on the data is allowed for your use. Read the terms that apply to your account and your product, including how you store, retain and redistribute collected data. Property listings and estimates shown on a public page are not the same as data licensed for model training.

PropertiesAPI provides data as shown on the public page at collection time, as-is, without a guarantee of accuracy or completeness. This article is about data handling, not model quality. It makes no claim about how accurate a model trained this way will be.

Define the prediction moment for every row

Each training example needs an as-of time: the moment the prediction would have been made in production. For a sale-price model it might be the listing date; for a model that screens properties on request, it is when the request would arrive. Write the definition down, because every leakage check depends on it.

Each feature value needs its own observation time. For values from the API, that is meta.fetched_at, the time the source page was collected. It is not the time your job ran and not the export date of a spreadsheet. A cached response keeps the original fetched_at, which is exactly what you want here.

With both times stored, the core rule becomes checkable: a feature is allowed only if its observation time is strictly earlier than the row's as-of time. That requires a stored history of observations, which you build by saving timestamped snapshots going forward. If you do not have one, the honest options are to start collecting now or to restrict yourself to fields that genuinely do not change.

Audit every field for future information

Go through the fields you plan to use and ask two questions: could this value change after the as-of time, and could it encode the outcome itself? Fields that change need an observation from before the as-of time. Fields that can encode the outcome usually should not be features at all, even when observed in time.

The calculated block deserves the same scrutiny. Its figures are arithmetic on the returned fields, so they inherit every leak in their inputs. change_since_last_sale_pct, for example, depends on the last sale and the current price or estimate.

FieldLeakage riskSafer handling
zestimate, rentZestimateCurrent estimates can already reflect the sale or rent you are predicting.Use only values observed before the as-of time.
lastSoldPrice, dateSold, calculated last sale fieldsCan be the target itself when looked up after the event.Exclude, or use only from an earlier observation, and confirm the sale predates as-of.
homeStatus, statusTextA sold or pending status reveals the outcome.Exclude as a feature for outcome models.
priceHistoryA later collection includes later events and can include revisions.Use an array collected before as-of, then filter events by date.
daysOnZillow, calculated days on marketGrows with time and stops when the listing ends.Use only from an earlier observation.
pageViewCount, favoriteCountCumulative counters that keep growing after the event.Use only from an earlier observation, or exclude.
taxHistory, taxAssessedValueLater tax years can reflect the sale price.Keep only tax years complete before as-of.
description, photosListings are edited; later text can mention the outcome.Use only from an earlier observation.
beds, baths, livingArea, yearBuilt, homeTypeUsually stable, but renovations and corrections happen.Still join from an earlier observation and record its age.

Join features as of each row

The program below joins each label to the most recent observation of the same zpid strictly before the label's event time, using an as-of join. Labels with no earlier observation are dropped and counted, not filled. The assertion at the end fails the build if any feature row is not earlier than its label, so a later change to the join cannot quietly reintroduce leakage.

The feature list is deliberately short. Add fields only after they pass the audit above, and keep feature_age_days so you can see how stale each observation was when the prediction would have been made.

build_training_table.py
"""build_training_table.py: attach only features observed BEFORE each label's event time."""
import pandas as pd

# One row per lookup you stored, written from each response at collection time:
# zpid, fetched_at (meta.fetched_at), beds, baths, livingArea, yearBuilt, homeType
obs = pd.read_parquet("observations.parquet")
# One row per outcome you want to predict: zpid, event_time, target
labels = pd.read_csv("labels.csv", dtype={"zpid": str})  # zpid is a string in the API

obs["fetched_at"] = pd.to_datetime(obs["fetched_at"], utc=True)
labels["event_time"] = pd.to_datetime(labels["event_time"], utc=True)

# Fields that describe the property as it was observed. Anything that can encode the
# outcome or what happened after it (see the audit table) is not on this list.
FEATURES = ["beds", "baths", "livingArea", "yearBuilt", "homeType"]

table = pd.merge_asof(
    labels.sort_values("event_time"),
    obs.sort_values("fetched_at")[["zpid", "fetched_at", *FEATURES]],
    left_on="event_time",
    right_on="fetched_at",
    by="zpid",
    direction="backward",        # the latest observation at or before the event...
    allow_exact_matches=False,   # ...strictly before it, never at the same moment
)

missing = table["fetched_at"].isna()
print(f"{missing.sum()} labels have no earlier observation and are dropped")
table = table[~missing].copy()

# Fail closed: every feature row must predate its label.
assert (table["fetched_at"] < table["event_time"]).all()
table["feature_age_days"] = (table["event_time"] - table["fetched_at"]).dt.days
table.to_parquet("training_table.parquet")

Filter event arrays twice

priceHistory needs two filters. First, the array must come from an observation collected before the as-of time, because a later collection can include later events or revised ones. Second, within that array, keep only events dated before the as-of day. Dates in priceHistory are calendar days, so compare them as days rather than inventing a time of day.

Keep event labels as the source wrote them and keep missing prices missing. A price change event is not a sale, and an empty array does not prove a home never sold.

Filter source-reported events by date
def events_before(price_history, cutoff_day):
    """Source-reported events dated strictly before cutoff_day (YYYY-MM-DD).

    Only use this on a priceHistory array that was itself collected before the cutoff.
    A later collection can contain events that were revised or added after it.
    """
    return [event for event in price_history if event["date"] and event["date"] < cutoff_day]

Split by time, and keep properties on one side

Random train and test splits leak across time: the model trains on next month and is evaluated on last month. Split on the label's event time instead, with training first, validation next and test last. For cross-validation inside the training period, use a splitter that only ever trains on the past, such as scikit-learn's TimeSeriesSplit.

Properties that sell more than once add a second path for leakage. The same home's details from a later sale should not sit in training while an earlier sale of it sits in test. The split below keeps each zpid on the earliest side where it appears; a grouped time split is an alternative.

Time-ordered split with property separation
# Time-ordered split on the label's event time, never a random shuffle.
VALID_FROM = pd.Timestamp("2026-01-01", tz="UTC")
TEST_FROM = pd.Timestamp("2026-04-01", tz="UTC")
table = table.sort_values("event_time")
train = table[table["event_time"] < VALID_FROM]
valid = table[(table["event_time"] >= VALID_FROM) & (table["event_time"] < TEST_FROM)]
test = table[table["event_time"] >= TEST_FROM]

# A property that appears on both sides of a boundary can still leak its own
# details forward. Keep each zpid on the earliest side it appears in.
seen = set(train["zpid"])
valid = valid[~valid["zpid"].isin(seen)]
seen |= set(valid["zpid"])
test = test[~test["zpid"].isin(seen)]

for earlier, later in ((train, valid), (valid, test)):
    if len(earlier) and len(later):
        assert earlier["event_time"].max() < later["event_time"].min()
print(len(train), len(valid), len(test), "rows in train, validation and test")

Checklist before training

Treat this as a gate, not a guideline. If any item fails, fix the dataset before measuring a model on it.

Validation scope: the code was run against a small synthetic table built to include same-moment observations, labels with no prior observation and a property that sold twice. Field names follow the published response contract. No model was trained for this article and no accuracy figures are claimed.

  • Permitted use for training is confirmed in writing.
  • Every row has a documented as-of time.
  • Every feature value carries its own fetched_at and predates as-of.
  • Outcome-revealing fields such as status and last sale are excluded.
  • Event arrays are taken from earlier collections and filtered by date.
  • Splits follow time and keep each property on one side.
  • Dropped rows are counted and reported, not filled in.

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.