Case-Control Study Design
An observational design that samples on outcome status — identifying cases who experience the event and controls who do not — and compares prior exposure odds between them, estimating an odds ratio that, under incidence-density (risk-set) sampling, equals the rate ratio without any rare-disease assumption.
In plain language
A case-control study works backwards from the result. You start by finding people who already have the outcome you care about (the cases) and a comparison group who do not (the controls), then you look back in time to ask who had been exposed to the thing you suspect. Because you can hunt down rare cases directly instead of waiting for them to appear, this design is fast and cheap for uncommon events. The catch: it tells you whether exposure and outcome travel together (an odds ratio), not how many people overall will get sick, and a poorly chosen control group can quietly distort the answer.
The case-control (CC) design reverses the logic of a cohort: instead of following exposed and unexposed forward to the outcome, it samples on the outcome — identifying everyone (or a sample) who became a case and a sample of controls who did not — and then looks backward at exposure in an etiologically relevant window. Its reason for existing is efficiency: when an outcome is rare or its ascertainment is expensive (adjudication, chart review, genotyping, biospecimen assay), enrolling 300 cases and 1,200 controls answers the question that would otherwise require following hundreds of thousands of people in a cohort.
Core conceptual distinction — what the odds ratio actually estimates
This is the point reviewers test first, and the point most casual descriptions get wrong. The estimand depends entirely on how controls are sampled: - Incidence-density (risk-set) sampling. Controls are sampled from the population still at risk and event-free at the instant each case occurs (the case's index date). Under this scheme the exposure odds ratio estimates the incidence rate ratio directly — with no rare-disease assumption (Greenland & Thomas). This is the correct mental model for pharmacoepidemiology and the one that makes a nested CC algebraically equivalent to a Cox model on the full cohort (Breslow's partial likelihood for a matched risk set is the conditional-logistic likelihood). - Cumulative ("traditional") sampling. Controls are sampled from those still non-cases at the end of the study period. Here the OR estimates the risk (cumulative-incidence) odds ratio, which approximates the risk ratio only under the rare-disease assumption (outcome < ~10%). - Case-cohort sampling. Controls are a random subcohort sampled at baseline (a "base series"); with appropriate weighting the OR estimates the risk ratio and one subcohort can serve multiple outcomes. Conflating these three is the classic error: density-sampled CC does not need a rare disease, cumulative CC does. State the sampling scheme and the resulting estimand explicitly in the SAP.
Pros, cons, and trade-offs
- vs a full cohort (cohort-prospective / cohort-retrospective): CC needs orders of magnitude less data collection for rare outcomes and lets you spend an expensive ascertainment budget (adjudication, assays) only on cases plus a handful of controls. Cost: it cannot produce absolute risks, incidence, or numbers-needed-to-treat without known sampling fractions; it is more exposed to selection bias through control choice; and it is harder to explain to clinical stakeholders. Prefer CC when the outcome is rare or its measurement is the binding cost constraint. Prefer a cohort when you need absolute risk, multiple outcomes from one exposure, or the outcome is common. - vs nested case-control: A nested CC samples cases and risk-set controls from inside an already-defined cohort (claims, EHR, registry). It inherits the cohort's clean source population, time-zero, and density-sampling validity while keeping CC efficiency — it is the modern default whenever a usable source cohort exists. Stand-alone CC (case registry + controls sampled from eligibility files) is what you fall back to when no such cohort is available. Prefer nested essentially always when you have the cohort. - vs self-controlled designs (self-controlled-case-series, case-crossover, case-time-control): Self-controlled designs use each case as their own control and therefore null out all time-invariant confounding (genetics, chronic frailty, baseline SES) by construction — something CC can only attempt through measurement and matching. But they require transient, repeatable exposures and (for SCCS) recurrent or at least non-fatal events, and they cannot study fixed exposures. Prefer CC for one-time or chronic exposures, fatal/non-recurrent outcomes, or when between-person factors are themselves of interest; prefer self-controlled for acute drug triggers of recurrent events.
When to use
Rare outcomes (specific malignancies, agranulocytosis, congenital anomalies, sudden cardiac death); outcomes requiring costly adjudication or biospecimens; rapid safety-signal evaluation in a defined source population; any setting where you have a clean cohort and want a nested density-sampled CC as an efficient surrogate for a Cox model.
When NOT to use — and when it is actively misleading
- The outcome is common and you used cumulative sampling. The OR then overstates the risk ratio (the rare-disease approximation fails). Switch to density sampling, a case-cohort design, or report the OR as an odds ratio only. - Controls are not drawn from the population that gave rise to the cases. This is the cardinal sin (Wacholder's "study base" principle). Hospital controls for a community case series, or controls whose exposure prevalence differs from the source base for reasons unrelated to disease, produce selection bias that no analysis can repair. Berkson's bias (hospital-based controls) is the canonical example. - Differential exposure ascertainment by case status. In primary-data CC this is recall bias; in claims/EHR it is surveillance/detection bias — cases, by virtue of being sicker or in more contact with the system, have more complete exposure capture than controls, inflating the OR. Match or adjust on healthcare utilization to blunt it. - Outcome misclassification. Low PPV of the case algorithm contaminates the case series with non-cases and biases the OR toward the null; this is why CC validity is downstream of the outcome phenotype (see claims-outcome-algorithm-ppv- sensitivity-rwe). - Prevalent exposure mixed in. If "exposed" includes long-term prevalent users, depletion-of-susceptibles and immortal-time issues from the source cohort leak in; apply a new-user/incident restriction inside the exposure window.
Data-source operational depth
- Claims (FFS vs MA, commercial): Cases come from validated outcome algorithms (e.g., 1 inpatient or 2 outpatient claims with the diagnosis in a primary/qualifying position, or a procedure + diagnosis). The index date is the case-defining event date; for risk-set controls it is the case's index date assigned to a still-at-risk enrollee. Exposure comes from pharmacy claims (NDC + `fill_date` + `days_supply`) for drugs or medical claims (CPT/HCPCS/J-codes, ICD-10-PCS) for procedures/devices, ascertained in a pre-specified pre-index window (e.g., 90 days for an acute trigger, cumulative for chronic exposure). Failure modes: (1) Medicare Advantage person-time lacks fee-for-service claims — a control's "no exposure" can be unobserved rather than truly unexposed, so require both medical and pharmacy benefit and exclude MA-only person-time. (2) Differential competing risks by exposure in the elderly — if the exposure raises short-term mortality, those people die before becoming cases and are absent from the risk set, distorting the OR; restrict risk sets to those alive and enrolled at the case's index date and run a competing-risks sensitivity analysis. (3) Detection bias — match controls on prior utilization (counts of visits, baseline cost quartile) so cases are not spuriously "more exposed" merely because they are in more contact with the system. (4) Claim reversals/adjudication lag — net by `claim_id` and avoid the most recent, incompletely adjudicated months. - EHR: Cases via structured codes + NLP phenotyping, validated on a chart-reviewed subset. Exposure = the medication order/administration, not a dispensing, so link to pharmacy fills where possible. The structural threat is visit-driven (informative) observation: a control who is healthy and rarely visits looks "unexposed" simply because little is recorded. Use encounter counts as a matching/adjustment proxy and define the observation window explicitly. - Registry / linked: Disease and product registries are often case-enriched and ideal for rare outcomes with adjudicated case status; link to claims for complete pharmacy/exposure history and to a death index to firm up the risk set. Linkage introduces selection (only the linkable subset) and date-discrepancy issues that must be reconciled before index-date assignment.
Worked claims example (incidence-density nested CC)
Question: does current exposure to a high-risk oral NSAID raise the risk of hospitalized upper-gastrointestinal (UGI) bleeding among adults ≥40 in a commercial + Medicare FFS database? (1) Source cohort: enrollees with ≥365 days of continuous medical and pharmacy enrollment (Parts A/B/D or commercial equivalent), excluding MA-only person-time so that absence of a fill is observed, not missing. (2) Cases: first hospitalization with a validated UGI-bleed algorithm (qualifying ICD-10-CM in the primary position on an inpatient claim); index_date = admission date; exclude anyone with a prior UGI bleed in the lookback (incident cases only). (3) Risk-set controls: for each case, sample m = 4 controls from enrollees alive, enrolled, and event-free on that case's index_date, matched on age (±2 y), sex, and index calendar month (matching on time = density sampling). (4) Exposure: "current use" = an NSAID `fill_date` with `days_supply` covering the case's index_date, or a fill ending within a 14-day carryover; the reference is non-use in the 90-day pre-index window. (5) Covariates: anticoagulant/antiplatelet/PPI use, prior GI diagnoses, and baseline visit count (utilization, to control detection bias), all measured strictly before index_date. (6) Analysis: conditional logistic regression stratified on the matched set (`clogit` / `STRATA match_id`), reporting the exposure OR as a rate ratio; sensitivity analyses vary the carryover window, the matching ratio, and add a negative-control outcome (e.g., a condition the NSAID should not cause) to detect residual confounding.
Worked example
Scenario
We want to know whether taking a high-risk NSAID pain reliever is linked to being hospitalized for an upper-gastrointestinal (GI) bleed. We assemble 100 cases (adults hospitalized for a GI bleed) and 100 controls (similar adults with no such bleed), then check each person's pharmacy records to see who had filled an NSAID before their index date. We count everyone into a 2x2 table and compute the odds ratio.
Dataset
The 2x2 table an analyst builds after classifying each of the 200 people by exposure and case status. Cell letters a, b, c, d are labeled for the odds-ratio formula.
| Cases | Controls | |
|---|---|---|
| Exposed (took NSAID) | a = 90 | b = 60 |
| Unexposed (no NSAID) | c = 10 | d = 40 |
Steps
Sort each person into one of four boxes: a = exposed cases (90), b = exposed controls (60), c = unexposed cases (10), d = unexposed controls (40).
The odds of having been exposed among cases is a / c = 90 / 10 = 9.
The odds of having been exposed among controls is b / d = 60 / 40 = 1.5.
The odds ratio compares those two odds: OR = (a / c) / (b / d), which rearranges to the cross-product OR = (a d) / (b c).
Plug in the cells: OR = (90 40) / (60 10) = 3600 / 600 = 6.0.
Result
OR = (ad)/(bc) = (9040)/(6010) = 3600/600 = 6.0. Cases had 6 times the odds of prior NSAID exposure as controls, suggesting NSAID use is associated with GI bleeding.
Runnable example
python implementation
Incidence-density risk-set sampling and 1:m matched case-control construction from claims-style inputs. Required inputs (already cleaned and de-duplicated): cases : one row per incident case -> person_id, index_date (datetime), age, sex cohort : everyone in...
import numpy as np
import pandas as pd
EXPOSURE_WINDOW_DAYS = 90 # pre-index etiologic window for "current use"
CARRYOVER_DAYS = 14 # supply may end shortly before index and still count as current
AGE_CALIPER = 2
M_CONTROLS = 4
RNG = np.random.default_rng(20240601)
def _enrolled_observable(person_ids, on_date, enroll):
"""Person_ids with continuous, non-MA-only enrollment covering `on_date`."""
e = enroll[enroll["person_id"].isin(person_ids)]
ok = e[(e["enroll_start"] <= on_date) & (e["enroll_end"] >= on_date) & (~e["ma_only"])]
return set(ok["person_id"])
def build_riskset_matched_cc(cases, cohort, enroll, m=M_CONTROLS):
case_ids = set(cases["person_id"])
rows = []
for _, case in cases.iterrows():
idx = case["index_date"]
# Candidate controls: event-free at idx (not yet a case or a case with a later index date),
# enrolled/observable on idx, and within the matching calipers.
future_or_noncase = cohort[
(~cohort["person_id"].isin(case_ids))
| (cohort["person_id"].map(
cases.set_index("person_id")["index_date"]).fillna(pd.Timestamp.max) > idx)
]
observable = _enrolled_observable(future_or_noncase["person_id"], idx, enroll)
pool = future_or_noncase[
(future_or_noncase["person_id"].isin(observable))
& (future_or_noncase["person_id"] != case["person_id"])
& (future_or_noncase["sex"] == case["sex"])
& ((future_or_noncase["age"] - case["age"]).abs() <= AGE_CALIPER)
]
picks = pool.sample(n=min(m, len(pool)), random_state=int(RNG.integers(1e9)))
match_id = f"set_{case['person_id']}"
rows.append({"person_id": case["person_id"], "match_id": match_id,
"is_case": 1, "index_date": idx})
for pid in picks["person_id"]:
rows.append({"person_id": pid, "match_id": match_id,
"is_case": 0, "index_date": idx}) # control inherits the case index date
return pd.DataFrame(rows)
def add_current_exposure(cc, rx, drug_class):
"""Flag 'current use': a fill whose [fill_date, fill_date+days_supply+carryover] covers index_date."""
r = rx[rx["drug_class"] == drug_class].merge(cc[["person_id", "match_id", "index_date"]],
on="person_id")
r["cov_end"] = r["fill_date"] + pd.to_timedelta(r["days_supply"] + CARRYOVER_DAYS, unit="D")
r["covers"] = (r["fill_date"] <= r["index_date"]) & (r["cov_end"] >= r["index_date"])
exposed = r.loc[r["covers"], ["person_id", "match_id"]].drop_duplicates()
exposed["exposed"] = 1
out = cc.merge(exposed, on=["person_id", "match_id"], how="left")
out["exposed"] = out["exposed"].fillna(0).astype(int)
return out
# cc = build_riskset_matched_cc(cases, cohort, enroll)
# cc = add_current_exposure(cc, rx, drug_class="NSAID")
# -> downstream: conditional logistic on `exposed` stratified by `match_id` (see R/SAS).r implementation
Conditional logistic regression for the risk-set matched case-control file produced above. Input `cc` has one row per subject: person_id, match_id (the risk set), is_case (1/0), and one column per exposure/covariate measured strictly in the pre-index...
library(survival)
library(data.table)
# --- Risk-set sampling skeleton (density sampling, m controls per case) ---------------
sample_riskset <- function(cases, cohort, enroll, m = 4L, age_caliper = 2L) {
setDT(cases); setDT(cohort); setDT(enroll)
out <- vector("list", nrow(cases))
for (i in seq_len(nrow(cases))) {
idx <- cases$index_date[i]; cid <- cases$person_id[i]
observable <- enroll[enroll_start <= idx & enroll_end >= idx & !ma_only, unique(person_id)]
pool <- cohort[person_id %in% observable & person_id != cid &
sex == cases$sex[i] & abs(age - cases$age[i]) <= age_caliper, person_id]
ctrls <- if (length(pool) > m) sample(pool, m) else pool
out[[i]] <- data.table(
person_id = c(cid, ctrls),
match_id = paste0("set_", cid),
is_case = c(1L, rep(0L, length(ctrls))),
index_date = idx)
}
rbindlist(out)
}
# --- Conditional logistic on the matched analytic file --------------------------------
# cc: person_id, match_id, is_case, exposed, plus pre-index covariates
fit <- clogit(
is_case ~ exposed + anticoagulant + prior_gi_dx + utilization_q + strata(match_id),
data = cc, method = "exact")
summary(fit) # exp(coef) of `exposed` = incidence rate ratio under density sampling