Skip to main content
Thirdwatchthirdwatch
Real estate

Track US Rent Concessions and Lease-Up Specials by Metro

Free-month offers move before asking rent does. Parse the specials field across a metro's ZIP codes to compute effective rent and spot soft submarkets early.

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

Concessions move before asking rent does. Run the US Rentals Scraper across a metro's ZIP codes, read the specials field on every row, parse it into months of free rent, and compute effective rent as price_min × (12 − months) ÷ 12. Track the share of listings offering a concession by ZIP over time and you have the earliest reliable softening indicator in rental housing. Built for asset managers, acquisition teams and investors testing a market thesis.

Why concessions are the earliest signal of a softening rental market

A landlord facing weak demand has three levers, and uses them in a strict order. First the unit sits. Then a concession appears — a free month, a waived admin fee, a look-and-lease bonus. Only last does the advertised rent come down. That order is not accidental: a concession is reversible and invisible in the headline number, while a rent cut resets the anchor for every renewal in the building and is immediately visible to every competing property.

The consequence is that any market series built on asking rent is looking at the last lever to move. Concessions are the first, and they are published in plain text on the listing.

They matter most where new supply is landing. Census Bureau new residential construction data has shown multifamily completions running at their highest levels in decades through the recent cycle, and a building in lease-up has to fill hundreds of units in a few quarters. Lease-up concessions are how that happens, and they set the floor for every stabilised property within a mile. If you are underwriting an acquisition, testing a market-entry thesis, or defending occupancy at an existing asset, the concession map of a submarket is the fact you need and the one that is hardest to buy.

How does this compare to the alternatives?

Concession data is unusually badly served. Rent indices strip it out or model it. Broker surveys capture it anecdotally for whichever properties got called. Your own leasing team knows the two competitors across the street. A listings pull is the only method that gives you every advertised concession in a submarket, on a repeatable cadence, with the asking rent attached to it.

Approach Cost model Reliability Setup time Maintenance
Broker or leasing-team shop reports Analyst and broker hours Anecdotal; covers whoever answered the phone Days per submarket Redo it every month
Market-data subscription Annual licence Concessions usually modelled, not observed Procurement cycle Vendor handles it
DIY Python scraper on cron Your servers and infrastructure Breaks on layout changes; you own the snapshot store 3-7 days You own every breakage
Thirdwatch US Rentals Scraper Transparent pay-per-result Maintained; specials returned as published text 15 minutes Thirdwatch tracks site changes

The US Rentals Scraper returns specials alongside rent, unit mix and location on the same row, so effective rent is one calculation rather than a join across two vendors.

How to build a concession tracker in 4 steps

How do I pull the inventory that carries concessions?

Concessions are an apartment-community practice, so weight the pull toward Apartments.com. Pass ZIP codes as separate entries in queries — a single city query stops paginating well before a metro is exhausted — and leave includeDetails off, since specials and price_min both come back on the search card.

import os, requests, pandas as pd

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

METRO_ZIPS = ["33125", "33127", "33130", "33131", "33132",
              "33137", "33139", "33142", "33145", "33147"]

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "queries": METRO_ZIPS,
        "source": "both",
        "maxResults": 200,
        "includeDetails": False,
        "propertyTypes": ["apartments", "condos", "townhomes"],
        "proxyConfiguration": {"useApifyProxy": True, "apifyProxyCountry": "US"},
    },
    timeout=3600,
)
df = pd.DataFrame(resp.json())
has_special = df["specials"].notna() & (df["specials"].astype(str).str.strip() != "")
print(f"{has_special.sum()} of {len(df)} listings advertise a concession")

Running source: "both" keeps single-family rentals in the frame as a control group. They rarely advertise concessions, so a rising concession share among houses is itself a strong signal.

How do I parse the specials text into months of free rent?

specials is the string the listing publishes — "2 Months Free", "6 Weeks Free", "$500 Off First Month", "Look and Lease". Match the common shapes with regex, and keep anything unmatched as raw text rather than discarding it.

import re

MONTHS = re.compile(r"(\d+(?:\.\d+)?)\s*months?\s*free", re.I)
WEEKS = re.compile(r"(\d+(?:\.\d+)?)\s*weeks?\s*free", re.I)
DOLLARS = re.compile(r"\$\s*([\d,]+)\s*(?:off|free|credit)", re.I)

def months_free(text, ask):
    if not isinstance(text, str) or not text.strip():
        return 0.0, "none"
    if m := MONTHS.search(text):
        return float(m.group(1)), "months"
    if m := WEEKS.search(text):
        return float(m.group(1)) / 4.33, "weeks"
    if (m := DOLLARS.search(text)) and ask:
        return float(m.group(1).replace(",", "")) / ask, "dollars"
    return 0.0, "unparsed"

df["ask"] = df["price"].fillna(df["price_min"])
parsed = df.apply(lambda r: months_free(r.get("specials"), r["ask"]), axis=1)
df["months_free"] = [p[0] for p in parsed]
df["special_kind"] = [p[1] for p in parsed]

print(df["special_kind"].value_counts())

Watch the unparsed count. A phrase like "Look and Lease" is a real concession with no monetary value you can extract, and an unparsed share that suddenly grows usually means landlords have moved to a new kind of offer — which is a finding, not a bug.

How do I compute effective rent and rank submarkets?

Spread the concession across a twelve-month lease. That single line turns an advertised number into something you can compare against your own rent roll.

LEASE_MONTHS = 12

df["effective_rent"] = df["ask"] * (LEASE_MONTHS - df["months_free"]) / LEASE_MONTHS
df["concession_pct"] = 1 - df["effective_rent"] / df["ask"]

by_zip = (
    df.dropna(subset=["ask", "zip_code"])
    .groupby("zip_code")
    .agg(
        listings=("listing_id", "nunique"),
        concession_share=("months_free", lambda s: (s > 0).mean()),
        median_ask=("ask", "median"),
        median_effective=("effective_rent", "median"),
        avg_months_free=("months_free", lambda s: s[s > 0].mean()),
    )
    .round(3)
    .sort_values("concession_share", ascending=False)
)
print(by_zip)

Two columns carry the thesis. concession_share is the proportion of advertised inventory buying occupancy, which is the softness measure. The gap between median_ask and median_effective is what a headline rent index is missing in that ZIP.

How do I turn it into a time series and an alert?

Schedule the same input weekly on an Apify schedule, append each pull with its scraped_at value, and watch the share rather than the level. A ZIP where concession share went from four percent to nineteen percent in six weeks is the one to look at, whatever its absolute rent.

curl -X POST "https://api.apify.com/v2/schedules?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "miami-concession-tracker-weekly",
    "cronExpression": "0 6 * * 1",
    "timezone": "America/New_York",
    "isEnabled": true,
    "actions": [{
      "type": "RUN_ACTOR",
      "actorId": "thirdwatch~us-rentals-scraper",
      "runInput": {
        "queries": ["33125", "33127", "33130", "33131", "33132",
                    "33137", "33139", "33142", "33145", "33147"],
        "source": "both",
        "maxResults": 200,
        "includeDetails": false
      }
    }]
  }'

Cross-reference against year_built. A concession on a 2025 or 2026 building is lease-up pricing and expected. The same concession on 1990s product is genuine distress, and it is the second case that should page an asset manager.

Sample output

Two rows from a concession pull. The first is a new community in lease-up running an aggressive offer; the second is stabilised product in the same metro with no concession.

[
  {
    "listing_id": "s286zb6",
    "source": "apartments",
    "source_query": "78758",
    "listing_url": "https://www.apartments.com/the-watson-austin-tx/s286zb6/",
    "listing_name": "The Watson",
    "address": "11901 Burnet Rd, Austin, TX 78758",
    "zip_code": "78758",
    "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",
    "year_built": 2026,
    "specials": "2 Months Free",
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:04:48+00:00"
  },
  {
    "listing_id": "s7k41mp",
    "source": "apartments",
    "source_query": "78745",
    "listing_name": "Ridgemont Commons",
    "address": "5410 Manchaca Rd, Austin, TX 78745",
    "zip_code": "78745",
    "price": null, "price_min": 1245, "price_max": 1810,
    "beds_min": 1.0, "beds_max": 2.0,
    "property_type": "apartment",
    "year_built": 1994,
    "specials": null,
    "listing_status": "for_rent",
    "scraped_at": "2026-09-07T22:04:51+00:00"
  }
]

specials is null on the second row, which is the normal state — most listings carry no concession at any given moment, so a populated value is the event you are counting. price_min is the base for the effective-rent calculation, year_built separates lease-up pricing from distress, and zip_code is the grouping key. Multi-unit communities have price null by design and publish a range, which is why price_min rather than price anchors the maths.

Common pitfalls

Treating concession share as a level rather than a trend. Some submarkets always run concessions because they always have new supply. The signal is the change against that ZIP's own trailing baseline.

Dropping unparsed specials. "Look and Lease", "Waived Admin Fee" and "Reduced Deposit" are real offers with no clean monetary value. Count them as concessions with zero months free rather than as no concession at all, and report the unparsed share alongside the headline number.

Applying the concession to the wrong rent. For a community, price_min is the cheapest unit in the building. The concession is usually offered on select units, not all of them. Use includeDetails and the floorplans array when you need to know which plan the offer actually attaches to.

Comparing across a changing query set. Add ZIPs mid-series and the metro-level share jumps for structural reasons. Freeze the input the way you would freeze a survey panel.

Confusing a blocked run with a market with no concessions. A run that returns nothing because a source did not answer is not a market where nobody is discounting. Thirdwatch's Actor distinguishes the two — a source that answers with a genuinely empty result set succeeds with a message asking you to widen the filters, while a source that never answered fails and names itself, so a bad day never enters the series as a data point.

Related use cases

Frequently asked questions

What is the difference between asking rent and effective rent?

Asking rent is the advertised monthly number. Effective rent spreads any concession across the lease term, so two months free on a twelve-month lease is an effective rent about seventeen percent below the ask. Landlords prefer concessions precisely because the headline number stays intact.

Which field carries the concession?

The specials field, as the free-text string the listing advertises, such as 2 Months Free or Look and Lease. It is empty when no concession is advertised, which is the normal state, so treat a populated specials as the event rather than the baseline.

Do both sources publish concessions?

Concessions are overwhelmingly an apartment-community practice, so populated specials values come mostly from Apartments.com rows. Single-family rentals rarely advertise a free month. Run source both if you want the whole market, but expect the concession signal to sit on the community side.

How reliable is parsing free text into a number?

Regex handles the common patterns well: a number of months or weeks free, a dollar amount off, and a waived fee. Anything unmatched should be kept as raw text and counted separately rather than silently dropped, because the unmatched share is itself a data-quality metric.

How early does the concession signal actually appear?

Before asking rent moves and usually alongside a rise in days on market. Landlords cut the ask last because it resets the anchor for renewals and is visible to every competitor. A concession is the reversible version of a rent cut, which is why it comes first.

Related

Try it yourself

100 free credits, no credit card.

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