Adverse Selection
When insurers cannot tell who is sick
Adverse selection arises when one side of a market has private information about their type. In insurance, buyers know more about their health risk than insurers do. This asymmetry can destroy markets.
The logic of the death spiral
Akerlof (1970) described the “lemons problem” for used cars, but the logic applies directly to insurance:
- An insurer sets a premium equal to the average expected cost of the pool
- Healthy (low-risk) people find the premium too high relative to their expected costs — some drop out
- The remaining pool is sicker on average, so costs rise
- The insurer raises the premium to cover costs
- More healthy people drop out
- Repeat until only the sickest people remain, or the market collapses entirely
This “death spiral” is the textbook case of market failure from asymmetric information.
The death spiral
#| 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: #ebf5fb; border-radius: 6px; padding: 12px;
margin-top: 10px; font-size: 13px;
}
"))),
sidebarLayout(
sidebarPanel(
width = 3,
sliderInput("frac_sick", "Fraction sick in population:",
min = 0.1, max = 0.9, value = 0.3, step = 0.05),
sliderInput("cost_sick", "Annual cost if sick ($):",
min = 5000, max = 50000, value = 20000, step = 1000),
sliderInput("cost_healthy", "Annual cost if healthy ($):",
min = 500, max = 10000, value = 2000, step = 500),
radioButtons("rating", "Pricing rule:",
choices = c("Community rating (pooled)" = "community",
"Risk rating (separate)" = "risk"),
selected = "community"),
tags$hr(),
uiOutput("info_box")
),
mainPanel(
width = 9,
plotOutput("spiral_plot", height = "280px"),
plotOutput("efc_plot", height = "300px")
)
)
)
server <- function(input, output, session) {
vals <- reactive({
fs <- input$frac_sick
cs <- input$cost_sick
ch <- input$cost_healthy
# WTP: sick people will pay up to their expected cost + risk premium
# Healthy people will pay less
wtp_sick <- cs * 1.1
wtp_healthy <- ch * 1.5
# Under community rating: one premium for all
avg_cost <- fs * cs + (1 - fs) * ch
premium_com <- avg_cost * 1.05 # 5% loading
# Who buys under community rating?
sick_buy <- wtp_sick >= premium_com
healthy_buy <- wtp_healthy >= premium_com
# Simulate death spiral rounds
pool_sick <- fs
pool_healthy <- 1 - fs
premiums <- c()
frac_insured <- c()
rounds <- 8
ps <- pool_sick
ph <- pool_healthy
for (r in 1:rounds) {
total <- ps + ph
if (total <= 0) {
premiums <- c(premiums, NA)
frac_insured <- c(frac_insured, 0)
next
}
avg <- (ps * cs + ph * ch) / total
prem <- avg * 1.05
premiums <- c(premiums, prem)
frac_insured <- c(frac_insured, total)
# Healthy people drop out if premium > WTP
# Fraction of healthy who stay: decreasing in premium
healthy_stay_rate <- max(0, min(1, (wtp_healthy - prem) / wtp_healthy + 0.5))
sick_stay_rate <- max(0, min(1, (wtp_sick - prem) / wtp_sick + 0.5))
ph <- ph * healthy_stay_rate
ps <- ps * sick_stay_rate
}
# EFC diagram: rank people by WTP (highest first)
# Demand curve: WTP as function of quantity insured
n_people <- 100
n_sick <- round(fs * n_people)
n_h <- n_people - n_sick
# Each person has a type and a WTP with some noise
set.seed(42)
types <- c(rep("sick", n_sick), rep("healthy", n_h))
costs <- ifelse(types == "sick", cs, ch)
wtp <- ifelse(types == "sick",
cs * (1 + runif(n_people, 0, 0.3))[1:n_people],
ch * (1 + runif(n_people, 0.2, 0.8))[1:n_people])
wtp <- wtp[1:n_people]
costs <- costs[1:n_people]
# Sort by WTP descending (highest WTP buys first)
ord <- order(wtp, decreasing = TRUE)
wtp_sorted <- wtp[ord]
costs_sorted <- costs[ord]
# Average cost curve: mean cost of first Q enrollees
avg_cost_curve <- cumsum(costs_sorted) / (1:n_people)
# Marginal cost curve
mc_curve <- costs_sorted
# Market equilibrium: where demand = average cost
eq_q <- NA
for (i in 1:n_people) {
if (wtp_sorted[i] < avg_cost_curve[i]) {
eq_q <- i - 1
break
}
}
if (is.na(eq_q)) eq_q <- n_people
# Efficient quantity: where demand = marginal cost
eff_q <- NA
for (i in 1:n_people) {
if (wtp_sorted[i] < mc_curve[i]) {
eff_q <- i - 1
break
}
}
if (is.na(eff_q)) eff_q <- n_people
dwl <- 0
if (eff_q > eq_q && eq_q > 0) {
# DWL = sum of (WTP - MC) for people between eq_q and eff_q
idx <- (eq_q + 1):min(eff_q, n_people)
dwl <- sum(wtp_sorted[idx] - mc_curve[idx])
}
eq_prem <- if (eq_q > 0) avg_cost_curve[eq_q] else NA
list(premiums = premiums, frac_insured = frac_insured,
wtp_sorted = wtp_sorted, avg_cost_curve = avg_cost_curve,
mc_curve = mc_curve, eq_q = eq_q, eff_q = eff_q,
dwl = dwl, n_people = n_people, eq_prem = eq_prem,
rating = input$rating, fs = fs, cs = cs, ch = ch)
})
output$spiral_plot <- renderPlot({
v <- vals()
par(mar = c(4, 5, 3, 1))
rounds <- 1:length(v$premiums)
valid <- !is.na(v$premiums)
if (sum(valid) < 2) {
plot.new()
text(0.5, 0.5, "Market collapsed immediately", cex = 1.5, col = "#e74c3c")
return()
}
plot(rounds[valid], v$premiums[valid] / 1000, type = "b", pch = 19, lwd = 2.5,
col = "#e74c3c", xlab = "Round", ylab = "Premium ($1000s)",
main = "Death Spiral: Premium Over Time",
ylim = c(0, max(v$premiums[valid], na.rm = TRUE) / 1000 * 1.2),
cex.lab = 1.1)
par(new = TRUE)
plot(rounds[valid], v$frac_insured[valid], type = "b", pch = 17, lwd = 2.5,
col = "#3498db", axes = FALSE, xlab = "", ylab = "",
ylim = c(0, 1.2))
axis(4, col = "#3498db", col.axis = "#3498db")
mtext("Fraction insured", side = 4, line = 2.5, col = "#3498db")
legend("right", bty = "n", cex = 0.85,
legend = c("Premium", "Fraction insured"),
col = c("#e74c3c", "#3498db"), pch = c(19, 17), lwd = 2)
})
output$efc_plot <- renderPlot({
v <- vals()
par(mar = c(5, 5, 3, 1))
q_seq <- 1:v$n_people
plot(q_seq, v$wtp_sorted / 1000, type = "l", lwd = 3, col = "#2c3e50",
xlab = "Quantity insured (people)", ylab = "$ (thousands)",
main = "Demand-Cost Diagram (Einav-Finkelstein-Cullen)",
ylim = c(0, max(v$wtp_sorted / 1000) * 1.1),
cex.lab = 1.1)
lines(q_seq, v$avg_cost_curve / 1000, lwd = 3, col = "#e74c3c")
lines(q_seq, v$mc_curve / 1000, lwd = 2, col = "#e74c3c", lty = 2)
# Shade DWL
if (v$eff_q > v$eq_q && v$eq_q > 0) {
idx <- v$eq_q:v$eff_q
polygon(c(idx, rev(idx)),
c(v$wtp_sorted[idx] / 1000, rev(v$mc_curve[idx] / 1000)),
col = adjustcolor("#f39c12", 0.3), border = NA)
text((v$eq_q + v$eff_q) / 2,
mean(c(v$wtp_sorted[v$eq_q], v$mc_curve[v$eq_q])) / 1000,
"DWL", col = "#e67e22", font = 2, cex = 1.1)
}
# Mark equilibrium and efficient quantities
if (v$eq_q > 0 && v$eq_q <= v$n_people) {
abline(v = v$eq_q, lty = 3, col = "#7f8c8d")
text(v$eq_q, max(v$wtp_sorted / 1000) * 0.95,
paste0("Mkt eq (Q=", v$eq_q, ")"), col = "#7f8c8d",
cex = 0.85, adj = 0)
}
if (v$eff_q > 0 && v$eff_q <= v$n_people) {
abline(v = v$eff_q, lty = 3, col = "#27ae60")
text(v$eff_q, max(v$wtp_sorted / 1000) * 0.85,
paste0("Efficient (Q=", v$eff_q, ")"), col = "#27ae60",
cex = 0.85, adj = 0)
}
legend("topright", bty = "n", cex = 0.85,
legend = c("Demand (WTP)", "Average cost", "Marginal cost", "DWL"),
col = c("#2c3e50", "#e74c3c", "#e74c3c",
adjustcolor("#f39c12", 0.5)),
lwd = c(3, 3, 2, 8), lty = c(1, 1, 2, 1))
})
output$info_box <- renderUI({
v <- vals()
eq_prem_txt <- if (!is.na(v$eq_prem)) {
paste0("$", format(round(v$eq_prem), big.mark = ","))
} else { "No equilibrium" }
tags$div(class = "stats-box",
HTML(paste0(
"<b>Market equilibrium:</b><br>",
"Premium: ", eq_prem_txt, "<br>",
"Insured: <span class='", ifelse(v$eq_q < v$eff_q, "bad", "good"),
"'>", v$eq_q, " of ", v$n_people, "</span><br>",
"Efficient: ", v$eff_q, " of ", v$n_people, "<br>",
"<hr style='margin:8px 0'>",
"<b>DWL from adverse selection:</b><br>",
"<span class='bad'>$", format(round(v$dwl), big.mark = ","), "</span><br>",
"<hr style='margin:8px 0'>",
"<b>Underinsurance:</b> ", v$eff_q - v$eq_q, " people"
))
)
})
}
shinyApp(ui, server)
Things to try
- Increase the fraction sick (0.7+): average cost rises, healthy people are priced out, the death spiral accelerates.
- Set fraction sick very low (0.1): the pool is mostly healthy, the premium is low, and nearly everyone buys.
- Widen the gap between sick and healthy costs: adverse selection becomes more severe because the types are more different.
- Compare community rating (pooled premium) to risk rating: under risk rating, each type pays their own cost and there is no adverse selection — but sick people may be priced out entirely.
Did you know?
George Akerlof won the Nobel Prize in 2001 (with Spence and Stiglitz) for his 1970 paper on “lemons.” His original paper was rejected by three journals — reviewers said the result was trivial or incorrect.
The ACA individual mandate (2010) was a direct response to adverse selection. By requiring everyone to buy insurance (or pay a penalty), the mandate kept healthy people in the pool and prevented the death spiral. When the penalty was reduced to $0 in 2019, some predicted market collapse — but the exchanges survived, partly because subsidies kept coverage affordable for many.
Cutler and Zeckhauser (1998) documented adverse selection in employer health plans: when Harvard University introduced a choice between a generous and a stingy plan, the generous plan experienced a classic death spiral and was eventually eliminated. This became one of the most cited examples of adverse selection in practice.
The Einav-Finkelstein-Cullen (2010) framework can also detect advantageous selection — when healthier people are more likely to buy insurance (perhaps because they are also more risk-averse). In this case, the average cost curve slopes up rather than down, and the market provides too much insurance rather than too little.