Rental arbitrage is the play people love to hate: you lease an apartment long-term, get written permission from the landlord to sublet it on Airbnb, and keep the spread between what tourists pay per night and what you pay per month. No property purchase. No mortgage. The internet is full of gurus selling courses on it — which is exactly why most people dismiss it. The smarter move is to ignore the hype and treat it as a data problem: which units, in which neighbourhoods, at which price points actually leave a margin after Airbnb takes its cut?

That question is answerable for free. Inside Airbnb publishes complete snapshots of Airbnb’s public listings for dozens of cities — every listing with its nightly price, minimum stay, room type, availability calendar and neighbourhood — licensed under Creative Commons CC-BY 4.0. No API key. No scraping. When I pulled the Bangkok snapshot for this article it contained 30,922 listings scraped on 2026-09-22, and it’s a 17.8 MB gzipped CSV.

This article gives you a working Python screener (tested end to end, exit code 0) that reduces those 30,922 rows to a ranked shortlist of arbitrage candidates, walks through the economics with real output, and then covers the part no screener can do for you: locking the deal with the landlord and staying on the right side of the rules that killed this play in cities like New York and Paris.

Where the money actually is

The core spread looks like this. A studio in a tourist-heavy part of Bangkok rents long-term for maybe 25,000–30,000 baht a month (~$750–900). The same unit on Airbnb goes for roughly $55–65 a night. Book it just 60% of the time and you gross around $1,000–1,200 a month from a unit that costs you $800 in rent, cleaning and fees. Thin? Yes — on one unit. That’s why this is a portfolio business: the screener’s job is finding the units where the margin is widest, and the operator’s job is stacking units so the fixed work (cleaner, locksmith, guest messaging) amortizes across all of them.

One thing worth internalizing before the code: short-term rents roughly track long-term rents plus a premium, and that premium compresses when supply floods in. Inside Airbnb’s availability_365 column is your early-warning system — it tells you how many nights each listing is still open for booking this year. A neighbourhood where listings are wide open (250+ available nights) behaves differently from one where hosts are fully booked or blocked. The screener uses it as a market-health filter.

The currency trap (I fell into it so you don’t have to)

Here’s the gotcha that would have wrecked every number in this article. Inside Airbnb’s price column looks like dollars — the Bangkok CSV literally renders it as $1,338.49. But it is not USD. It’s the city’s local currency with a dollar sign painted on it.

I verified this empirically before trusting a single number. The Athens snapshot (September 2026) has a median whole-unit price of ~112 — clearly euros. Buenos Aires shows a median of ~110,075 — clearly Argentine pesos. Bangkok’s median of ~1,756 only makes sense as Thai baht (at the mid-market rate of ฿33.57 per $1, per Xe, that’s ~$52/night, which is exactly what a Bangkok one-bedroom on Airbnb costs). Inside Airbnb’s own documentation doesn’t shout about this, so the screener below makes the conversion explicit and forces you to type in the current FX rate before running it on a new city. Check xe.com, set LOCAL_FX, and the numbers are real. Skip that step and you’ll be pricing Bangkok apartments at a million dollars a night like my first run did.

The screener

What it does, in order:

  1. Reads the gzipped snapshot.
  2. Keeps only Entire home/apt listings — private rooms don’t work for rental arbitrage because you can’t re-let a room in a unit you control.
  3. Converts nightly price from local currency to USD and sanity-caps it ($10–$300/night) to drop mansions and data errors.
  4. Filters to short minimum stays (≤7 nights — long minimums kill turnover and attract the wrong landlord conversation).
  5. Filters to active markets (availability_365 ≥ 250).
  6. Computes monthly economics: gross at 60% occupancy, minus Airbnb’s 15.5% host-only fee, plus net cleaning margin, minus an implied rent budget.
  7. Keeps units clearing a 15% monthly profit-on-rent margin and ranks everything.

The fee number matters: Airbnb now deducts a single 15.5% host-only fee from most hosts — rolled out through 2026 — and it’s charged on the full booking subtotal including your cleaning fee. The old split model (≈3% host, ~14% guest) still exists in some markets, but you should underwrite with 15.5% so a surprise model switch doesn’t gut your margin.

#!/usr/bin/env python3
"""Rental arbitrage deal screener.

Reads a free Inside Airbnb `listings.csv.gz` snapshot (CC-BY 4.0),
filters whole-unit listings down to rental-arbitrage candidates and
prints two tables:

  1. NEIGHBOURHOOD VIEW  - where the viable units cluster, with median
     nightly price, implied rent budget and monthly profit at 60% occupancy.
  2. BEST-ROI UNITS      - individual units with the best profit-to-rent
     margin in the normal apartment band (<= $150/night, 2-7 night minimum).

IMPORTANT: the `price` column in Inside Airbnb snapshots is in the
CITY'S LOCAL CURRENCY, not USD (verified: Bangkok prices are Baht,
Athens are EUR, Buenos Aires are ARS). Set LOCAL_FX to the current
local-currency-per-USD rate (check xe.com) before running on a city.

Inside Airbnb snapshot used for testing (Bangkok, scraped 2026-09-22):
https://data.insideairbnb.com/thailand/central-thailand/bangkok/2026-09-22/data/listings.csv.gz
"""
import csv
import gzip
import re
import statistics
import sys

# ---------------- assumptions (edit these, everything recalculates) ----------
LOCAL_FX = 33.6           # THB per USD (mid-market, Oct 2026) - change per city
RENT_MARKUP = 1.30        # STR nightly rent is ~30% above long-term rent, same unit
OCCUPANCY = 0.60          # conservative: unit booked 60% of nights
AIRBNB_HOST_FEE = 0.155   # Airbnb host-only fee (charged on cleaning fees too)
MAX_MIN_NIGHTS = 7        # landlord-friendly: short minimum stays only
MIN_AVAIL_DAYS = 250      # still bookable >=250 nights/yr -> active market
MAX_NIGHTLY_USD = 300     # sanity cap: above this is a mansion or bad data
UNIT_MAX_NIGHTLY = 150    # "normal apartment" band for the best-ROI table
UNIT_MIN_NIGHTS = 2       # skip 1-night churn units in the best-ROI table
MIN_MARGIN = 0.15         # unit must clear 15% monthly profit-on-rent
CLEANING_FEE = 25.0       # USD charged to the guest per turnover
CLEANING_COST = 18.0      # USD paid to the cleaner per turnover
MIN_RENT_BUDGET = 400.0   # ignore studio-level numbers below this monthly rent
MIN_HOOD_DEALS = 25       # neighbourhoods need >=25 viable units to make the table
# -----------------------------------------------------------------------------

NUM_RE = re.compile(r"[^\d.]")


def parse_number(raw):
    if not raw:
        return None
    try:
        return float(NUM_RE.sub("", raw))
    except ValueError:
        return None


def unit_economics(nightly_usd, min_nights):
    """Return (rent_budget, profit_month) for one unit."""
    nights_per_month = 30.4 * OCCUPANCY
    gross_monthly = nightly_usd * nights_per_month
    net_nightly = gross_monthly * (1 - AIRBNB_HOST_FEE)
    turnovers = nights_per_month / max(min_nights, 1)
    net_cleaning = turnovers * CLEANING_FEE * (1 - AIRBNB_HOST_FEE) \
        - turnovers * CLEANING_COST
    rent_budget = gross_monthly / RENT_MARKUP
    profit = net_nightly + net_cleaning - rent_budget
    return rent_budget, profit


def screen(csv_path):
    stats = {"total": 0, "entire": 0, "price_ok": 0, "min_nights_ok": 0,
             "avail_ok": 0, "rent_ok": 0, "margin_ok": 0}
    deals = []

    opener = gzip.open if csv_path.endswith(".gz") else open
    with opener(csv_path, "rt", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f):
            stats["total"] += 1
            if row.get("room_type") != "Entire home/apt":
                continue
            stats["entire"] += 1

            nightly_local = parse_number(row.get("price", ""))
            if nightly_local is None or nightly_local <= 0:
                continue
            nightly_usd = nightly_local / LOCAL_FX
            if not (10 <= nightly_usd <= MAX_NIGHTLY_USD):
                continue
            stats["price_ok"] += 1

            try:
                min_nights = int(row.get("minimum_nights") or 0)
                avail = int(row.get("availability_365") or 0)
            except ValueError:
                continue
            if not (1 <= min_nights <= MAX_MIN_NIGHTS):
                continue
            stats["min_nights_ok"] += 1
            if avail < MIN_AVAIL_DAYS:
                continue
            stats["avail_ok"] += 1

            rent_budget, profit = unit_economics(nightly_usd, min_nights)
            if rent_budget < MIN_RENT_BUDGET:
                continue
            stats["rent_ok"] += 1
            margin = profit / rent_budget
            if margin < MIN_MARGIN:
                continue
            stats["margin_ok"] += 1

            deals.append({
                "neighbourhood": row.get("neighbourhood_cleansed", "?"),
                "nightly_usd": nightly_usd,
                "min_nights": min_nights,
                "rent_budget": round(rent_budget),
                "profit_month": round(profit),
                "margin": margin,
            })

    return deals, stats


def main():
    csv_path = sys.argv[1] if len(sys.argv) > 1 else "bangkok_listings.csv.gz"
    deals, stats = screen(csv_path)

    print(f"rows read           : {stats['total']:,}")
    print(f"entire home/apt     : {stats['entire']:,}")
    print(f"usable nightly USD  : {stats['price_ok']:,}")
    print(f"min_nights <= {MAX_MIN_NIGHTS}     : {stats['min_nights_ok']:,}")
    print(f"availability >= {MIN_AVAIL_DAYS}   : {stats['avail_ok']:,}")
    print(f"rent >= ${MIN_RENT_BUDGET:.0f}        : {stats['rent_ok']:,}")
    print(f"margin >= {MIN_MARGIN:.0%}       : {stats['margin_ok']:,}")
    print()

    # ---- table 1: neighbourhood view ----
    hoods = {}
    for d in deals:
        hoods.setdefault(d["neighbourhood"], []).append(d)
    rows = []
    for hood, ds in hoods.items():
        if len(ds) < MIN_HOOD_DEALS:
            continue
        rows.append({
            "neighbourhood": hood,
            "deals": len(ds),
            "med_nightly": round(statistics.median(x["nightly_usd"] for x in ds), 1),
            "med_rent": round(statistics.median(x["rent_budget"] for x in ds)),
            "med_profit": round(statistics.median(x["profit_month"] for x in ds)),
        })
    rows.sort(key=lambda r: r["med_profit"], reverse=True)

    header = (f"{'neighbourhood':<22}{'deals':>7}{'med nightly':>13}"
              f"{'med rent/mo':>13}{'med profit/mo':>15}")
    print("NEIGHBOURHOOD VIEW (min {} viable units)".format(MIN_HOOD_DEALS))
    print(header)
    print("-" * len(header))
    for r in rows[:12]:
        print(f"{r['neighbourhood']:<22}{r['deals']:>7}"
              f"${r['med_nightly']:>12,.2f}"
              f"${r['med_rent']:>12,.0f}"
              f"${r['med_profit']:>14,.0f}")
    print()

    # ---- table 2: best-ROI units in the normal apartment band ----
    band = [d for d in deals
            if d["nightly_usd"] <= UNIT_MAX_NIGHTLY
            and d["min_nights"] >= UNIT_MIN_NIGHTS]
    band.sort(key=lambda d: d["margin"], reverse=True)

    header2 = (f"{'neighbourhood':<22}{'nightly':>9}{'min_n':>7}"
               f"{'rent/mo':>10}{'profit/mo':>11}{'margin':>8}")
    print(f"BEST-ROI UNITS (<= ${UNIT_MAX_NIGHTLY}/night, "
          f"min nights >= {UNIT_MIN_NIGHTS})")
    print(header2)
    print("-" * len(header2))
    for d in band[:10]:
        print(f"{d['neighbourhood']:<22}"
              f"${d['nightly_usd']:>8,.2f}"
              f"{d['min_nights']:>7}"
              f"${d['rent_budget']:>9,.0f}"
              f"${d['profit_month']:>10,.0f}"
              f"{d['margin']:>8.0%}")

    out = "deals.csv"
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(deals[0].keys()))
        w.writeheader()
        w.writerows(deals)
    print(f"\n{len(deals)} deals written to {out}")


if __name__ == "__main__":
    main()

What real output looks like

This ran on the actual Bangkok snapshot, exit code 0, on a plain Python 3 install with zero dependencies:

rows read           : 30,922
entire home/apt     : 20,811
usable nightly USD  : 18,601
min_nights <= 7     : 11,659
availability >= 250 : 7,963
rent >= $400        : 7,358
margin >= 15%       : 2,377

NEIGHBOURHOOD VIEW (min 25 viable units)
neighbourhood           deals  med nightly  med rent/mo  med profit/mo
----------------------------------------------------------------------
Bang Rak                   87$       61.60$         864$           142
Khlong Toei               452$       59.70$         838$           140
Vadhana                   405$       58.30$         818$           138
Phra Khanong              114$       57.60$         809$           136
Huai Khwang               336$       54.00$         758$           132
Phaya Thai                 79$       51.90$         729$           129
Ratchathewi                92$       51.80$         727$           129
Phra Nakhon                42$       52.30$         734$           129
Yan na wa                  30$       50.60$         710$           127
Lat Krabang                27$       48.20$         676$           124
Chatu Chak                 69$       46.20$         648$           121
Bang Na                    44$       44.20$         620$           118

If you know Bangkok even slightly, this table passes the smell test. Khlong Toei and Vadhana are Sukhumvit — the densest Airbnb corridor in the city, 452 and 405 viable units respectively. Huai Khwang is Ratchada, a legitimate second cluster. Bang Rak covers the riverside/Sathorn area. The screener isn’t inventing geography; it’s recovering it from raw data.

Read the columns right, though: median profit per unit is ~$120–140/month. That is the honest number for a single Bangkok apartment at 60% occupancy after fees and cleaning. Anyone promising $3,000/month from one leased condo is selling a course. The profitable version of this business is breadth: the same snapshot leaves 2,377 units clearing a 15% margin, and your shortlist is how you find which buildings they’re in. Five units at ~$130 = $650/month for work that’s mostly the same work as one unit. Twenty units is a ~$2,600/month operation where your cleaner and your messaging templates are doing the heavy lifting. The economics only get interesting at portfolio scale, and the screener is what makes portfolio-scale targeting possible.

The deals.csv file the script writes is your actual working artifact: neighbourhood, nightly price, minimum nights, implied rent budget, monthly profit and margin for all 2,377 survivors. Sort by neighbourhood, and you’ll see whole buildings repeated — those are your first phone calls.

From shortlist to signed lease

The screener gets you to candidate buildings, not candidate deals. The closing sequence matters more than the analysis:

1. Identify the actual landlord. In Bangkok and much of Southeast Asia, condos are individually owned and many are already on Airbnb — the listing you found in the data may already be the owner running their own unit. That’s not your deal. Your deal is the long-term rental listing on the floor below: check local long-let platforms and Facebook groups for the same buildings, and ask specifically for owners who “don’t want the hassle.”

2. Get the sublet clause in writing. The conversation is simple and honest: “I want to lease your unit on a 12-month contract, furnished, and I’ll be renting it out short-term. I handle cleaning, guest problems, and building compliance; you get guaranteed rent every month with zero vacancy risk.” Many owners hate hosting but love guaranteed income. What kills you is the verbal yes — get “short-term rental permitted with written landlord consent” into the lease itself, because the first neighbour complaint will test exactly how real that permission was.

3. Underwrite with the building, not just the owner. Condo juristic offices (or HOAs in the West) often have their own short-term rental rules that supersede what a friendly owner tells you. A deal the building can evict is not a deal.

4. Stage one unit, then scale the building. Prove the unit economics on one lease before signing a second. Your real unit-level numbers — actual occupancy, actual cleaning costs, actual nightly rates — go back into the screener’s assumption block, and the whole model sharpens with every month of your own data.

The regulatory wall — check it before you spend a dollar

This is where rental arbitrage dies quietly, and it’s non-negotiable due diligence:

  • New York City — Local Law 18 (adopted January 2022) requires hosts to register with the Mayor’s Office of Special Enforcement, and its rules make unregistered short-term rentals of whole units effectively impossible. Not a market for this play; the city’s own materials frame it as a registration system, and registration is the wall.
  • Paris — primary residences are capped at 90 nights per year (Paris cut the former 120-night limit using powers from the Loi LeMeur), require a registration number in the listing, and unauthorized rentals draw fines up to €100,000 per unit. That’s not theoretical: a Paris studio owner was fined €110,000 for illegal rentals, per an October 2026 report in The Connexion. Rental arbitrage (which by definition runs second units at full availability) does not survive that regime.
  • Thailand and much of Southeast Asia — the practical gate is the building, not the state: condo juristic rules and, for foreigners, visa/work-permit realities around operating a business. The data snapshot shows a market with tens of thousands of active listings, but “many people do it” is not legal advice — structure your presence locally and get real advice if you scale.

The screener’s availability_365 filter is also a crude regulatory sensor: cities where the government has strangled STR supply tend to show thin, stale listing inventories rather than the 7,900-unit deep, wide-open market Bangkok shows. If your target city’s snapshot looks starved, assume there’s a reason.

Honest risk stack

  • Occupancy is an assumption, not a guarantee. 60% is conservative for Bangkok’s tourist volumes, but a bad monsoon season or a soft economy can pin you at 40% — where every fixed cost comes out of your own pocket. The screener prints profit at your occupancy setting; stress-test it at 40% before signing anything.
  • FX risk. Your rent is in baht, your revenue (if you’re foreign) is notionally in dollars. The ฿33.6 rate used here moves; a 10% baht rally eats a third of a thin margin.
  • Landlord pull-out. The single biggest operational risk. A 12-month lease with a renewal option and the sublet clause embedded is your only real protection.
  • Platform dependency. One Airbnb account suspension — guest dispute, neighbourhood complaint, ID verification glitch — halts revenue across your whole portfolio overnight. Keep listings compliant, respond fast, and know your local OTA alternatives (Booking.com dominates Southeast Asian short-term bookings too).
  • Margin is thin by design. This model runs on volume and operational tightness, not on any single brilliant deal. If you want fat margins per unit, this isn’t the play.

Verdict

Rental arbitrage earns its “grey” reputation from the landlord conversation, not from the law — done with written consent and a compliant building, it’s a legal business that happens to exploit a pricing inefficiency between two rental markets. The inefficiency is real, measurable, and now free to measure: Inside Airbnb hands you the entire demand side of the market under CC-BY, and a 100-line script turns it into a ranked target list with real dollar figures attached.

The sequence that works: run the screener on a city you can actually visit, shortlist the buildings with clusters of 15%+ margin units, find their long-term listings, sign one lease with the clause in writing, and let twelve months of your own operating data replace the assumptions in the model. The people who lose money on this play are the ones who skipped straight to leasing. The screener costs you nothing but an evening — the leases cost real money, so let the data pick them first.