Thirty Nights or Nothing: What Drives Airbnb Prices in New York City

Author

Dev Narang

Published

Invalid Date

Brownstones on Kane Street, Brooklyn — the kind of housing stock that dominates NYC’s short-term rental market. Photo by Wikimedia Commons user Beyond My Ken, licensed CC BY-SA 4.0.

Introduction

The topic and why it matters to me

New York City has spent a decade fighting over whether short-term rentals take housing off the market. In 2022 the city passed Local Law 18, and in September 2023 it began enforcing a registration requirement that made most sub-30-night rentals effectively illegal. I wanted to know what that market looks like now, several years later, and specifically whether the price of a listing still comes down to location or whether the regulation itself has become the dominant factor. I picked this because I am at the age where I will be renting soon, and the argument that platforms like this raise rents is one I have heard repeatedly without ever seeing the numbers.

The dataset and how it was collected

The data comes from Inside Airbnb, an independent project founded by Murray Cox that scrapes publicly visible Airbnb listing pages and republishes them as quarterly snapshots under a Creative Commons Attribution 4.0 license (Inside Airbnb, n.d.). Because it is scraped from public listing pages rather than supplied by Airbnb, it captures what a traveller would actually see: the advertised nightly price, the minimum stay, and the review history. It does not include bookings or revenue.

I used the New York City summary file from the 14 June 2026 snapshot: 30,555 listings and 19 variables.

Variables I use

Quantitative

  • price — advertised nightly price in US dollars
  • minimum_nights — the fewest nights a guest may book
  • availability_365 — nights available in the next year, a rough proxy for how commercially active a listing is
  • reviews_per_month — average monthly reviews, a proxy for booking volume
  • number_of_reviews — lifetime review count

Categorical

  • room_type — Entire home/apt, Private room, Hotel room, or Shared room
  • neighbourhood_group — the five boroughs
  • neighbourhood — 223 finer-grained neighbourhoods
  • stay_type — a variable I derive during cleaning: whether a listing requires 30 or more nights

I did not use license, because 83% of listings leave it blank.

Research questions

  1. Which listing characteristics predict nightly price, and how much of the variation can they explain?
  2. Has the 30-night threshold created two separate markets at different price levels?
  3. Do neighbourhoods with more long-stay listings charge less?

Background Research

Local Law 18, the Short-Term Rental Registration Law, was adopted on 9 January 2022 and enforcement began 5 September 2023. It requires hosts to register with the Mayor’s Office of Special Enforcement and bars platforms from processing transactions for unregistered listings. Critically, it contains an exemption: “rentals for 30 consecutive days or more” do not require registration, and a registered host must be a permanent resident who is present during the stay (New York City Office of Special Enforcement, n.d.).

That single exemption predicts the shape of this dataset. A host unwilling or unable to register can stay legal by raising the minimum stay to 30 nights. If that is what happened at scale, the data should show most listings clustered at a 30-night minimum, and the two groups should behave like different products. That is the hypothesis the analysis below tests.

Setup

# tidyverse: readr for import, dplyr for wrangling, ggplot2 for the static plot.
# plotly: converts a ggplot into the required interactive visualization.
# scales: dollar and comma axis formatting.
library(tidyverse)
library(plotly)
library(scales)
# Import with readr::read_csv rather than base read.csv: it returns a tibble
# and parses column types explicitly.
airbnb_raw <- readr::read_csv("../nyc_airbnb_listings.csv")

dim(airbnb_raw)
[1] 30555    19

Cleaning and Wrangling

I handled each missing-value problem on its own terms rather than dropping rows wholesale. No na.omit() or drop_na() is used anywhere in this document.

# Count missing values in the variables the analysis depends on.
airbnb_raw |>
  summarize(across(c(price, minimum_nights, reviews_per_month,
                     availability_365, room_type, neighbourhood_group),
                   ~ sum(is.na(.x)))) |>
  pivot_longer(everything(), names_to = "variable", values_to = "n_missing")
# A tibble: 6 × 2
  variable            n_missing
  <chr>                   <int>
1 price                    8758
2 minimum_nights              2
3 reviews_per_month        8616
4 availability_365            0
5 room_type                   0
6 neighbourhood_group         0

Three decisions follow from this:

reviews_per_month (8,616 missing) becomes 0, not dropped. I checked whether missingness here means “unknown” or “none”:

# If every listing with a missing reviews_per_month has exactly zero lifetime
# reviews, then the NA encodes "never reviewed" and the true value is 0.
airbnb_raw |>
  group_by(reviews_missing = is.na(reviews_per_month)) |>
  summarize(listings = n(),
            min_reviews = min(number_of_reviews),
            max_reviews = max(number_of_reviews),
            .groups = "drop")
# A tibble: 2 × 4
  reviews_missing listings min_reviews max_reviews
  <lgl>              <int>       <dbl>       <dbl>
1 FALSE              21939           1        4502
2 TRUE                8616           0           0

Every one of those 8,616 listings has exactly zero reviews, and every listing with a value has at least one. The NA is a recording convention for zero, so replacing it with 0 is correct rather than an assumption.

price (8,758 missing) is excluded. Price is the response variable; a listing with no advertised price cannot inform a model of price. This is a deliberate exclusion of the outcome, not a blanket row-drop, and I report the cost of it below.

Extreme prices are trimmed. The maximum advertised price is $30,973 and the 99th percentile is $1,702. I keep listings between $20 and $2,000, which removes 205 listings at both extremes that are either data errors or non-comparable luxury inventory.

airbnb <- airbnb_raw |>
  # 1. filter: keep only listings with a usable response variable and a
  #    plausible price. One listing also has no minimum_nights value.
  filter(!is.na(price), price >= 20, price <= 2000, !is.na(minimum_nights)) |>
  # 2. mutate: recode verified-zero reviews, order the categorical variables
  #    deliberately instead of alphabetically, derive the stay_type variable
  #    that the Local Law 18 exemption implies, and log-transform price because
  #    prices are strongly right-skewed.
  mutate(
    reviews_per_month = if_else(is.na(reviews_per_month), 0, reviews_per_month),
    room_type = factor(room_type,
                       levels = c("Entire home/apt", "Private room",
                                  "Hotel room", "Shared room")),
    borough = factor(neighbourhood_group,
                     levels = c("Manhattan", "Brooklyn", "Queens",
                                "Bronx", "Staten Island")),
    stay_type = factor(if_else(minimum_nights >= 30,
                               "Long stay (30+ nights)",
                               "Short stay (under 30 nights)"),
                       levels = c("Long stay (30+ nights)",
                                  "Short stay (under 30 nights)")),
    log_price = log(price)
  ) |>
  # 3. select: keep only the columns this analysis uses.
  select(neighbourhood, borough, room_type, stay_type, price, log_price,
         minimum_nights, availability_365, reviews_per_month, number_of_reviews,
         latitude, longitude)

# Report what survived and confirm no missing values remain in the model columns.
cat("Listings retained:", nrow(airbnb),
    "of", nrow(airbnb_raw),
    sprintf("(%.1f%%)\n", 100 * nrow(airbnb) / nrow(airbnb_raw)))
Listings retained: 21595 of 30555 (70.7%)
cat("Missing values remaining in model variables:",
    sum(is.na(select(airbnb, log_price, room_type, borough, stay_type,
                     availability_365, reviews_per_month))), "\n")
Missing values remaining in model variables: 0 
# 4. group_by + summarize + arrange: how large is each market, and how do they
#    differ on price?
airbnb |>
  group_by(stay_type) |>
  summarize(listings = n(),
            share = percent(n() / nrow(airbnb), accuracy = 0.1),
            median_price = dollar(median(price)),
            .groups = "drop") |>
  arrange(desc(listings))
# A tibble: 2 × 4
  stay_type                    listings share median_price
  <fct>                           <int> <chr> <chr>       
1 Long stay (30+ nights)          16366 75.8% $142        
2 Short stay (under 30 nights)     5229 24.2% $299        

Three quarters of surviving listings require a stay of 30 nights or longer, and their median price is less than half that of short-stay listings. The regulation did not empty the market; it reshaped it.

Multiple Linear Regression

I model the log of nightly price on room type, borough, stay type, availability, and review volume. Logging the response means each coefficient can be read as an approximate percentage effect, which suits a variable as skewed as price.

price_model <- lm(log_price ~ room_type + borough + stay_type +
                    availability_365 + reviews_per_month,
                  data = airbnb)
summary(price_model)

Call:
lm(formula = log_price ~ room_type + borough + stay_type + availability_365 + 
    reviews_per_month, data = airbnb)

Residuals:
    Min      1Q  Median      3Q     Max 
-2.5312 -0.3736 -0.0696  0.3108  3.2851 

Coefficients:
                                        Estimate Std. Error t value Pr(>|t|)
(Intercept)                            5.284e+00  1.162e-02 454.713   <2e-16
room_typePrivate room                 -7.352e-01  8.310e-03 -88.466   <2e-16
room_typeHotel room                   -2.693e-01  2.869e-02  -9.384   <2e-16
room_typeShared room                  -1.033e+00  4.162e-02 -24.822   <2e-16
boroughBrooklyn                       -2.504e-01  9.003e-03 -27.810   <2e-16
boroughQueens                         -4.057e-01  1.131e-02 -35.884   <2e-16
boroughBronx                          -5.215e-01  2.077e-02 -25.108   <2e-16
boroughStaten Island                  -5.083e-01  3.363e-02 -15.112   <2e-16
stay_typeShort stay (under 30 nights)  9.961e-01  1.074e-02  92.785   <2e-16
availability_365                       8.006e-04  3.718e-05  21.529   <2e-16
reviews_per_month                     -2.957e-02  2.160e-03 -13.688   <2e-16
                                         
(Intercept)                           ***
room_typePrivate room                 ***
room_typeHotel room                   ***
room_typeShared room                  ***
boroughBrooklyn                       ***
boroughQueens                         ***
boroughBronx                          ***
boroughStaten Island                  ***
stay_typeShort stay (under 30 nights) ***
availability_365                      ***
reviews_per_month                     ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.5678 on 21584 degrees of freedom
Multiple R-squared:  0.4876,    Adjusted R-squared:  0.4874 
F-statistic:  2054 on 10 and 21584 DF,  p-value: < 2.2e-16

The model equation

\[ \begin{aligned} \widehat{\log(\text{price})} = 5.284 &- 0.735(\text{Private room}) - 0.269(\text{Hotel room}) - 1.033(\text{Shared room}) \\ &- 0.250(\text{Brooklyn}) - 0.406(\text{Queens}) - 0.521(\text{Bronx}) - 0.508(\text{Staten Island}) \\ &+ 0.996(\text{Short stay}) + 0.00080(\text{availability}_{365}) - 0.0296(\text{reviews per month}) \end{aligned} \]

The reference listing is an entire home in Manhattan requiring 30 or more nights.

# Convert log coefficients into percentage effects on price for interpretation.
tibble(term = names(coef(price_model)),
       coefficient = round(coef(price_model), 4),
       pct_effect_on_price = percent(exp(coef(price_model)) - 1, accuracy = 0.1),
       p_value = format.pval(summary(price_model)$coefficients[, 4], digits = 3)) |>
  filter(term != "(Intercept)")
# A tibble: 10 × 4
   term                                  coefficient pct_effect_on_price p_value
   <chr>                                       <dbl> <chr>               <chr>  
 1 room_typePrivate room                     -0.735  -52.1%              <2e-16 
 2 room_typeHotel room                       -0.269  -23.6%              <2e-16 
 3 room_typeShared room                      -1.03   -64.4%              <2e-16 
 4 boroughBrooklyn                           -0.250  -22.1%              <2e-16 
 5 boroughQueens                             -0.406  -33.3%              <2e-16 
 6 boroughBronx                              -0.522  -40.6%              <2e-16 
 7 boroughStaten Island                      -0.508  -39.8%              <2e-16 
 8 stay_typeShort stay (under 30 nights)      0.996  170.8%              <2e-16 
 9 availability_365                           0.0008 0.1%                <2e-16 
10 reviews_per_month                         -0.0296 -2.9%               <2e-16 

What the model says

Every predictor is significant at p < 0.001, and the adjusted R-squared is 0.487, so these five variables explain roughly 49% of the variation in log price. For a model with no information about square footage, bedrooms, or photographs, that is a lot.

The largest single effect is not location. Holding room type and borough constant, a listing that accepts stays under 30 nights charges about 171% more than one that requires 30 or more. That dwarfs the borough effects: moving from Manhattan to the Bronx, the steepest geographic drop, costs about 41%. In other words, whether a listing sits on the legal side of the Local Law 18 exemption matters roughly four times more to its price than which borough it is in.

Room type behaves as expected — a private room runs about 52% below an entire home, a shared room about 64% below. Two smaller effects are worth naming. More available nights per year associates with slightly higher prices, consistent with commercial operators pricing above casual hosts. More reviews per month associates with slightly lower prices, about 3% per additional monthly review, which fits budget listings turning over faster than expensive ones.

Diagnostics

# Standard four-panel diagnostic set: residuals vs fitted for linearity and
# constant variance, Q-Q for normality of residuals, scale-location for
# heteroskedasticity, and residuals vs leverage for influential points.
par(mfrow = c(2, 2))
plot(price_model)

par(mfrow = c(1, 1))

The residuals-versus-fitted panel is close to a flat band, so the log transformation dealt with most of the non-constant variance the raw prices would have shown. The Q-Q plot tracks the diagonal through the middle but lifts at both tails, meaning the model under-predicts the cheapest and most expensive listings — unsurprising, since those are where unmeasured features like square footage matter most. No point approaches a Cook’s distance that would make it individually influential across 21,595 observations. The model is sound for describing average behaviour and should not be trusted for pricing any single unusual listing.

Visualization 1: Two Markets, Five Boroughs

# Deliberate non-default palette: a muted slate for the long-stay market and a
# warm amber for the short-stay market, plus a grey reference line.
stay_palette <- c("Long stay (30+ nights)"       = "#4C6A92",
                  "Short stay (under 30 nights)" = "#E8A33D")

median_all <- median(airbnb$price)

ggplot(airbnb, aes(x = borough, y = price, fill = stay_type)) +
  # Reference line for the citywide median, so each box can be read against it.
  geom_hline(yintercept = median_all, linetype = "dashed",
             color = "#8A8A8A", linewidth = 0.6) +
  geom_boxplot(outlier.alpha = 0.12, outlier.size = 0.7, width = 0.7) +
  # Annotation calling out the single most important finding in the plot.
  annotate("text", x = 4.62, y = 1500,
           label = "The 30-night line splits the\nmarket in every borough",
           size = 3.5, color = "#3C3C3C", fontface = "italic", hjust = 0.5) +
  annotate("segment", x = 4.62, xend = 4.9, y = 1150, yend = 620,
           color = "#3C3C3C", linewidth = 0.4,
           arrow = arrow(length = unit(0.18, "cm"))) +
  annotate("label", x = 2.5, y = median_all,
           label = paste0("Citywide median ", dollar(median_all, accuracy = 1)),
           size = 3, color = "#5E5E5E", fill = "white",
           label.size = 0, label.padding = unit(0.12, "lines")) +
  scale_y_log10(labels = dollar_format(accuracy = 1),
                breaks = c(25, 50, 100, 200, 400, 800, 2000)) +
  scale_fill_manual(name = "Minimum stay required", values = stay_palette) +
  labs(
    title = "Listings That Can Legally Host Short Stays Charge Roughly Triple in Every Borough",
    subtitle = "Nightly price on a log scale, June 2026. Local Law 18 exempts rentals of 30 or more consecutive nights from registration,\nand the market has split along exactly that line.",
    x = "Borough",
    y = "Nightly Price (log scale)",
    caption = "Source: Inside Airbnb, New York City listings snapshot of 14 June 2026 (insideairbnb.com). 21,595 listings priced $20-$2,000."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title      = element_text(face = "bold", size = 13.5),
    plot.subtitle   = element_text(color = "grey35", size = 9.5,
                                   margin = margin(b = 10)),
    plot.caption    = element_text(color = "grey45", hjust = 0, size = 8),
    legend.position = "top",
    panel.grid.minor = element_blank(),
    panel.grid.major.x = element_blank()
  )
Boxplots of nightly Airbnb price on a logarithmic scale, split into five boroughs along the x-axis and coloured by whether a listing requires a long or short stay. In every borough the short-stay boxes sit well above the long-stay boxes, and Manhattan sits highest overall.
Figure 1

What this shows. Each pair of boxes is one borough, split by whether the listing requires 30 or more nights. Two patterns stack on top of each other. The familiar one is geographic: Manhattan is the most expensive borough and the Bronx and Staten Island the cheapest. The stronger one is regulatory — within every single borough, the amber short-stay box sits far above the slate long-stay box, and the gap is wider than the entire spread between Manhattan and the Bronx. A short-stay listing in the Bronx competes on price with a long-stay listing in Manhattan. The log scale is necessary here because raw prices span from $20 to $2,000; on a linear axis every box below $200 would be crushed against the axis.

Visualization 2: Interactive Neighbourhood View

# Aggregate to neighbourhood level: group_by + summarize + filter. Neighbourhoods
# with fewer than 30 listings are excluded because a median built on a handful of
# listings is too unstable to plot beside one built on 1,400.
nbhd <- airbnb |>
  group_by(borough, neighbourhood) |>
  summarize(listings      = n(),
            median_price  = median(price),
            pct_long_stay = 100 * mean(stay_type == "Long stay (30+ nights)"),
            .groups = "drop") |>
  filter(listings >= 30)

# Five intentional, non-default colours, one per borough.
borough_palette <- c("Manhattan"     = "#B3272D",
                     "Brooklyn"      = "#2E6E9E",
                     "Queens"        = "#43886B",
                     "Bronx"         = "#8A5FA8",
                     "Staten Island" = "#C8802B")

# Build as a themed ggplot first so the non-default theme and the annotation
# carry through, then convert to an interactive plotly object.
gg <- ggplot(nbhd, aes(x = pct_long_stay, y = median_price,
                       color = borough, size = listings,
                       text = paste0("<b>", neighbourhood, "</b><br>",
                                     borough, "<br>",
                                     "Median price: ", dollar(median_price), "<br>",
                                     "Long-stay share: ", round(pct_long_stay), "%<br>",
                                     "Listings: ", listings))) +
  geom_point(alpha = 0.8) +
  # Annotation marking the region where regulation has taken hold hardest.
  annotate("text", x = 62, y = 430,
           label = "Neighbourhoods where nearly every listing\nrequires 30+ nights cluster at low prices",
           size = 3.2, color = "#4A4A4A", hjust = 0) +
  scale_color_manual(name = "Borough", values = borough_palette) +
  scale_size_continuous(name = "Listings", range = c(4, 16)) +
  scale_y_continuous(labels = dollar) +
  scale_x_continuous(labels = function(x) paste0(x, "%")) +
  labs(
    title = "Neighbourhoods Dominated by 30-Night Minimums Have the Lowest Median Prices",
    x = "Share of Listings Requiring 30 or More Nights",
    y = "Median Nightly Price"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold", size = 12),
        panel.grid.minor = element_blank())

# Convert to plotly, use the custom hover text, and add the source caption as a
# layout annotation because plotly does not carry over a ggplot caption.
ggplotly(gg, tooltip = "text") |>
  layout(
    annotations = list(
      list(x = 0, y = -0.16, xref = "paper", yref = "paper",
           text = paste0("Source: Inside Airbnb, New York City snapshot of 14 June 2026 ",
                         "(insideairbnb.com). 99 neighbourhoods with at least 30 listings."),
           showarrow = FALSE, xanchor = "left",
           font = list(size = 10, color = "grey45"))
    ),
    margin = list(b = 90, t = 60)
  )
Figure 2

What this shows. Each bubble is one of the 99 neighbourhoods with at least 30 listings, positioned by the share of its listings requiring 30 or more nights and by its median nightly price, coloured by borough and sized by listing count. Hovering names the neighbourhood and gives its exact figures, which is what makes the interactivity worth having — a static version of this plot would need 99 labels to be readable. The relationship is clearly negative, correlating at −0.44: the more thoroughly a neighbourhood has converted to long-stay listings, the less it charges. The red Manhattan bubbles concentrate at the upper left, where a meaningful share of listings still accept short stays and medians run past $400 in Tribeca, SoHo, and Greenwich Village. The lower right belongs to Fordham and Mount Hope in the Bronx, Inwood in upper Manhattan, and Elmhurst in Queens, where 93% to 97% of listings require a month or more and medians sit near $60 to $72.

Conclusion

The regression and both visualizations converge on the same answer to my first question. Nightly price in New York City is predictable from a handful of listing attributes — 49% of the variation in log price — but the single strongest predictor is not the borough, the room type, or how busy the listing is. It is whether the listing can legally accept a stay shorter than a month. That premium, about 171%, is roughly four times the size of the largest geographic effect in the model.

The surprise was how completely the market has reorganised itself around a regulatory exemption. I expected Local Law 18 to have reduced the number of listings; I did not expect three quarters of the remaining ones to have migrated to a 30-night minimum, which converts an Airbnb listing into something closer to a sublet. The clean answer to my second question is that there are now effectively two markets sharing one platform, and the boundary sits exactly where the law drew it. My third question resolves in the same direction: neighbourhoods furthest along that migration charge the least, correlating at −0.44.

What I could not do. The summary file has no bedroom count, square footage, or property type, which are almost certainly the largest missing predictors and the most likely explanation for the tails in the Q-Q plot. Inside Airbnb’s detailed file includes them, and with more time I would join it in. I also cannot distinguish causation from selection: cheap neighbourhoods may have converted to long stays because they were cheap, rather than becoming cheap as a result, and a single snapshot cannot separate those. Answering that would need several quarterly snapshots to track the same listings before and after enforcement, which Inside Airbnb archives but which was beyond this project’s scope. Finally, I dropped 8,758 listings with no advertised price. Those are plausibly the least active listings, so my model describes the priced, visible market rather than every registered unit.

References

Inside Airbnb. (n.d.). About Inside Airbnb. Retrieved August 8, 2026, from https://insideairbnb.com/about/

Inside Airbnb. (2026). New York City listings data, snapshot of 14 June 2026 [Data set]. https://insideairbnb.com/get-the-data/

New York City Office of Special Enforcement. (n.d.). Registration law: Short-term rental registration and verification by booking services. Retrieved August 8, 2026, from https://www.nyc.gov/site/specialenforcement/registration-law/registration.page