Moral Hazard & Insurance

The fundamental tradeoff

Insurance exists to protect people from financial risk. But when insurance reduces the price of care, people consume more of it. This is moral hazard — the idea that insulated individuals change their behavior in ways that increase costs.

The tradeoff is inescapable: more generous insurance provides better risk protection but also more overconsumption. Less generous insurance reduces moral hazard but leaves people exposed to financial risk. Getting this balance right is the central problem of insurance design.

What the RAND HIE found

The RAND Health Insurance Experiment (1974–1982) remains the gold standard. Families were randomly assigned to plans with coinsurance rates of 0% (free care), 25%, 50%, or 95%. The key findings:

  • People with free care spent about 30% more than those on the 95% plan
  • The price sensitivity was concentrated in outpatient and discretionary care — things like office visits and minor procedures
  • For inpatient and serious care, the difference was much smaller — people did not skip hospitalizations for heart attacks regardless of cost-sharing
  • Health outcomes were similar across groups for most people, though low-income participants with hypertension benefited from free care

The implication: coinsurance can reduce moral hazard without much harm, but the effect is uneven across types of care and types of patients.

Demand for care under insurance

When a patient faces coinsurance rate \(c\), they pay \(c \times P\) per unit of care (where \(P\) is the full price). Lower coinsurance means a lower effective price, which means higher quantity demanded. The gap between the quantity consumed with insurance and the quantity that would be consumed at the full price represents the moral hazard — care that is consumed only because someone else is paying part of the bill.

The welfare cost of this overconsumption is a deadweight loss triangle: the cost of producing the extra care exceeds its value to the patient. The patient consumes it because their out-of-pocket price is below the social cost.

#| standalone: true
#| viewerHeight: 650

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; }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      sliderInput("coins", "Coinsurance rate (%):",
                  min = 0, max = 100, value = 25, step = 5),

      sliderInput("elast", "Demand elasticity:",
                  min = -0.8, max = -0.1, value = -0.2, step = 0.05),

      sliderInput("income", "Income ($1000s):",
                  min = 20, max = 150, value = 60, step = 10),

      tags$hr(),
      uiOutput("info_box")
    ),

    mainPanel(
      width = 9,
      plotOutput("demand_plot", height = "520px")
    )
  )
)

server <- function(input, output, session) {

  vals <- reactive({
    c_rate <- input$coins / 100
    elast  <- input$elast
    inc    <- input$income

    # Full price of care per unit (normalized)
    P <- 100

    # Base quantity at full price (income-adjusted)
    Q_base <- 5 + 0.05 * inc

    # Effective price to consumer
    P_eff <- c_rate * P
    if (P_eff < 1) P_eff <- 1

    # Quantity demanded: Q = Q_base * (P_eff / P)^elast
    Q_insured <- Q_base * (P_eff / P)^elast
    Q_full    <- Q_base  # quantity at full price

    # Out-of-pocket and insurer spending
    oop     <- Q_insured * P_eff
    insurer <- Q_insured * P * (1 - c_rate)
    total   <- Q_insured * P

    # Deadweight loss: triangle between Q_full and Q_insured
    # DWL = 0.5 * (P - P_eff) * (Q_insured - Q_full)
    extra_Q <- max(0, Q_insured - Q_full)
    dwl     <- 0.5 * (P - P_eff) * extra_Q

    # Consumer surplus under insurance (area under demand curve above P_eff)
    # Approximate as triangle: 0.5 * Q_insured * (max_wtp - P_eff)
    max_wtp <- P * (Q_insured / Q_base)^(1 / elast) * 2
    if (max_wtp < P) max_wtp <- P * 2

    list(P = P, P_eff = P_eff, Q_base = Q_base, Q_full = Q_full,
         Q_insured = Q_insured, oop = oop, insurer = insurer,
         total = total, dwl = dwl, c_rate = c_rate, elast = elast,
         extra_Q = extra_Q)
  })

  output$demand_plot <- renderPlot({
    v <- vals()

    # Build demand curve: P = willingness to pay at each Q
    # Q = Q_base * (P_consumer / P_full)^elast => P_consumer = P_full * (Q / Q_base)^(1/elast)
    Q_seq <- seq(0.1, v$Q_insured * 1.5, length.out = 300)
    # Inverse demand: WTP(Q) = P * (Q / Q_base)^(1/elast)
    WTP <- v$P * (Q_seq / v$Q_base)^(1 / v$elast)
    WTP[WTP > v$P * 3] <- NA

    par(mar = c(5, 5, 3, 2))
    plot(Q_seq, WTP, type = "l", lwd = 3, col = "#2c3e50",
         xlab = "Quantity of care", ylab = "Price per unit ($)",
         main = "Demand for Healthcare Under Insurance",
         xlim = c(0, max(Q_seq)),
         ylim = c(0, min(max(WTP, na.rm = TRUE), v$P * 2.5)),
         cex.lab = 1.2)

    # Full price line
    abline(h = v$P, lty = 2, lwd = 2, col = "#7f8c8d")
    text(max(Q_seq) * 0.85, v$P + 5, "Full price (P)", col = "#7f8c8d", cex = 0.9)

    # Effective price line
    abline(h = v$P_eff, lty = 2, lwd = 2, col = "#3498db")
    text(max(Q_seq) * 0.85, v$P_eff + 5,
         paste0("Patient pays (", round(v$c_rate * 100), "% of P)"),
         col = "#3498db", cex = 0.9)

    # Shade DWL triangle
    if (v$extra_Q > 0) {
      x_dwl <- c(v$Q_full, v$Q_insured, v$Q_insured, v$Q_full)
      y_dwl <- c(v$P, v$P, v$P_eff, v$P_eff)

      # Better DWL: area between demand curve and full price, from Q_full to Q_insured
      q_tri <- seq(v$Q_full, v$Q_insured, length.out = 100)
      wtp_tri <- v$P * (q_tri / v$Q_base)^(1 / v$elast)
      polygon(c(v$Q_full, q_tri, v$Q_insured),
              c(v$P, wtp_tri, v$P),
              col = adjustcolor("#e74c3c", 0.3), border = NA)
      text((v$Q_full + v$Q_insured) / 2, (v$P + v$P_eff) / 2,
           "DWL", col = "#e74c3c", font = 2, cex = 1.1)
    }

    # Shade insurer cost (rectangle)
    if (v$c_rate < 1) {
      rect(0, v$P_eff, v$Q_insured, v$P,
           col = adjustcolor("#3498db", 0.15), border = NA)
      text(v$Q_insured / 2, (v$P + v$P_eff) / 2 + 8,
           "Insurer pays", col = "#2980b9", cex = 0.9)
    }

    # Shade OOP cost
    rect(0, 0, v$Q_insured, v$P_eff,
         col = adjustcolor("#f39c12", 0.15), border = NA)
    text(v$Q_insured / 2, v$P_eff / 2,
         "Patient pays (OOP)", col = "#e67e22", cex = 0.9)

    # Mark quantities
    abline(v = v$Q_full, lty = 3, col = "#95a5a6")
    abline(v = v$Q_insured, lty = 3, col = "#95a5a6")
    text(v$Q_full, -5, expression(Q[full]), col = "#2c3e50", cex = 0.9, xpd = TRUE)
    text(v$Q_insured, -5, expression(Q[ins]), col = "#2c3e50", cex = 0.9, xpd = TRUE)

    legend("topright", bty = "n", cex = 0.85,
           legend = c("Demand curve", "Full price", "Patient's price",
                      "Deadweight loss", "Insurer cost", "Out-of-pocket"),
           col = c("#2c3e50", "#7f8c8d", "#3498db",
                   adjustcolor("#e74c3c", 0.5),
                   adjustcolor("#3498db", 0.3),
                   adjustcolor("#f39c12", 0.3)),
           lwd = c(3, 2, 2, 8, 8, 8),
           lty = c(1, 2, 2, 1, 1, 1))
  })

  output$info_box <- renderUI({
    v <- vals()
    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>Quantity (full price):</b> ", round(v$Q_full, 1), " units<br>",
        "<b>Quantity (insured):</b> ", round(v$Q_insured, 1), " units<br>",
        "<b>Moral hazard:</b> <span class='bad'>+", round(v$extra_Q, 1), " extra units</span><br>",
        "<hr style='margin:8px 0'>",
        "<b>Out-of-pocket:</b> $", format(round(v$oop), big.mark = ","), "<br>",
        "<b>Insurer pays:</b> $", format(round(v$insurer), big.mark = ","), "<br>",
        "<b>Total spending:</b> $", format(round(v$total), big.mark = ","), "<br>",
        "<hr style='margin:8px 0'>",
        "<b>Deadweight loss:</b> <span class='bad'>$", format(round(v$dwl), big.mark = ","), "</span>"
      ))
    )
  })
}

shinyApp(ui, server)

Things to try

  • Set coinsurance to 0% (free care) and watch quantity spike — this is maximum moral hazard.
  • Set coinsurance to 100% and note Q = Q_full — no insurance means no moral hazard but also no risk protection.
  • Make demand more elastic (-0.5 or -0.8): moral hazard grows because patients respond more to price.
  • Compare low vs high income: higher income shifts baseline demand up.

The welfare tradeoff

Moral hazard is a cost of insurance, but insurance also provides a benefit: risk protection. Risk-averse individuals are willing to pay a premium to avoid the financial uncertainty of medical expenses. The optimal coinsurance rate balances these two forces.

Feldstein (1973) showed that the optimal insurance generosity depends on two things:

  1. Risk aversion — more risk-averse individuals benefit more from insurance, justifying lower coinsurance
  2. Demand elasticity — more price-sensitive individuals generate more moral hazard, justifying higher coinsurance

When risk aversion is high and demand elasticity is low, generous insurance is optimal. When the reverse holds, higher cost-sharing is better.

#| standalone: true
#| viewerHeight: 620

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; }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      sliderInput("risk_av", "Risk aversion (CARA):",
                  min = 0.5, max = 5, value = 2, step = 0.25),

      sliderInput("elast2", "Demand elasticity:",
                  min = -0.8, max = -0.1, value = -0.2, step = 0.05),

      tags$hr(),
      uiOutput("welfare_box")
    ),

    mainPanel(
      width = 9,
      plotOutput("welfare_plot", height = "500px")
    )
  )
)

server <- function(input, output, session) {

  vals <- reactive({
    ra    <- input$risk_av
    elast <- input$elast2

    coins_seq <- seq(0, 100, by = 1)

    # Welfare gain from risk protection: decreasing in coinsurance
    # Higher coinsurance = less insurance = less risk protection
    # Gain = ra * variance_reduction, which is proportional to (1 - c)^2
    risk_gain <- ra * 50 * (1 - coins_seq / 100)^2

    # Welfare loss from moral hazard: increasing as coinsurance falls
    # More insurance = lower price = more overconsumption
    # Loss proportional to elasticity^2 * (1 - c)^2 / 2 (Harberger triangle)
    mh_loss <- abs(elast) * 80 * (1 - coins_seq / 100)^2

    # Net welfare = risk protection gain - moral hazard loss
    net_welfare <- risk_gain - mh_loss

    # Optimal coinsurance = argmax net welfare
    opt_idx   <- which.max(net_welfare)
    opt_coins <- coins_seq[opt_idx]

    list(coins_seq = coins_seq, risk_gain = risk_gain,
         mh_loss = mh_loss, net_welfare = net_welfare,
         opt_coins = opt_coins, opt_welfare = net_welfare[opt_idx])
  })

  output$welfare_plot <- renderPlot({
    v <- vals()

    par(mfrow = c(1, 3), mar = c(5, 4.5, 3, 1))

    # Panel 1: Risk protection gain
    plot(v$coins_seq, v$risk_gain, type = "l", lwd = 3, col = "#27ae60",
         xlab = "Coinsurance rate (%)", ylab = "Welfare ($)",
         main = "Risk Protection Gain", cex.lab = 1.1)
    abline(v = v$opt_coins, lty = 3, col = "#e74c3c", lwd = 1.5)

    # Panel 2: Moral hazard loss
    plot(v$coins_seq, v$mh_loss, type = "l", lwd = 3, col = "#e74c3c",
         xlab = "Coinsurance rate (%)", ylab = "Welfare cost ($)",
         main = "Moral Hazard Loss", cex.lab = 1.1)
    abline(v = v$opt_coins, lty = 3, col = "#e74c3c", lwd = 1.5)

    # Panel 3: Net welfare
    plot(v$coins_seq, v$net_welfare, type = "l", lwd = 3, col = "#2c3e50",
         xlab = "Coinsurance rate (%)", ylab = "Net welfare ($)",
         main = "Net Welfare", cex.lab = 1.1)
    abline(h = 0, lty = 2, col = "#bdc3c7")
    abline(v = v$opt_coins, lty = 2, lwd = 2, col = "#e74c3c")
    points(v$opt_coins, v$opt_welfare, pch = 19, cex = 2, col = "#e74c3c")
    text(v$opt_coins + 3, v$opt_welfare,
         paste0("Optimal: ", v$opt_coins, "%"),
         col = "#e74c3c", adj = 0, cex = 0.95)
  })

  output$welfare_box <- renderUI({
    v <- vals()
    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>Optimal coinsurance:</b> <span class='good'>",
        v$opt_coins, "%</span><br>",
        "<b>Net welfare at optimum:</b> $", round(v$opt_welfare, 1), "<br>",
        "<hr style='margin:8px 0'>",
        "Higher risk aversion &rarr; more insurance<br>",
        "Higher elasticity &rarr; less insurance"
      ))
    )
  })
}

shinyApp(ui, server)

Things to try

  • Set high risk aversion (4+) and low elasticity (-0.1): optimal coinsurance is near 0% — generous insurance is worthwhile because the moral hazard cost is small.
  • Set low risk aversion (0.5) and high elasticity (-0.7): optimal coinsurance is high — these people do not value risk protection much and respond strongly to price.
  • This is why one-size-fits-all insurance is suboptimal: different people have different optimal plans.

Did you know?

  • The RAND Health Insurance Experiment cost over $300 million in today’s dollars, making it one of the most expensive social experiments ever conducted. It ran from 1974 to 1982 and enrolled about 2,000 families across six sites. Joseph Newhouse directed the study.

  • The Oregon Health Insurance Experiment (2008) provided a second major randomized test of insurance effects — see the Oregon Experiment page. Unlike RAND, it studied Medicaid (public insurance for low-income adults) rather than variation in coinsurance.

  • Mark Pauly (1968) wrote the foundational paper on moral hazard in health insurance, arguing that the extra consumption under insurance represents a genuine welfare loss — not just “getting your money’s worth.” This was controversial: some argued that if people want more care when it is cheaper, that reveals genuine demand. The debate continues.