A flat in Villaverde pays back its asking price in 13.9 years of asking rent. In Barrio de Salamanca, the same calculation gives 32.3 years.
I wanted to know how wide that gap really is across Madrid, and how much of it survives once you clean the data properly. So I pulled for-sale and for-rent listings for all 21 Madrid districts from Idealista with Python, computed a gross rental yield per district, and then went looking for everything that could distort it.
The data
Idealista's own developer API is granted by application, so for this I used the Happy Endpoint Idealista API, which returns the public search results as JSON. Two endpoints did all the work:
-
/sublocationswith Madrid's location ID (0-EU-ES-28-07-001-079) lists the 21 districts with their location IDs. -
/property-searchwithsearch_type=for_saleorfor_rent,property_type=homes,sort_order=newestandresult_count=50returns the 50 most recent ads per district.
I took one page of 50 for every district and operation, and a second page for seven searches where the first page left a thin sample after cleaning. That came to 50 API calls and 2,280 listings (1,200 for sale, 1,080 for rent), collected on 24 October 2026.
The yield is the simplest version that people actually quote:
gross yield = 12 × median rent per m² per month ÷ median sale price per m²
Both medians are taken over flats in the same district, so it is a district-level estimate built from asking prices, not the yield of any particular flat.
The script
This is the whole thing. It caches every response in data/raw/, so you only pay for the calls once and can rerun the analysis as often as you like. You need a RapidAPI key in RAPIDAPI_KEY, plus requests and pandas.
"""Gross rental yield by Madrid district from Idealista asking prices.
Usage: RAPIDAPI_KEY=... python madrid_yield.py
Needs: requests, pandas. Every response is cached in data/raw/, so a re-run is free.
"""
import json
import os
import re
from pathlib import Path
import pandas as pd
import requests
HOST = "idealista17.p.rapidapi.com"
MADRID = "0-EU-ES-28-07-001-079" # Madrid municipality
RAW = Path(__file__).resolve().parent.parent / "data" / "raw"
RAW.mkdir(parents=True, exist_ok=True)
SECOND_PAGE = { # district code -> searches that get a second page of 50
"04": ["for_sale"], "05": ["for_sale"], "09": ["for_sale"],
"11": ["for_rent"], "12": ["for_rent"], "13": ["for_rent"], "18": ["for_rent"],
}
NOT_VACANT = re.compile( # sale ads that are not a vacant flat: occupied, bare ownership, auction
r"bare ownership|bare property|nuda propiedad|usufruct|occupied property|occupied by"
r"|illegally occupied|okupa|squat|without possession|auction|subasta|judicial",
re.I,
)
def get(endpoint, params, name):
"""GET an endpoint once, then read it from data/raw/<name>.json."""
path = RAW / (name + ".json")
if path.exists():
return json.loads(path.read_text())
resp = requests.get(
"https://" + HOST + endpoint,
headers={"X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"], "X-RapidAPI-Host": HOST},
params=params,
timeout=60,
)
resp.raise_for_status()
path.write_text(json.dumps(resp.json(), ensure_ascii=False))
return resp.json()
def districts():
body = get("/sublocations", {"country": "es", "location_id": MADRID,
"property_type": "homes", "search_type": "for_sale"},
"sublocations-madrid-sale")
return [(loc["locationId"], loc["name"].replace(", Madrid", ""))
for loc in body["data"]["locations"]]
def search(location_id, search_type, page=1):
code = location_id.split("-")[-1]
body = get("/property-search", {
"country": "es", "location_ids": location_id, "search_type": search_type,
"property_type": "homes", "sort_order": "newest", "page": page,
"result_count": 50, "language": "en",
}, "search-%s-%s-p%d" % (search_type, code, page))
return body["data"]
def to_rows(listings, district):
rows = []
for ad in listings:
labels = [lab["name"] for lab in ad.get("labels") or []]
rows.append({
"id": ad["propertyCode"], "district": district, "operation": ad["operation"],
"typology": (ad.get("detailedType") or {}).get("typology"),
"price": ad["price"], "size": ad.get("size"), "rooms": ad.get("rooms"),
"neighborhood": ad.get("neighborhood"),
"seasonal": "seasonalRental" in labels,
"not_vacant": bool(NOT_VACANT.search(ad.get("description") or "")),
})
return rows
def load():
"""Every listing collected, one row each."""
rows = []
for loc_id, name in districts():
for search_type in ("for_sale", "for_rent"):
pages = 2 if search_type in SECOND_PAGE.get(loc_id[-2:], []) else 1
for page in range(1, pages + 1):
rows += to_rows(search(loc_id, search_type, page)["listings"], name)
return pd.DataFrame(rows).drop_duplicates("id")
def clean(df):
"""Flats only, plausible sizes, no repeated ads, no seasonal lets, no occupied sales."""
df = df[(df.typology == "flat") & df["size"].between(30, 150)].copy()
df = df.drop_duplicates(["operation", "district", "neighborhood", "rooms", "size", "price"])
df = df[~((df.operation == "rent") & df.seasonal)]
df = df[~((df.operation == "sale") & df.not_vacant)]
df["eur_m2"] = df.price / df["size"]
return df
def yields(df):
med = df.pivot_table(index="district", columns="operation", values="eur_m2",
aggfunc="median")
n = df.pivot_table(index="district", columns="operation", values="id", aggfunc="count")
out = pd.DataFrame({
"sale_eur_m2": med["sale"].round(0),
"rent_eur_m2_month": med["rent"].round(2),
"gross_yield_pct": (12 * med["rent"] / med["sale"] * 100).round(2),
"n_sale": n["sale"], "n_rent": n["rent"],
})
return out.sort_values("gross_yield_pct", ascending=False)
if __name__ == "__main__":
table = yields(clean(load()))
print(table.to_string())
Two details are worth knowing before you write your own version. The ads come back under data.listings, and short-term lets are flagged in a labels list on each ad ({"name": "seasonalRental", "text": "Short-term rental"}), not in a field of their own. Both matter below.
Results: the south and east out-earn the centre
After cleaning, 1,649 listings were left (946 for sale, 703 for rent). Across the whole city the median asking price is €5,255 per m² and the median asking rent is €20.77 per m² a month, which gives a city-wide gross yield of 4.74%. Nine of the 21 districts come in above 5%.
The top seven are Villaverde (7.19%), Puente de Vallecas (6.49%), Carabanchel (6.28%), Usera (6.13%), San Blas (5.80%), Villa de Vallecas (5.78%) and Latina (5.72%). The bottom four are the expensive core: Chamartín (3.70%), Chamberí (3.63%), Retiro (3.36%) and Barrio de Salamanca (3.10%).
The reason is in the two medians, not in the rent. A square metre in Salamanca asks €10,797, 3.31 times Villaverde's €3,260. Rent per square metre is only 1.43 times higher (€27.86 against €19.52). Outside Centro, Salamanca and Chamberí, the median asking rent sits between €17.08 and €23.93 per m² in every district. Rents in Madrid are compressed; sale prices are not. So the yield ranking is mostly a ranking of purchase prices, read upside down.
How precise is it? I bootstrapped each district 2,000 times (resampling its sale and rent ads separately). The 90% interval for Villaverde runs from 6.04% to 7.98%, for Salamanca from 2.77% to 3.38%. The top and bottom of the table are clearly different. The neighbours inside the table are not: Puente de Vallecas (6.07% to 7.15%) overlaps Villaverde, and Carabanchel and Usera overlap each other almost entirely. Treat the ranking as tiers, not places.
The catch: the high-yield districts have almost nothing to rent
Every search response also carries a total: the number of live ads in that district, not just the 50 I fetched. Comparing the for-sale and for-rent totals turned out to be the most interesting number in the whole exercise.
The seven top-yield districts had 615 homes for rent against 5,381 for sale: 11.4 rental ads per 100 sale ads. The other 14 districts had 52.7. Thin supply does not create a high yield by itself, though: Vicálvaro and Moratalaz are just as thin and sit in the lower half of the ranking, because they combine the two lowest asking rents in the city (€17.08 and €17.21 per m²) with sale prices well above the southern districts. Villaverde had 44 rental ads in total against 751 for sale. Centro had 1,912 rentals against 2,371 sales.
Two consequences. First, in four districts (Barajas, Moratalaz, Vicálvaro and Villaverde) there were 50 rental ads or fewer, so my "sample" of rentals was the entire live rental stock. Villaverde's 7.19% rests on 30 long-term rental ads after cleaning, and Moratalaz's on 14. Second, a thin rental market cuts both ways for an investor. Few competing ads can mean steady demand, but it also means the asking rent is set by a handful of landlords, and one new building of rentals can move the median.
What each cleaning step changed
Here is the city-wide yield after each step, applied cumulatively. It is the most useful table in this post if you are building your own version.
| Step | Listings | Sale €/m² | Rent €/m²/month | Gross yield |
|---|---|---|---|---|
| All homes as returned | 2,280 | 5,500 | 22.19 | 4.84% |
| Same, means instead of medians | 2,280 | 6,243 | 24.61 | 4.73% |
| Flats only (no chalets) | 2,217 | 5,511 | 22.23 | 4.84% |
| 30 to 150 m² only | 1,942 | 5,238 | 21.93 | 5.02% |
| Repeated ads removed | 1,889 | 5,234 | 21.76 | 4.99% |
| Short-term lets removed | 1,675 | 5,234 | 20.77 | 4.76% |
| Occupied and bare-ownership sales removed | 1,649 | 5,255 | 20.77 | 4.74% |
The headline barely moves, from 4.84% to 4.74%. That is reassuring, but it hides changes that matter a lot more at district level. These are the traps, in the order I hit them.
Pitfalls in Idealista data for yield work
Short-term lets are a separate market. Of the 917 rental flats left after removing repeated ads, 214 (23.3%) carried the seasonalRental label. In Centro it was 21 of 48 ads, or 43.8%. Spanish law treats these as seasonal contracts outside the ordinary rules for a primary home (the Ley de Arrendamientos Urbanos separates the two), so their rent is not what a long-term tenant pays. Removing them took the city figure from 4.99% to 4.76%.
What surprised me is where it mattered. In Centro, where the share is highest, the yield hardly changed (5.17% with them, 5.16% without), because short-term flats there asked €29.26 per m² against €29.17 for long-term ones. In Puente de Vallecas, a third of the rental ads were short-term, at €26.67 per m² against €19.17, and they pushed the yield from 6.49% to 7.24%. You cannot guess the direction from the share alone; you have to filter.
The same flat appears more than once. I grouped flats with the same operation, district, neighbourhood, rooms, size and price. That found 38 groups covering 91 ads. In 25 of the groups the ads came from different advertisers, which is what you expect in Spain, where a seller often lists with several agencies at once. In the other 13 it was one advertiser, either with several identical units or with the same flat posted twice. Either way, one flat or one building should not count three times in a median, so I kept one ad per group (53 removed).
Some "cheap" flats come with a tenant or a squatter. A text search of the sale descriptions (requested with language=en, so they come back translated) found 26 flats sold as occupied, without possession, as bare ownership or through an auction process. Their median price per m² was 15.8% below their district's median for ordinary flats. They are real listings, but they are not comparable with a flat you can let tomorrow, and in a small district a few of them pull the sale median down and the yield up.
Rentals are smaller than sales. The median rental flat is 71 m², the median flat for sale 81 m². Small flats ask far more rent per m² (€28.06 for 30 to 50 m², against €18.06 for 121 to 150 m²), while the sale price per m² is similar at both ends (€6,310 and €6,039):
Part of this is location (big flats cluster in Salamanca, Chamberí and Retiro), but the effect is large enough that the size band you pick changes the answer. Restricting both sides to 40 to 90 m² gives a city-wide yield of 5.18% instead of 4.74%. Whatever band you choose, use the same one for rent and sale, and say which it was.
priceByArea is rounded for rentals. Every ad has a priceByArea field, and on rentals it is a whole number of euros: 100% of the rental ads I collected had an integer value. That is up to 4.7% away from price divided by size (median gap 0.95%). It barely shifts most districts, but Latina moves from 5.72% to 5.84% if you use the field instead of computing it. Compute price / size yourself.
Use medians, and expect odd ads. Means push the city-wide asking price from €5,500 to €6,243 per m², because a few luxury flats sit far above everything else. There are odd ads at the other end too: one flat filed under Sol, in Centro, asked €176,000 for 70 m² (about €2,500 per m²), and its description turned out to be about a flat near Barcelona. A median shrugs that off; a mean does not.
These are asking prices, and the yield is gross. Asking prices are not closing prices, and nothing in a listing tells you the final figure. Sorting by newest also means few ads had been cut yet: only 4.8% of the sale ads and 3.6% of the rental ads carried a price drop. A gross yield ignores community fees, IBI property tax, insurance, empty months and income tax, which are what decide whether a 7% gross yield is still attractive net.
Newest first is a choice. sort_order=newest gives a fresh snapshot rather than the stale ads that never sold. In Centro, all 50 rental ads on the first page had been published within about 29 hours. The default sort is a weighted relevance ranking, and I did not want a ranking deciding which ads enter a price median.
What I would do next
Run the same script weekly and keep the snapshots, so each district gets a time series instead of one reading (I wrote up the snapshot-and-compare pattern for buyers in turning Idealista searches into shortlists). Go down to neighbourhood level in Centro and Carabanchel, where the samples are big enough. And for the thin districts, fetch every rental page rather than the newest 50, since the whole stock fits in one or two calls anyway.
Endpoints and parameters for everything above are documented on happyendpoint.com, and you can get a key and test the calls in the browser on RapidAPI. The same code works for Barcelona, Lisbon or Milan once you swap the location ID and country. If you need a larger pull, such as every listing in a city each week, get in touch.




Top comments (0)