Skip to main content
Thirdwatchthirdwatch
Real estate

How to Analyse a Metro Rental Market With Listings Data

Turn Apartments.com and Realtor.com listings into a ZIP-level rental market report: median asking rent, unit mix and supply concentration per submarket.

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

To analyse a metro rental market, run the US Rentals Scraper across every ZIP code in the metro with source: "both", then aggregate price_min, beds_min, square_feet_min, year_built and property_type by zip_code. You get median asking rent by bedroom count, the unit mix on offer, the vintage of advertised stock, and which submarkets are concentrated in a handful of large communities. Built for housing researchers, economists, policy teams and market-study analysts.

Why analyse a rental market from listings rather than surveys

Official rent statistics are excellent and slow. The Bureau of Labor Statistics rebuilds the shelter component of the Consumer Price Index from a rotating panel of housing units, which makes it the most methodologically rigorous rent series in the country and also means it reflects the stock of existing leases, most of which were signed months or years ago. Census survey data has the same property. Both answer "what are people paying?" — a different question from "what is being asked right now?"

For anyone writing a market study, siting a development, setting a policy position or briefing an investment committee, the second question is the operative one. Advertised rent turns first. When a submarket softens, the ask drops and the concessions appear months before the average signed rent in a survey panel budges.

Listings also carry the composition of supply, which no rent index does. From a metro-wide pull you can see how much of the advertised inventory is studios versus three-bedrooms, how much is 2020s vintage versus 1980s, how much sits in large managed communities versus scattered single-family rentals, and how unevenly that is distributed across ZIP codes. Composition is usually the finding. A submarket where median asking rent rose eight percent often turns out to be a submarket where the mix shifted toward new lease-ups, not one where any individual unit got more expensive.

How does this compare to the alternatives?

There are three ways to get a metro rental picture. Public statistics are free, authoritative and lagged. A commercial market-data subscription gives you modelled submarket series that are good for trend and opaque about method. A listings pull gives you the raw advertised inventory, which you can slice however your study needs and re-run on a fixed cadence so the series is yours.

Approach Cost model Reliability Setup time Maintenance
Census and BLS series Free Authoritative, but lagged and aggregated Hours None
Commercial market-data subscription Annual licence Strong trend, modelled rather than observed Procurement cycle Vendor handles it
DIY Python scraper Your servers and infrastructure Breaks on layout changes at either site 3-7 days You own every breakage
Thirdwatch US Rentals Scraper Transparent pay-per-result Maintained; two sources in one normalised schema 15 minutes Thirdwatch tracks site changes

None of these replaces the others. The strongest market studies use public series for the long trend and a listings pull for the current quarter, and reconcile the two. The US Rentals Scraper covers the rental side; the Zillow Suite Scraper covers for-sale and sold inventory if your study needs both tenures.

How to build a metro rental snapshot in 4 steps

How do I build the ZIP list for a metro?

Start from the metro's constituent counties and take every ZIP Code Tabulation Area inside them. The Census Bureau gazetteer files publish this as a plain text table, so the list is a data file in your repo rather than something you retype.

AUSTIN_METRO_ZIPS = [
    "78701", "78702", "78703", "78704", "78705", "78717", "78719",
    "78721", "78722", "78723", "78724", "78725", "78726", "78727",
    "78728", "78729", "78730", "78731", "78732", "78733", "78734",
    "78735", "78736", "78737", "78738", "78739", "78741", "78742",
    "78744", "78745", "78746", "78747", "78748", "78749", "78750",
    "78751", "78752", "78753", "78754", "78756", "78757", "78758", "78759",
]

Keep the list versioned. A rental series is only comparable across months if the geography is identical, and the fastest way to corrupt a time series is to quietly add a ZIP in month three.

How do I pull the whole metro without hitting a pagination ceiling?

Pass every ZIP as its own entry in queries and leave includeDetails off. A market snapshot needs rent, bedrooms, property type and location on thousands of rows — not the full photo gallery of each one. Detail mode is for the comp work you do afterwards on a shortlist.

import os, requests, pandas as pd

ACTOR = "thirdwatch~us-rentals-scraper"
TOKEN = os.environ["APIFY_TOKEN"]

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "queries": AUSTIN_METRO_ZIPS,
        "source": "both",
        "maxResults": 150,
        "includeDetails": False,
        "proxyConfiguration": {"useApifyProxy": True, "apifyProxyCountry": "US"},
    },
    timeout=7200,
)
df = pd.DataFrame(resp.json())
df["scraped_month"] = pd.Timestamp.utcnow().strftime("%Y-%m")
print(f"{len(df)} listings, {df['zip_code'].nunique()} ZIP codes, "
      f"{df.groupby('source').size().to_dict()}")

maxResults is per query per source, so 150 with source: "both" requests up to 300 rows per ZIP. That is deliberately generous — you want the ZIP exhausted, not truncated, because a truncated ZIP silently biases the median toward whatever the site sorts first.

How do I compute median asking rent by ZIP and bedroom count?

Use price_min as the asking rent for the row, and beds_min as the bedroom count, since large communities publish a range rather than a single figure. Report the median, the count and the interquartile spread together — a median over four listings is not a statistic.

df["ask"] = df["price"].fillna(df["price_min"])
df["beds_n"] = df["beds"].fillna(df["beds_min"])
clean = df.dropna(subset=["ask", "beds_n", "zip_code"])
clean = clean[clean["ask"].between(400, 20000)]

by_zip = (
    clean.groupby(["zip_code", "beds_n"])["ask"]
    .agg(n="count", median="median",
         p25=lambda s: s.quantile(0.25), p75=lambda s: s.quantile(0.75))
    .reset_index()
)
reportable = by_zip[by_zip["n"] >= 8]
print(reportable.sort_values(["beds_n", "median"], ascending=[True, False]).head(20))

The between(400, 20000) guard removes the two failure modes that wreck a median: a per-week or per-night figure that slipped into a monthly field, and a corporate-housing listing priced for a whole floor.

How do I read supply composition, not just price?

This is where listings beat any rent index. Group the same frame by vintage, property type and community size, and you can see what the market is actually offering.

clean["vintage"] = pd.cut(
    clean["year_built"],
    bins=[0, 1979, 1999, 2014, 2100],
    labels=["pre-1980", "1980-1999", "2000-2014", "2015+"],
)

mix = (
    clean.pivot_table(index="zip_code", columns="beds_n",
                      values="listing_id", aggfunc="count")
    .fillna(0).astype(int)
)
mix["share_studio_1br"] = (
    mix.get(0.0, 0) + mix.get(1.0, 0)
) / mix.sum(axis=1)

concentration = (
    clean[clean["source"] == "apartments"]
    .groupby("zip_code")["listing_id"].nunique()
    .rename("communities")
    .to_frame()
    .join(clean.groupby("zip_code")["listing_id"].count().rename("listings"))
)
print(mix.sort_values("share_studio_1br", ascending=False).head(10))

Two findings usually fall out immediately. ZIP codes with a high studio-and-one-bedroom share are the ones absorbing new construction, and ZIP codes where a handful of listing_id values account for most of the advertised inventory are landlord-concentrated — a single community's pricing decision moves the whole submarket median there.

Then re-run the same input every month on an Apify schedule, append with the scraped_at timestamp, and after four runs you have a series rather than a snapshot.

Sample output

Two rows from a metro pull, one per source, trimmed to the fields a market snapshot reads.

[
  {
    "listing_id": "s286zb6",
    "source": "apartments",
    "source_query": "78758",
    "listing_name": "The Watson",
    "address": "11901 Burnet Rd, Austin, TX 78758",
    "city": "Austin", "state": "TX", "zip_code": "78758",
    "latitude": 30.40504, "longitude": -97.71511,
    "price": null, "price_min": 1615, "price_max": 4884,
    "price_formatted": "$1,615 - $4,884",
    "currency": "USD",
    "beds_min": 0.0, "beds_max": 2.0,
    "square_feet_min": 425, "square_feet_max": 1403,
    "property_type": "apartment",
    "specials": "2 Months Free",
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:04:48+00:00"
  },
  {
    "listing_id": "4471203856",
    "source": "realtor",
    "source_query": "78704",
    "listing_name": "2107 Ford St",
    "address": "2107 Ford St, Austin, TX 78704",
    "city": "Austin", "state": "TX", "zip_code": "78704",
    "price": 2950, "price_min": 2950, "price_max": 2950,
    "beds": 2.0, "baths": 1.0, "square_feet": 1080,
    "property_type": "house",
    "list_date": "2026-08-19T00:00:00+00:00",
    "days_on_market": 19,
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:05:02+00:00"
  }
]

Note the asymmetry, because it drives every grouping decision. The community row has price null and a price_min/price_max band across its unit mix, so it contributes one observation at the low end of its range unless you enrich it. The single-family row has scalar price, beds, baths and square_feet. days_on_market and list_date appear on Realtor.com rows only — Apartments.com does not publish a list date, so those fields stay empty on its rows.

Common pitfalls

Comparing sources without segmenting. Apartments.com skews to managed communities, Realtor.com to houses and condos. A metro-level median across both is a weighted average of two different housing stocks. Group by source and property_type first.

Letting a truncated ZIP into the aggregate. If a ZIP returns exactly maxResults rows, assume it was cut short and either raise the cap or split the ZIP by property type. A truncated ZIP biases the median in whatever direction the site sorts.

Treating price_min as the community's rent. For a large community it is the cheapest unit. That is fine as a consistent, comparable statistic across ZIPs, but say so in the methodology note — it is a floor, not a central tendency.

Comparing months across a changed ZIP list or changed filters. Freeze the input. A rental time series is only as good as the constancy of its geography and filters.

Assuming a ZIP query returns only that ZIP. Both sites bleed into adjacent ZIPs on a ZIP search. Group on the zip_code field, which is the listing's true ZIP. Thirdwatch's Actor also fails loudly rather than returning an empty set when a source does not answer, so a genuinely thin submarket is never confused with a failed pull.

Related use cases

Frequently asked questions

How many ZIP codes do I need to cover a metro?

A mid-sized metro is typically twenty-five to sixty ZIP codes, a large one over a hundred. Pull the full list from the Census Bureau ZCTA gazetteer for the metro's counties, then pass them as separate entries in queries so no single search hits a pagination ceiling.

Is asking rent the same as market rent?

No. Asking rent is what landlords advertise on currently vacant units, which is a leading and slightly biased indicator. It moves before signed rents move and skews toward whatever is vacant. Read it alongside concessions and days on market rather than alone.

Can I compare Apartments.com and Realtor.com numbers directly?

Compare them by segment, not in aggregate. Apartments.com is weighted toward professionally managed communities, Realtor.com toward single-family and condo rentals. Group by source and property_type before you compare, or you are measuring stock composition rather than price.

How often should I refresh a metro snapshot?

Monthly is the right cadence for a market report. Asking rent moves slowly enough that weekly pulls mostly add noise, and a monthly series with a consistent ZIP list and consistent filters gives you a clean time series after three or four runs.

How do I handle listings that appear in two ZIP queries?

The Actor de-duplicates on listing_id within a run, so overlapping searches are safe. Group your analysis on the zip_code field, which is the listing's true ZIP, rather than source_query, which is the search that found it.

Related

Try it yourself

100 free credits, no credit card.

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