How to Map Airbnb Prices by Neighbourhood in Python

On this page

Problem statement

A map of Airbnb prices by neighbourhood is the classic first analysis of short-term rental data, and an easy one to get wrong. Mixing private rooms with whole apartments compares different products. Neighbourhoods with three listings get the same colour weight as those with three hundred. Averages are dragged up by a handful of mansions at thousands of dollars a night. And the neighbourhood label in the data may not match the polygon a listing actually falls in.

This page maps the median nightly price of entire homes in each neighbourhood of Washington, D.C. from the Inside Airbnb snapshot of 24 June 2026. It joins the listings to Inside Airbnb's neighbourhood boundaries, applies a minimum sample size, compares medians with means, and checks the neighbourhood labels against a spatial join.

Quick answer

Keep one room type, compute medians per neighbourhood, require a minimum count, and join to the polygons:

E = L[(L.room_type == "Entire home/apt") & L.price_usd.notna()]
stats = E.groupby("neighbourhood_cleansed").agg(listings=("id", "size"), median_price=("price_usd", "median"), mean_price=("price_usd", "mean"))
N = NB.merge(stats, left_on="neighbourhood", right_index=True, how="left")
N.loc[N.listings < 10, ["median_price", "mean_price"]] = np.nan                      # too few to say
print(f"neighbourhoods {len(N)}; with at least 10 priced entire homes: {N.median_price.notna().sum()}")
print(N.nlargest(3, "median_price")[["neighbourhood", "listings", "median_price"]].to_string(index=False))
neighbourhoods 39; with at least 10 priced entire homes: 38
                                                                neighbourhood  listings  median_price
Downtown, Chinatown, Penn Quarters, Mount Vernon Square, North Capitol Street       152        539.20
                                                  West End, Foggy Bottom, GWU        55        393.71
                                              Georgetown, Burleith/Hillandale       140        324.79

38 of the 39 neighbourhoods have at least 10 priced entire homes. Downtown, with Chinatown, Penn Quarter and Mount Vernon Square, is the most expensive at a median of $539 a night, followed by the West End and Foggy Bottom ($394) and Georgetown ($325).

Choropleth of median entire-home nightly price in D.C. neighbourhoods in five Fisher-Jenks classes, darkest downtown Choropleth of median entire-home nightly price in D.C. neighbourhoods in five Fisher-Jenks classes, darkest downtown
Downtown has the highest median, $539; the class breaks come from Fisher-Jenks on 38 neighbourhoods.

Median not mean, sample size, labels against a spatial join

1. Median, not mean

d = N.dropna(subset=["median_price"])
print(f"mean exceeds median in {(d.mean_price > d.median_price).mean():.0%} of neighbourhoods; largest gap ${(d.mean_price - d.median_price).max():.0f} in {d.loc[(d.mean_price - d.median_price).idxmax(), 'neighbourhood'][:40]}")
mean exceeds median in 100% of neighbourhoods; largest gap $361 in Brightwood Park, Crestwood, Petworth

The mean exceeds the median in every neighbourhood, by as much as $361 in Brightwood Park, Crestwood and Petworth, where a few very expensive listings pull the average up.

Horizontal bars of median price for the five cheapest and five dearest neighbourhoods with at least 20 listings Horizontal bars of median price for the five cheapest and five dearest neighbourhoods with at least 20 listings
Medians range from $145 to $539 among neighbourhoods with 20 or more entire homes.

2. How much the sample size matters

print(f"entire homes per neighbourhood: median {stats.listings.median():.0f}, range {stats.listings.min()} to {stats.listings.max()}")
print(f"neighbourhoods dropped for having fewer than 10: {(N.listings.fillna(0) < 10).sum()}")
entire homes per neighbourhood: median 56, range 5 to 569
neighbourhoods dropped for having fewer than 10: 1

Neighbourhoods have a median of 56 priced entire homes, ranging from 5 to 569. Only one falls below the threshold of 10.

3. Labels against a spatial join

G = gpd.GeoDataFrame(E, geometry=gpd.points_from_xy(E.longitude, E.latitude), crs=4326)
j = gpd.sjoin(G, NB[["neighbourhood", "geometry"]].rename(columns={"neighbourhood": "polygon"}), predicate="within", how="left")
agree = (j.polygon == j.neighbourhood_cleansed)
print(f"listings whose label matches the polygon they fall in: {agree.mean():.1%}; outside every polygon: {j.polygon.isna().sum()}")
listings whose label matches the polygon they fall in: 100.0%; outside every polygon: 0

Every listing's label matches the polygon it falls in, and none falls outside them all, consistent with Inside Airbnb assigning the labels by the same spatial join. The labels inherit the blurred coordinates, though, so they are only as precise as the locations.

Price per guest

The data used on this page

The Inside Airbnb snapshot for Washington, D.C. dated 24 June 2026: listings.csv.gz and neighbourhoods.geojson, the neighbourhood clusters Inside Airbnb uses for D.C. (CC BY 4.0).

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
BASE = "https://data.insideairbnb.com/united-states/dc/washington-dc/2026-06-24"
def cached(path, url):
    p = Path(path)
    if not p.exists():
        r = requests.get(url, timeout=600); r.raise_for_status(); p.write_bytes(r.content)
    return p
L = pd.read_csv(cached("data/listings.csv.gz", f"{BASE}/data/listings.csv.gz"))
L["price_usd"] = pd.to_numeric(L.price.str.replace(r"[$,]", "", regex=True), errors="coerce")
NB = gpd.read_file(cached("data/neighbourhoods.geojson", f"{BASE}/visualisations/neighbourhoods.geojson"))
print(f"{len(L):,} listings; {len(NB)} neighbourhood polygons")
6,945 listings; 39 neighbourhood polygons

The snapshot has 6,945 listings and 39 neighbourhood polygons, the District's neighbourhood clusters.

Example 1: price per guest

Larger homes cost more. Divide by accommodates to compare neighbourhoods on price per guest:

E2 = E.assign(per_guest=E.price_usd / E.accommodates.clip(lower=1))
pg = E2.groupby("neighbourhood_cleansed").agg(n=("id", "size"), per_guest=("per_guest", "median"))
pg = pg[pg.n >= 10].sort_values("per_guest")
print(f"median price per guest: lowest ${pg.per_guest.iloc[0]:.0f} ({pg.index[0][:35]}), highest ${pg.per_guest.iloc[-1]:.0f} ({pg.index[-1][:35]})")
median price per guest: lowest $39 (River Terrace, Benning, Greenway, D), highest $148 (Downtown, Chinatown, Penn Quarters,)

Per guest, the gap narrows but remains: from $39 a night in River Terrace, Benning and Greenway to $148 downtown.

Building a neighbourhood price map from Inside Airbnb. listings to a choroplethentire homespriced listingsgroupneighbourhood_cleanseddrop thinareasunder 10 listings:1merge polygons39 neighbourhoodsclassifyFisher-Jenks, 5classesLabels matched the polygons for 100% of listings spatialworkflow.io
One neighbourhood is dropped for having fewer than 10 priced entire homes.

Every summary choice changes the picture

Median compared with mean nightly price for mapping neighbourhoods. which average to mapmean✓above the median in all 38 areas✓gap up to $361✓one luxury listing moves itmedian✓typical listing✓Downtown $539✓stable with 10+ listingsEntire homes only, so room mix does not drive the map spatialworkflow.io
The mean is above the median in every neighbourhood.

A neighbourhood price map summarises many listings into one value per area. Each choice in that summary changes the picture. Room types are different markets: a private room in a shared house and a whole three-bedroom apartment rarely compete. Nightly prices are skewed, with a long tail of luxury homes, so the median describes a typical listing far better than the mean. Areas with few listings give unstable medians, where one or two listings decide the colour, so a minimum count and a visible "too few" class keep the map honest.

The geography needs care too. Inside Airbnb assigns each listing to a neighbourhood using its coordinates, which Airbnb has blurred by up to about 150 m, so listings near a boundary can carry the label of the area next door. For a city like Washington, whose neighbourhood clusters are large, the effect is small, but it grows with smaller areas. Finally, prices are the quoted prices on the scrape date, so a map of June quotes is a June map.

Classes, weighting and long minimum stays

Neighbourhood price map problems and fixes. price map trapsSymptomFixmap shows room mix, not priceone room type per mapone listing colours an areaminimum count, show nmeans look inflatedmap the medianbig homes look dearprice per guest tooPer guest: $39 to $148 across neighbourhoods spatialworkflow.io
Per guest, prices run from $39 to $148.
  • Classes: quantile classes make every colour hold the same number of neighbourhoods; state the breaks in the legend.
  • Weighting: a city-wide median is the median of listings, not of neighbourhood medians.
  • Long minimum stays: filter minimum_nights < 30 to map short-term lets only.
  • Other cities: some cities have neighbourhood_group_cleansed for a coarser level.

FAQ

How do I make a map of Airbnb prices by neighbourhood?

Filter to one room type, compute the median price per neighbourhood with a minimum listing count, merge with the neighbourhood polygons, and plot a choropleth.

Should I use the mean or median Airbnb price?

The median; prices are skewed by a few very expensive listings.

Why do some neighbourhoods have no colour?

They have too few listings for a stable median; showing them as "too few" is better than a misleading value.

Where do the neighbourhood boundaries come from?

Inside Airbnb publishes neighbourhoods.geojson with each city's snapshot, using the city's official neighbourhood areas.