Covariates in Difference-in-Differences

The LaLonde test in Python — when do covariates rescue a DiD?

3,621naive DiD · 2× too large
1,711–1,993trend + propensity estimators
1,794experimental benchmark (ATT)

Carlos Mendez

Nagoya University (GSID)

August 4, 2026

The Puzzle

Act I

We know the answer. Can an observational estimator find it?

A randomized trial says job training raised earnings by $1,794.

Now throw the control group away. Use a survey instead.

Do you still find $1,794?

185 trainees, 15,992 survey controls, one known answer

Following Scott Cunningham’s Mixtape Substack, we reproduce the test in Python.

  • Data: Dehejia-Wahba subsample — 185 NSW trainees + 15,992 CPS controls.
  • Panel: two periods, pre = 1975, post = 1978. Outcome = real earnings.
  • Estimand: the ATT (effect on the treated). Design: observational.
  • Ground truth: the experimental ATT \(\approx\) $1,794.

The treated group never changes. So the ATT target stays $1,794, whatever control group we use.

The Investigation

Act II — diagnose the bias, then climb the spec ladder

Why the naive DiD fails: covariate imbalance

Standardized mean differences: trainees versus each control group

Standardized mean differences: trainees versus each control group

CPS controls differ from trainees by +2.3 SD on race and −1.6 SD on prior earnings. The randomized controls do not.

Four regressions, one number

The \(2 \times 2\) DiD ATT is computed identically by:

  1. Saturated dummies (treatment \(\times\) post interaction)
  2. Two-way fixed effects + post \(\times\) treatment
  3. First-difference regression on treatment
  4. Across-group regression on post

We use the saturated form. Only it lets us move covariates around and watch the estimate.

Same number, four ways — so the specification choice is about covariates, not about the DiD itself.

Three ways to add a covariate

A covariate \(X\) can enter a DiD in three fundamentally different places:

Placement Formula What it changes
Level \(+ X\) the intercept (additive control)
Effect \(X \times D\) the treatment effect (heterogeneity)
Trend \(X \times \text{post}\) the counterfactual trend

Only the last placement addresses why the naive estimate is wrong.

The whole thesis is one token in the formula

Spec A — inert ($3,621)

XF = " + ".join(XVARS)

pf.feols(
  f"re ~ post * ever_treated + {XF}",
  data=panel, vcov="HC1")

Spec B — corrected ($1,711)

post_ints = " + ".join(f"post:{x}" for x in XVARS)

pf.feols(
  f"re ~ post * ever_treated + {XF} + {post_ints}",
  data=panel, vcov="HC1")

Same covariates, same data, same estimator. One extra term — and the answer halves.

The estimate stays inert…

  • Spec 0 — No covariates → $3,621
  • Spec A — Additive \(X\) (level) → $3,621 · inert
  • Spec BT — \(X \times\) treatment (effect) → $3,621 · inert

Time-invariant covariates in the level or the effect never touch the control’s trend. Nothing moves.

…until covariates touch the trend

  • Spec B — \(X \times \text{post}\) (trend) → $1,711
  • Spec C — saturated first differences = HIT (1997) → $1,770

The instant covariates bend the counterfactual trend, the estimate snaps to the benchmark.

Propensity weighting reaches the benchmark by a different route

  • IPW (Abadie 2005) → $1,861
  • DR (Sant’Anna-Zhao 2020) → $1,993

These do not model the trend. They model treatment — how the probability of being a trainee depends on \(X\) — and reweight the controls.

Outcome regression specifies the trend; propensity weighting specifies selection. Two philosophies, one answer near $1,794.

The estimate is flat at $3,621 until covariates enter the trend

The ATT across the ordered specification sequence

The ATT across the ordered specification sequence

That cliff — between \(X \times\) treatment and \(X \times\) post — is the single most important feature of the analysis.

Independent check: the diff-diff package

By-hand estimators versus the diff-diff package

By-hand estimators versus the diff-diff package

The transparent code and the battle-tested package tell the same story — within $14.

Where \(X\) enters sorts every estimator into inert or corrected

flowchart LR
    Q{"Where does X enter?"}
    Q -->|level / effect| I["inert · ~3,621"]
    Q -->|trend / propensity| C["corrected · ~1,794"]
    C -.recovers.-> B["RCT benchmark 1,794"]

Covariates in DiD are not a robustness knob.

They have a job: make parallel trends hold, once you condition on X.

The Payoff

Act III

Covariates rescue LaLonde only when they enter the trend

All eight estimators with 95% intervals against the RCT benchmark

All eight estimators with 95% intervals against the RCT benchmark

Three inert specifications cluster at $3,621; every trend and propensity estimator clusters on the $1,794 line.

How much should we trust $1,770 over $1,711?

Trust the pattern, not the decimal

Every corrected estimate’s 95% CI spans roughly $400 to $3,100; the benchmark itself has SE \(\approx\) $671.

The $59 gap between Spec B and Spec C is noise.

Trust the split, not the ranking: ignore the trend and you are wrong; model it and you are right.

Placement, not inclusion, is what moves a DiD

  1. Placement, not inclusion. Covariates in the level ($3,621) or the effect ($3,621) are inert; in the trend ($1,711 / $1,770) they correct.
  2. The common spec fails. Two-way fixed effects with additive controls is exactly the one that cannot move.
  3. Two philosophies agree. Outcome regression and propensity reweighting both land near the $1,794 truth.
  4. Report uncertainty honestly. Wide CIs mean the pattern is the finding, not a ranking of corrected estimates.

Everything here is reproducible

Packages: pyfixest, diff-diff, causaldata.

Covariates rescue a DiD only from the trend — never from the level or the effect.