Tiebout Sorting
The idea
How should local public goods be provided? Unlike private goods (where the market works well), public goods — schools, parks, police — are non-excludable and non-rival. The classic public finance result (Samuelson 1954) says markets underprovide them.
Tiebout (1956) offered a radical alternative: competition between jurisdictions can solve the public goods problem. If there are many local governments, each offering a different bundle of taxes and services, households can vote with their feet — moving to the jurisdiction that best matches their preferences.
The mechanism
Suppose town A has high taxes and excellent schools, while town B has low taxes and basic schools. A family that values education highly moves to town A. A retiree with no children moves to town B. Each household sorts into the community that matches its preferences, just as consumers choose among private goods in a market.
In the Tiebout equilibrium:
- Each jurisdiction provides the level of public goods its residents want
- Taxes reflect the cost of provision
- No one wants to move — revealed preference through location choice
- Public goods are provided efficiently, as if by a market
The result: sorting and inequality
Tiebout sorting is efficient in one sense (people get what they want) but creates a problem: income stratification across jurisdictions. High-income households cluster in towns with high-quality public goods and high taxes. Low-income households cluster in towns with low-quality services and low taxes. The result is sharp inequality in school quality, public safety, and infrastructure across neighboring jurisdictions.
This is the American pattern: affluent suburbs with excellent schools adjacent to underfunded urban school districts, separated by invisible jurisdictional boundaries.
#| standalone: true
#| viewerHeight: 700
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("Town A (high service)"),
sliderInput("tax_a", "Tax rate (%):", min = 5, max = 40, value = 25, step = 1),
sliderInput("school_a", "School quality (1-10):", min = 1, max = 10, value = 8, step = 1),
tags$hr(),
tags$h4("Town B (low service)"),
sliderInput("tax_b", "Tax rate (%):", min = 5, max = 40, value = 10, step = 1),
sliderInput("school_b", "School quality (1-10):", min = 1, max = 10, value = 4, step = 1),
tags$hr(),
sliderInput("n_hh", "Number of households:", min = 50, max = 300, value = 150, step = 10),
sliderInput("income_spread", "Income inequality (spread):", min = 5, max = 40, value = 20, step = 2),
checkboxInput("moving_costs", "Moving costs (friction)", value = FALSE),
actionButton("go", "Sort households", class = "btn-primary", width = "100%"),
uiOutput("info")
),
mainPanel(
width = 8,
fluidRow(
column(6, plotOutput("income_plot", height = "380px")),
column(6, plotOutput("utility_plot", height = "380px"))
)
)
)
)
server <- function(input, output, session) {
result <- reactive({
input$go
n <- input$n_hh
tax_a <- input$tax_a / 100
tax_b <- input$tax_b / 100
g_a <- input$school_a
g_b <- input$school_b
spread <- input$income_spread
moving <- input$moving_costs
# Generate households with different incomes and preferences
set.seed(NULL)
income <- 30 + spread * abs(rnorm(n)) # income in $1000s
# Preference for public goods (higher income -> somewhat higher preference)
pref_g <- 0.3 + 0.5 * (income - min(income)) / (max(income) - min(income) + 1) +
rnorm(n, 0, 0.15)
pref_g <- pmax(pmin(pref_g, 1), 0.05)
# Utility in each town: V = (1 - tax) * income + pref * school_quality * 10
v_a <- (1 - tax_a) * income + pref_g * g_a * 10
v_b <- (1 - tax_b) * income + pref_g * g_b * 10
# Without moving costs: choose the better town
if (!moving) {
choice <- ifelse(v_a >= v_b, "A", "B")
} else {
# With moving costs: some households are randomly assigned initially
# and only move if the gain exceeds a threshold
initial <- sample(c("A", "B"), n, replace = TRUE)
threshold <- abs(rnorm(n, mean = 5, sd = 3))
gain_from_a <- v_a - v_b
choice <- initial
# Those in B who'd prefer A: move only if gain > threshold
choice[initial == "B" & gain_from_a > threshold] <- "A"
# Those in A who'd prefer B: move only if loss > threshold
choice[initial == "A" & gain_from_a < -threshold] <- "B"
}
# Who's satisfied (in their preferred town)?
preferred <- ifelse(v_a >= v_b, "A", "B")
satisfied <- choice == preferred
# Tiebout efficiency: fraction in their preferred town
efficiency <- mean(satisfied)
list(income = income, pref_g = pref_g,
v_a = v_a, v_b = v_b, choice = choice,
satisfied = satisfied, efficiency = efficiency,
n_a = sum(choice == "A"), n_b = sum(choice == "B"),
avg_inc_a = mean(income[choice == "A"]),
avg_inc_b = mean(income[choice == "B"]),
tax_a = tax_a, tax_b = tax_b, g_a = g_a, g_b = g_b)
})
output$income_plot <- renderPlot({
r <- result()
par(mar = c(5, 5, 4, 1))
inc_a <- r$income[r$choice == "A"]
inc_b <- r$income[r$choice == "B"]
rng <- range(r$income)
brks <- seq(rng[1] - 2, rng[2] + 2, length.out = 25)
ha <- hist(inc_a, breaks = brks, plot = FALSE)
hb <- hist(inc_b, breaks = brks, plot = FALSE)
ylim <- c(0, max(ha$counts, hb$counts) * 1.2)
hist(inc_a, breaks = brks, col = adjustcolor("#3498db", 0.5),
border = "white", main = "Income Distribution by Town",
xlab = "Household income ($1000s)", ylim = ylim)
hist(inc_b, breaks = brks, col = adjustcolor("#e74c3c", 0.5),
border = "white", add = TRUE)
abline(v = r$avg_inc_a, col = "#3498db", lwd = 2, lty = 2)
abline(v = r$avg_inc_b, col = "#e74c3c", lwd = 2, lty = 2)
legend("topright", bty = "n", cex = 0.85,
legend = c(paste0("Town A (n=", r$n_a, ", mean=$", round(r$avg_inc_a), "k)"),
paste0("Town B (n=", r$n_b, ", mean=$", round(r$avg_inc_b), "k)")),
fill = c(adjustcolor("#3498db", 0.5), adjustcolor("#e74c3c", 0.5)),
border = "white")
})
output$utility_plot <- renderPlot({
r <- result()
par(mar = c(5, 5, 4, 1))
cols <- ifelse(r$satisfied, "#27ae60", "#e74c3c")
pch_vals <- ifelse(r$choice == "A", 19, 17)
plot(r$income, r$v_a - r$v_b, pch = pch_vals, cex = 1.2,
col = adjustcolor(cols, 0.6),
xlab = "Household income ($1000s)",
ylab = "Utility advantage of Town A (V_A - V_B)",
main = "Who prefers which town?")
abline(h = 0, lty = 2, col = "gray50", lwd = 1.5)
text(max(r$income) * 0.9, max(r$v_a - r$v_b) * 0.8,
"Prefers A", col = "#3498db", cex = 1, font = 2)
text(max(r$income) * 0.9, min(r$v_a - r$v_b) * 0.8,
"Prefers B", col = "#e74c3c", cex = 1, font = 2)
legend("topleft", bty = "n", cex = 0.8,
legend = c("In Town A", "In Town B", "Satisfied", "Mismatched"),
pch = c(19, 17, 19, 19), pt.cex = 1.2,
col = c("gray50", "gray50", "#27ae60", "#e74c3c"))
})
output$info <- renderUI({
r <- result()
inc_gap <- abs(r$avg_inc_a - r$avg_inc_b)
tags$div(class = "info-box",
HTML(paste0(
"<b>Town A:</b> ", r$n_a, " households, avg income $", round(r$avg_inc_a), "k<br>",
"<b>Town B:</b> ", r$n_b, " households, avg income $", round(r$avg_inc_b), "k<br>",
"<b>Income gap:</b> $", round(inc_gap), "k<br>",
"<b>Fraction satisfied:</b> ",
ifelse(r$efficiency > 0.9,
paste0("<span class='good'>", round(r$efficiency * 100), "%</span>"),
paste0("<span class='bad'>", round(r$efficiency * 100), "%</span>")),
"<br>",
"<b>Sorting:</b> ",
ifelse(r$efficiency > 0.9, "Strong Tiebout sorting",
ifelse(r$efficiency > 0.7, "Partial sorting (frictions matter)",
"Weak sorting (high frictions)"))
))
)
})
}
shinyApp(ui, server)
Things to try
- No moving costs: households sort almost perfectly. High-income households go to Town A (high taxes, good schools), low-income to Town B. This is the pure Tiebout result.
- Turn on moving costs: some households are “stuck” in the wrong town. The red dots in the right panel are mismatched — they’d prefer the other town but can’t afford to move. Sorting efficiency drops.
- Make the towns identical: no sorting pressure. Households distribute randomly.
- Increase income inequality: the income gap between towns widens. Tiebout sorting amplifies underlying inequality.
- Give Town B very high taxes and low school quality: almost everyone moves to Town A. Town B becomes a fiscal death spiral — low income, low revenue, poor services.
Connections
- Spatial Equilibrium — Tiebout sorting is a local version of spatial equilibrium. Instead of equalizing utility across cities, it equalizes utility across jurisdictions within a metro area.
- Hedonic Pricing — school quality differences across jurisdictions get capitalized into house prices. Tiebout sorting is why the hedonic price of school quality is so large.
- Regression Discontinuity — the sharp boundaries between school districts created by Tiebout sorting are used as natural experiments in RDD designs (like Black 1999).
Did you know?
- Charles Tiebout’s 1956 paper “A Pure Theory of Local Expenditures” is one of the most cited papers in public economics. He was a graduate student at the time. The paper is only 8 pages long.
- Bewley (1981) showed that the Tiebout mechanism requires very specific conditions to achieve efficiency: enough jurisdictions, no spillovers between jurisdictions, no economies of scale, and perfectly mobile households. In practice, none of these hold perfectly — but the sorting prediction is remarkably robust.
- School district boundaries create some of the sharpest Tiebout sorting in America. Families with school-age children are willing to pay large premiums to live on the “right” side of a boundary. Black (1999) exploited this by comparing house prices on opposite sides of school district boundaries, estimating that parents pay roughly 2.5% more for a 5% increase in test scores. This boundary-based approach is now a standard identification strategy in urban and education economics.