Skip to main content
Thirdwatchthirdwatch
Real estate

Track Rental Days on Market as a Demand Signal by ZIP

Days on market shows how fast rentals lease before asking rent moves. Build a ZIP-level absorption series from Realtor.com list dates and repeat snapshots.

Sep 8, 2026 · 7 min read · 1,641 words
See the scraper →

Days on market is the first number to move when rental demand shifts. Run the US Rentals Scraper with source: "realtor" and read days_on_market and list_date directly, or run source: "both" on a schedule and measure how long each listing_id survives between snapshots. Group by zip_code and bedroom count and you get an absorption series that leads asking-rent changes by weeks. Built for asset managers, leasing teams and market analysts.

Why days on market leads asking rent

Rent is sticky and time on market is not. A landlord facing soft demand does not cut the advertised rent in week two — cutting the ask is visible to every prospect and every competitor, and it resets the anchor for renewals. What actually happens first is that the unit sits. Days on market drifts from eighteen to twenty-six to thirty-five while the number on the listing stays exactly where it was. Only after that does the ask move, or a concession appear.

That makes time on market the earlier signal, and it is the reason Realtor.com's housing research publishes days on market alongside price in its monthly series. For anyone managing a portfolio, timing an acquisition or setting leasing targets, a two-to-six week lead on a rent inflection is the difference between adjusting into a soft quarter and reacting after it.

The operational catch is that time on market is not a field you can read once. A single snapshot is biased: everything that leased fast is already gone, so what you are looking at is a survivor sample weighted toward slow inventory. The median days on market in one pull is always higher than the true median time to lease. Getting the real number means either reading the site's own list_date where it publishes one, or running the same searches repeatedly and measuring survival yourself.

How does this compare to the alternatives?

Three sources answer "how fast is this submarket leasing?" A property-management system knows exactly, for your own units only. A market-data subscription gives you a modelled submarket figure with a reporting lag. A listings pull gives you the observable universe of advertised inventory, which is the only one of the three that covers your competitors at unit level.

Approach Cost model Reliability Setup time Maintenance
Your own PMS leasing data Already paid for Exact, but only your own units Hours None
Market-data subscription Annual licence Modelled submarket average, reported with a lag Procurement cycle Vendor handles it
DIY Python scraper on cron Your servers and infrastructure Breaks on layout changes; you own the snapshot store too 3-7 days You own every breakage
Thirdwatch US Rentals Scraper Transparent pay-per-result Maintained; list dates parsed to ISO 8601 15 minutes Thirdwatch tracks site changes

The US Rentals Scraper returns list_date already parsed and days_on_market already derived, so the snapshot logic below is the only code you write. For the for-sale equivalent of this measurement, the Zillow Suite Scraper exposes days_on_zillow on the same input shape.

How to build a rental absorption series in 4 steps

Where does days on market actually come from?

Read it straight off Realtor.com rows. Realtor.com publishes a list date on its rental listings, so the Actor returns list_date as an ISO 8601 timestamp and days_on_market as an integer derived from it. Apartments.com publishes no list date at all — list_date, days_on_market, agent_name and broker_name stay empty on its rows. That is a limit of the site, and knowing it up front stops you from writing an analysis that silently drops half the sample.

import os, requests, pandas as pd

ACTOR = "thirdwatch~us-rentals-scraper"
TOKEN = os.environ["APIFY_TOKEN"]
ZIPS = ["78704", "78745", "78741", "78702", "78751", "78757"]

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "queries": ZIPS,
        "source": "realtor",
        "maxResults": 200,
        "includeDetails": False,
        "minBeds": 1,
        "proxyConfiguration": {"useApifyProxy": True, "apifyProxyCountry": "US"},
    },
    timeout=3600,
)
rentals = pd.DataFrame(resp.json())
dom = rentals.dropna(subset=["days_on_market", "zip_code"])
print(dom.groupby("zip_code")["days_on_market"]
        .agg(n="count", median="median", p90=lambda s: s.quantile(0.9))
        .round(1))

Run source: "both" when you want the Apartments.com inventory in the same table for the survival method below; run source: "realtor" when you only want the directly published number.

How do I correct for survivorship bias?

Take repeated snapshots and measure how long each listing_id persists. The Actor de-duplicates within a run, not across runs, so cross-run memory is yours to build — and listing_id is the key to build it on, because it is stable per site.

import sqlite3
from datetime import datetime, timezone

run_at = datetime.now(timezone.utc).isoformat()
KEEP = ["listing_id", "source", "listing_url", "listing_name", "address",
        "zip_code", "price", "price_min", "price_max", "beds_min",
        "square_feet_min", "property_type", "list_date", "days_on_market",
        "listing_status", "specials", "source_query"]

snap = rentals[[c for c in KEEP if c in rentals.columns]].copy()
snap["run_at"] = run_at
snap["listing_id"] = snap["listing_id"].astype(str)

con = sqlite3.connect("rental_absorption.db")
snap.to_sql("snapshots", con, if_exists="append", index=False)

Run this daily or every other day. After three weeks you have a survival table, and the median time-to-disappear across it is a far better estimate of time-to-lease than the median days_on_market in any single pull.

How do I turn snapshots into a lease-up event?

Join the newest snapshot against the previous one on listing_id. A listing present before and absent now is a candidate lease-up. Require two consecutive absences before you record it, because a truncated search or an unreachable page produces exactly the same symptom as a leased unit.

runs = pd.read_sql(
    "SELECT DISTINCT run_at FROM snapshots ORDER BY run_at DESC LIMIT 3", con
)["run_at"].tolist()
now, prev, prev2 = runs[0], runs[1], runs[2]

def ids(r):
    return set(pd.read_sql(
        "SELECT listing_id FROM snapshots WHERE run_at = ?", con, params=[r]
    )["listing_id"])

gone = (ids(prev) & ids(prev2)) - ids(now)

history = pd.read_sql(
    "SELECT listing_id, MIN(run_at) first_seen, MAX(run_at) last_seen, "
    "MAX(zip_code) zip_code, MAX(beds_min) beds, "
    "MAX(COALESCE(price, price_min)) ask "
    "FROM snapshots GROUP BY listing_id", con
)
leased = history[history["listing_id"].isin(gone)].copy()
leased["days_tracked"] = (
    pd.to_datetime(leased["last_seen"]) - pd.to_datetime(leased["first_seen"])
).dt.days

print(leased.groupby(["zip_code", "beds"])["days_tracked"]
            .agg(leased="count", median_days="median").round(1))

Cross-check the survival number against the published one where both exist. When your days_tracked and Realtor.com's days_on_market agree on the same listing, confidence in the whole series goes up.

How do I schedule it and read the trend?

Fire the same input on an Apify schedule so the snapshot series never has gaps, then plot the rolling median per ZIP.

curl -X POST "https://api.apify.com/v2/schedules?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "austin-rental-absorption-daily",
    "cronExpression": "0 6 * * *",
    "timezone": "America/Chicago",
    "isEnabled": true,
    "actions": [{
      "type": "RUN_ACTOR",
      "actorId": "thirdwatch~us-rentals-scraper",
      "runInput": {
        "queries": ["78704", "78745", "78741", "78702", "78751", "78757"],
        "source": "both",
        "maxResults": 200,
        "includeDetails": false
      }
    }]
  }'

Read two things off the series. A rising median days on market in a ZIP while asking rent is flat is the softening signal — that is the window where you adjust pricing before your competitors do. A falling median with flat rent means you are leaving rent on the table.

Sample output

Two Realtor.com rows from an absorption pull. The first is stale inventory in a slowing ZIP; the second is fresh.

[
  {
    "listing_id": "4471203856",
    "source": "realtor",
    "source_query": "78704",
    "listing_url": "https://www.realtor.com/realestateandhomes-detail/2107-Ford-St_Austin_TX_78704",
    "listing_name": "2107 Ford St",
    "address": "2107 Ford St, Austin, TX 78704",
    "zip_code": "78704",
    "price": 2950, "price_min": 2950, "price_max": 2950,
    "currency": "USD",
    "beds": 2.0, "baths": 1.0, "square_feet": 1080,
    "property_type": "house",
    "agent_name": "Dana Whitfield",
    "broker_name": "Austin Realty Group",
    "list_date": "2026-07-11T00:00:00+00:00",
    "days_on_market": 58,
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:05:02+00:00"
  },
  {
    "listing_id": "4489117302",
    "source": "realtor",
    "source_query": "78745",
    "listing_name": "6304 Ridgemont Dr",
    "address": "6304 Ridgemont Dr, Austin, TX 78745",
    "zip_code": "78745",
    "price": 2195,
    "beds": 3.0, "baths": 2.0, "square_feet": 1340,
    "property_type": "house",
    "list_date": "2026-09-02T00:00:00+00:00",
    "days_on_market": 5,
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:05:04+00:00"
  }
]

list_date is the raw fact and days_on_market is derived from it, so both move together and either can anchor your series. listing_id is the join key across snapshots. Note that neither field appears on Apartments.com rows — for those you use the survival method, and listing_status plus the presence of the row in the run is your only signal.

Common pitfalls

Reading one snapshot's median as time-to-lease. It is not. Fast leases are already gone from the board, so a single pull over-represents slow inventory. Only repeated runs correct that.

Treating a missing listing as a lease. A listing can vanish because the landlord pulled it, because the search was truncated at maxResults, or because a page did not load that run. Require two consecutive absences, and sanity-check that the ZIP returned its usual row count before you trust any absence in it.

Expecting days on market on Apartments.com rows. Apartments.com does not publish a list date. Those rows will always have list_date and days_on_market empty. Segment on source before you aggregate or you will compute a median over a half-empty column.

Applying property-level logic to communities. A 300-unit community is on the market permanently. Its useful signal is turnover inside floorplans, not the listing's own persistence.

Changing the query set mid-series. Add a ZIP and every ZIP-level median shifts for reasons that have nothing to do with demand. Freeze the input. Thirdwatch's Actor also fails a run loudly rather than reporting an empty page as zero listings, so a blocked day never enters your series as a mass lease-up.

Related use cases

Frequently asked questions

Which source carries days on market?

Realtor.com. Its rows return list_date as an ISO 8601 timestamp and days_on_market derived from it. Apartments.com publishes no list date, so those fields stay empty on its rows and you infer velocity there from listing disappearance between scheduled runs instead.

What counts as a fast or slow rental market?

Read it relative, not absolute. Compare a ZIP against its own trailing median and against neighbouring ZIPs in the same pull. A submarket whose median days on market rose from eighteen to thirty-four over two months is softening regardless of what the absolute number is.

Why does the median look artificially low?

Because listings that lease quickly leave the board quickly, and a single snapshot over-samples slow inventory while under-sampling fast inventory. The fix is repeated runs: track the survival of each listing_id across snapshots rather than reading one pull's median.

Does a listing disappearing mean it leased?

Not always. It can also mean the landlord pulled it, the search was truncated, or a page was not reachable that run. Require two consecutive absences before you record a lease-up, and check whether the ZIP returned its usual row count that day.

Can I get days on market for apartment communities?

Not directly, because a community never fully leaves the market. Track its floorplans array across runs instead. A plan that appears, then disappears, then reappears is real unit-level turnover, and the gap length is the community's true absorption signal.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.