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.

The Model View. The simulation below has two towns with different tax rates and public good levels. Households differ by income and preferences. With no moving costs, they sort perfectly. With moving costs, sorting is incomplete — some households are “stuck” in the wrong town.

#| 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.