Spatial Equilibrium
The idea
Why do people live where they do? If city A has higher wages than city B, why doesn’t everyone move to A? The Rosen-Roback framework (Rosen 1979, Roback 1982) answers: because high-wage cities must have some offsetting disadvantage — higher rents, worse amenities, or both — that makes workers indifferent across locations.
In equilibrium, utility must be equalized across cities:
\[V(w, r, a) = w - r + a = \bar{V} \quad \text{for all cities}\]
where \(w\) is the wage, \(r\) is the rent, and \(a\) is the amenity value. If one city offered higher utility, people would migrate there, driving up rents and driving down wages until the advantage disappeared.
What this explains
- NYC has high wages AND high rents. The two compensate each other. Workers earn more but pay more for housing. Net utility is the same as elsewhere.
- San Diego has lower wages than you’d expect. The weather, beaches, and quality of life are the compensating amenity. Workers accept lower wages because the city is pleasant.
- Detroit has low rents AND low wages. Both reflect low amenity value (post-industrial decline, crime, weak public services). Low rents partially compensate, but the amenity deficit is what keeps people away.
#| standalone: true
#| viewerHeight: 680
library(shiny)
ui <- fluidPage(
tags$head(tags$style(HTML("
.stats-box {
background: #f0f4f8; border-radius: 6px; padding: 14px;
margin-top: 12px; font-size: 14px; line-height: 1.9;
}
.stats-box b { color: #2c3e50; }
.good { color: #27ae60; font-weight: bold; }
.bad { color: #e74c3c; font-weight: bold; }
.info-box {
background: #eaf2f8; border-radius: 6px; padding: 14px;
margin-top: 12px; font-size: 13px; line-height: 1.8;
}
.info-box b { color: #2c3e50; }
"))),
sidebarLayout(
sidebarPanel(
width = 4,
tags$h4("City 1"),
sliderInput("w1", "Wage ($1000s):", min = 30, max = 120, value = 80, step = 5),
sliderInput("a1", "Amenity value:", min = -20, max = 40, value = 10, step = 2),
tags$hr(),
tags$h4("City 2"),
sliderInput("w2", "Wage ($1000s):", min = 30, max = 120, value = 50, step = 5),
sliderInput("a2", "Amenity value:", min = -20, max = 40, value = 30, step = 2),
tags$hr(),
checkboxInput("migration", "Allow migration (rents adjust)", value = FALSE),
uiOutput("info")
),
mainPanel(
width = 8,
plotOutput("bar_plot", height = "500px")
)
)
)
server <- function(input, output, session) {
eq <- reactive({
w1 <- input$w1
w2 <- input$w2
a1 <- input$a1
a2 <- input$a2
if (input$migration) {
# In equilibrium: w1 - r1 + a1 = w2 - r2 + a2
# Normalize: total rent = w1 + w2 + a1 + a2 (split based on relative advantage)
# Simple: set r so utility is equal
# V1 = w1 - r1 + a1, V2 = w2 - r2 + a2
# Set V1 = V2 = V_bar
# r1 = w1 + a1 - V_bar, r2 = w2 + a2 - V_bar
# V_bar = average utility = ((w1+a1) + (w2+a2)) / 2
v_bar <- ((w1 + a1) + (w2 + a2)) / 2
r1 <- w1 + a1 - v_bar
r2 <- w2 + a2 - v_bar
# Ensure rents are non-negative (floor at 5)
r1 <- max(r1, 5)
r2 <- max(r2, 5)
v1 <- w1 - r1 + a1
v2 <- w2 - r2 + a2
eq_status <- "Equilibrium"
} else {
# Without migration, rents are set at a baseline (proportional to wage)
r1 <- w1 * 0.35
r2 <- w2 * 0.35
v1 <- w1 - r1 + a1
v2 <- w2 - r2 + a2
eq_status <- "No migration"
}
list(w1 = w1, w2 = w2, r1 = r1, r2 = r2, a1 = a1, a2 = a2,
v1 = v1, v2 = v2, eq_status = eq_status, migration = input$migration)
})
output$bar_plot <- renderPlot({
e <- eq()
par(mar = c(5, 5, 4, 2))
# Build matrix for stacked bars
# Each city: wage component, -rent component, amenity component
cities <- c("City 1", "City 2")
wage_comp <- c(e$w1, e$w2)
rent_comp <- c(-e$r1, -e$r2)
amen_comp <- c(e$a1, e$a2)
utility <- c(e$v1, e$v2)
# Bar positions
bp <- barplot(rbind(wage_comp, rent_comp, amen_comp),
beside = FALSE, col = c("#3498db", "#e74c3c", "#27ae60"),
names.arg = cities, ylim = c(min(rent_comp) * 1.3, max(wage_comp + amen_comp) * 1.2),
ylab = "Value ($1000s)",
main = ifelse(e$migration,
"Spatial Equilibrium (rents adjust)",
"Utility Decomposition (no migration)"),
border = "white", cex.names = 1.2)
# Add utility line
segments(bp - 0.4, utility, bp + 0.4, utility, lwd = 3, col = "#2c3e50")
text(bp, utility, paste0("V = ", round(utility, 1)),
pos = 3, col = "#2c3e50", font = 2, cex = 1.1)
# Migration arrow if not in equilibrium
if (!e$migration && abs(e$v1 - e$v2) > 1) {
if (e$v1 > e$v2) {
arrows(bp[2], max(utility) * 0.5, bp[1], max(utility) * 0.5,
lwd = 3, col = "#f39c12", length = 0.15)
text(mean(bp), max(utility) * 0.55, "Migration", col = "#f39c12", cex = 1, font = 2)
} else {
arrows(bp[1], max(utility) * 0.5, bp[2], max(utility) * 0.5,
lwd = 3, col = "#f39c12", length = 0.15)
text(mean(bp), max(utility) * 0.55, "Migration", col = "#f39c12", cex = 1, font = 2)
}
}
legend("topright", bty = "n",
legend = c("Wage", "Rent (cost)", "Amenity", "Net utility"),
fill = c("#3498db", "#e74c3c", "#27ae60", NA),
border = c("white", "white", "white", NA),
lwd = c(NA, NA, NA, 3), col = c(NA, NA, NA, "#2c3e50"),
cex = 0.9)
})
output$info <- renderUI({
e <- eq()
gap <- abs(e$v1 - e$v2)
direction <- if (e$v1 > e$v2) "City 2 -> City 1" else if (e$v2 > e$v1) "City 1 -> City 2" else "None"
eq_label <- if (e$migration) {
"<span class='good'>Equilibrium (utilities equalized)</span>"
} else if (gap < 1) {
"<span class='good'>Approximately equal</span>"
} else {
paste0("<span class='bad'>Gap = ", round(gap, 1), " -- not in equilibrium</span>")
}
tags$div(class = "info-box",
HTML(paste0(
"<b>City 1 utility:</b> ", round(e$v1, 1), "<br>",
"<b>City 2 utility:</b> ", round(e$v2, 1), "<br>",
"<b>City 1 rent:</b> $", round(e$r1, 1), "k<br>",
"<b>City 2 rent:</b> $", round(e$r2, 1), "k<br>",
"<b>Migration:</b> ", direction, "<br>",
"<b>Status:</b> ", eq_label
))
)
})
}
shinyApp(ui, server)
Things to try
- Give City 1 high wages and low amenities, City 2 the reverse: with migration on, rents adjust so utility is equal. High-wage City 1 gets high rents; pleasant City 2 gets lower rents.
- Turn migration off: see the utility gap. One city is strictly better, and people would move there if they could.
- Make both cities identical: no utility gap, no migration pressure. This is the trivial equilibrium.
- Give City 1 high wages AND high amenities: with migration on, City 1 gets very high rents — both advantages are capitalized into housing costs.
Revealed preference for amenities
The spatial equilibrium condition is useful because it lets you infer amenity values from observables. If city A has the same wages as city B but higher rents, then A must have better amenities — by exactly the rent difference. The market reveals what people are willing to pay for sunshine, safety, cultural institutions, or short commutes.
Albouy (2012) used this logic to construct quality-of-life rankings across US cities. The approach adjusts for federal taxes (which don’t vary across cities but reduce the wage compensation for bad amenities) and finds that Honolulu, Santa Barbara, and San Francisco top the quality-of-life ranking — consistent with people accepting lower real wages to live there.
The logic also works for negative amenities. If a city has unusually low rents and low wages, the spatial equilibrium framework says the city must have poor amenities — otherwise people would have moved in, driving rents up.
Did you know?
- Rosen (1979) and Roback (1982) formalized the idea that wages and rents jointly compensate for amenity differences across cities. The framework is sometimes called the “Rosen-Roback model” even though the two papers had somewhat different focuses — Rosen emphasized labor markets, Roback added housing markets.
- Albouy (2012) published an influential quality-of-life ranking of US cities based on the spatial equilibrium framework, adjusting for federal tax distortions. Honolulu ranked first — people accept substantially lower real wages to live there.
- Why does anyone live in Detroit? A classic puzzle for spatial equilibrium. The answer involves moving costs, place-based social networks, housing market frictions (underwater mortgages), and the fact that Detroit’s very low rents do partially compensate for low wages and amenities. The model predicts slow outmigration, which is exactly what happened — Detroit lost 25% of its population between 2000 and 2020.