This guide compares diff-diff with popular R packages for DiD analysis, helping users familiar with R transition to Python.
| Feature | diff-diff (Python) | did (R) | Other R |
|---|---|---|---|
| Basic DiD | ✅ DifferenceInDifferences |
✅ att_gt |
✅ fixest::feols |
| Staggered DiD | ✅ CallawaySantAnna |
✅ att_gt |
did2s, DRDID |
| Covariate adjustment | ✅ DR, IPW, Reg | ✅ DR, IPW, Reg | ✅ Varies |
| Honest DiD | ✅ HonestDiD |
HonestDiD package |
N/A |
| Synthetic DiD | ✅ SyntheticDiD |
synthdid package |
N/A |
| Wild bootstrap | ✅ wild_bootstrap_se |
fwildclusterboot |
N/A |
The R did package by Callaway and Sant'Anna is the gold standard for
staggered DiD. Here's how to translate common operations:
Basic estimation:
# R (did package) library(did) out <- att_gt( yname = "Y", tname = "period", idname = "id", gname = "G", data = data )
# Python (diff-diff) from diff_diff import CallawaySantAnna cs = CallawaySantAnna() results = cs.fit( data, outcome='Y', time='period', unit='id', first_treat='G' )
With covariates (doubly robust):
# R out <- att_gt( yname = "Y", tname = "period", idname = "id", gname = "G", xformla = ~ X1 + X2, est_method = "dr", data = data )
# Python cs = CallawaySantAnna(estimation_method='dr') results = cs.fit( data, outcome='Y', time='period', unit='id', first_treat='G', covariates=['X1', 'X2'] )
Aggregations:
# R agg_simple <- aggte(out, type = "simple") agg_dynamic <- aggte(out, type = "dynamic") agg_group <- aggte(out, type = "group")
# Python (unlike R's aggte(), aggregation is requested at fit time) results = cs.fit(data, outcome='Y', time='period', unit='id', first_treat='G', aggregate='all') overall_att = results.overall_att # Simple aggregation event_study = results.event_study_effects # Dynamic by_group = results.group_effects # By cohort
The HonestDiD package implements Rambachan & Roth (2023) sensitivity analysis:
Relative magnitudes (ΔRM):
# R library(HonestDiD) delta_rm_results <- createSensitivityResults_relativeMagnitudes( betahat = beta_hat, sigma = sigma, numPrePeriods = 4, numPostPeriods = 3, Mbarvec = seq(0, 2, by = 0.5) )
# Python from diff_diff import HonestDiD honest = HonestDiD(method='relative_magnitude', M=1.0) results = honest.fit(event_study_results) # Sensitivity analysis over M grid sensitivity = honest.sensitivity_analysis( event_study_results, M_grid=[0, 0.5, 1.0, 1.5, 2.0] )
Smoothness restrictions (ΔSD):
# R delta_sd_results <- createSensitivityResults( betahat = beta_hat, sigma = sigma, numPrePeriods = 4, numPostPeriods = 3, Mvec = seq(0, 0.1, by = 0.02) )
# Python from diff_diff import HonestDiD honest = HonestDiD(method='smoothness', M=0.05) results = honest.fit(event_study_results)
The synthdid package implements Arkhangelsky et al. (2021):
# R library(synthdid) setup <- panel.matrices(data, unit = "unit", time = "time", outcome = "Y", treatment = "treatment") tau.hat <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
# Python from diff_diff import SyntheticDiD # SyntheticDiD requires a time-invariant ever-treated indicator data['ever_treated'] = data.groupby('unit')['treatment'].transform('max') # Derive post-treatment periods from treatment timing post_periods = sorted(data.loc[data['treatment'] == 1, 'time'].unique()) sdid = SyntheticDiD() results = sdid.fit( data, outcome='Y', unit='unit', time='time', treatment='ever_treated', post_periods=post_periods )
When every unit is treated at the post period (universal-rollout policies,
industry-wide regime changes) but treatment intensity varies across units,
the standard R workhorses (did, fixest, synthdid,
DIDmultiplegtDYN) assume an untreated comparison group exists and do
not apply. The dedicated R package DIDHAD (de Chaisemartin et al.,
August 2025) covers the QUG case (Design 1', d_lower = 0) from the
same arXiv paper.
diff-diff ships :class:`~diff_diff.HeterogeneousAdoptionDiD`, which
implements de Chaisemartin, Ciccia, D'Haultfoeuille and Knau (2026,
arXiv:2405.04465v6) and adds two surfaces beyond the QUG-focused R
package: Design 1 (no QUG, d_lower > 0, targets WAS_{d_lower} under
Assumption 6 or sign-only under Assumption 5), and survey-design
integration via Binder (1983) Taylor-series linearization (sampling weights
+ optional strata / PSU / FPC). The diagnostic battery
:func:`~diff_diff.did_had_pretest_workflow` surfaces violations of the HAD
identification assumptions (the design path is auto-detected separately by
:meth:`HeterogeneousAdoptionDiD.fit` from the dose support).
import numpy as np import pandas as pd from diff_diff import HeterogeneousAdoptionDiD # Build a HAD-shape panel: D=0 in pre-periods (t < F), D > 0 only at F+. rng = np.random.default_rng(42) G, F, T = 200, 4, 5 doses = rng.beta(0.5, 1.0, size=G) rows = [] for g in range(G): for t in range(1, T + 1): y = (rng.normal() + (doses[g] + doses[g] ** 2) * (t >= F) + rng.normal(0, 0.5)) d = doses[g] if t >= F else 0.0 rows.append({'unit': g, 'period': t, 'y': y, 'dose': d}) had_data = pd.DataFrame(rows) est = HeterogeneousAdoptionDiD() results = est.fit(had_data, outcome_col='y', unit_col='unit', time_col='period', dose_col='dose', aggregate='event_study')
- diff-diff: sklearn-style API with
fit()method, returning rich result objects - R packages: Function-based, returning lists or S3/S4 objects
- diff-diff: Analytical SEs by default, wild bootstrap available
- R did: Multiplier bootstrap by default
- diff-diff:
absorbparameter for high-dimensional FE (within transformation) - R fixest:
feolswith|notation for absorbed FE
diff-diff results have convenience methods:
results.summary() # Print formatted table results.to_dict() # Dictionary representation results.to_dataframe() # pandas DataFrame
| Feature | diff-diff | R did | R HonestDiD | R synthdid |
|---|---|---|---|---|
| Basic 2x2 DiD | ✅ | ✅ | ❌ | ❌ |
| TWFE | ✅ | ❌ | ❌ | ❌ |
| Staggered DiD (CS) | ✅ | ✅ | ❌ | ❌ |
| Covariate adjustment | ✅ | ✅ | ❌ | ❌ |
| Doubly robust | ✅ | ✅ | ❌ | ❌ |
| Group-time effects | ✅ | ✅ | ❌ | ❌ |
| Event study | ✅ | ✅ | ✅ | ❌ |
| Synthetic DiD | ✅ | ❌ | ❌ | ✅ |
| Honest DiD (ΔRM) | ✅ | ❌ | ✅ | ❌ |
| Honest DiD (ΔSD) | ✅ | ❌ | ✅ | ❌ |
| Wild bootstrap | ✅ | ❌ | ❌ | ❌ |
| Cluster-robust SE | ✅ | ✅ | ❌ | ✅ |
| Placebo tests | ✅ | ❌ | ❌ | ✅ |
| Parallel trends tests | ✅ | ✅ | ❌ | ❌ |
| Bacon decomposition | ✅ | ❌ | ❌ | ❌ |
| Sun-Abraham | ✅ | ❌ | ❌ | ❌ |
| Imputation DiD | ✅ | ❌ | ❌ | ❌ |
| Two-Stage DiD (did2s) | ✅ | ❌ | ❌ | ❌ |
| Stacked DiD | ✅ | ❌ | ❌ | ❌ |
| Continuous DiD | ✅ | ✅ | ❌ | ❌ |
| Triple Difference (DDD) | ✅ | ❌ | ❌ | ❌ |
| TROP | ✅ | ❌ | ❌ | ❌ |
| Efficient DiD | ✅ | ❌ | ❌ | ❌ |
| Heterogeneous adoption (HAD) | ✅ | ❌ | ❌ | ❌ |
Note
R equivalents for estimators not covered by the did, HonestDiD, or
synthdid packages: Sun-Abraham is available via fixest::sunab();
Imputation DiD via the didimputation package; Two-Stage DiD via the
did2s package; Bacon Decomposition via the bacondecomp package;
Stacked DiD requires manual implementation or the stackedev package;
Continuous DiD is available via the did package continuous extension;
Triple Difference requires manual implementation in R.
TROP and Efficient DiD have no direct R equivalents.
HeterogeneousAdoptionDiD (dCDH 2026) overlaps with the dedicated R
package DIDHAD (de Chaisemartin et al., 2025), which covers the
QUG case (Design 1'); diff-diff additionally covers Design 1 (no QUG,
WAS_{d_lower}) and survey-design integration via Binder TSL.
- Column names: diff-diff uses string column names, similar to R packages
- Formula interface: diff-diff supports R-style formulas for basic DiD:
formula='y ~ treated * post' - Results access: Use
.att,.se,.ciinstead of$att,$se - Visualization:
plot_event_study()produces matplotlib figures similar toggdid()output - Missing data: diff-diff requires complete data; use
balance_panel()ordropna()first - Heterogeneous Adoption (HAD): If you need surfaces the R
DIDHADpackage does not cover - Design 1 (no QUG,WAS_{d_lower}) or survey-design integration - reach for :class:`~diff_diff.HeterogeneousAdoptionDiD`. See the Heterogeneous Adoption (HAD) section above for the migration pattern.