MethodAtlas
Lab·tutorial·6 min read
tutorial90 minutes

Lab: Synthetic Difference-in-Differences

Estimate causal effects by combining the strengths of difference-in-differences and synthetic control. Learn to construct the SDID estimator, compare it with standard DiD and synthetic control, and conduct placebo-based inference.

LanguagesPython, R, Stata
DatasetSimulated state policy panel data

Overview

In this lab you will implement the synthetic difference-in-differences (SDID) estimator of Arkhangelsky et al. (2021). SDID combines two powerful ideas: it reweights untreated units to match the treated unit's pre-treatment trajectory (like synthetic control) and it differences out time-invariant confounders (like DiD). This hybrid approach can improve on both methods individually when the data satisfy a factor model structure (Arkhangelsky et al., 2021).

What you will learn:

  • How standard DiD and synthetic control each handle different threats to identification
  • How SDID combines unit weights and time weights to improve estimation
  • How to implement SDID step by step and with packages
  • How to compare SDID, DiD, and SC estimates on the same data
  • How to conduct placebo inference for SDID

Prerequisites: Familiarity with difference-in-differences and basic panel data methods. Completion of the DiD tutorial lab is recommended.


Step 1: Simulate State Policy Panel Data

We create a balanced panel with one treated state adopting a policy in period 16 of 25 periods.

library(synthdid)
library(fixest)

set.seed(42)
N <- 30; T_len <- 25; T_pre <- 15  # 30 states, 25 periods, treatment at period 16

# State fixed effects (permanent cross-section heterogeneity)
alpha_i <- rnorm(N, 10, 3)
# Common time trend (shared macro shocks that accumulate over time)
delta_t <- cumsum(rnorm(T_len, 0.2, 0.1))
# State-specific linear trends (violate parallel trends assumption)
trends <- rnorm(N, 0, 0.05)

# Build balanced panel: every state observed in every period
df <- expand.grid(state = 1:N, time = 1:T_len)
df <- df[order(df$state, df$time), ]
# Outcome = state FE + time FE + differential trend + noise
df$Y <- alpha_i[df$state] + delta_t[df$time] + trends[df$state] * df$time + rnorm(nrow(df), 0, 0.5)
df$treated_unit <- as.integer(df$state == 1)   # only state 1 is treated
df$post <- as.integer(df$time > T_pre)          # post-treatment indicator
df$D <- df$treated_unit * df$post               # treatment indicator (treated x post)

# Add constant treatment effect of 3.0 to treated observations
df$Y[df$D == 1] <- df$Y[df$D == 1] + 3.0

cat("Panel:", N, "states x", T_len, "periods\n")
cat("True effect: 3.0\n")

Expected output:

statetimeYtreated_unitpostD
1110.8100
1211.3100
1311.9100
1412.4100
1512.6100
Panel: 30 states x 25 periods = 750 observations
Treated: state 1 from period 16
True treatment effect: 3.0

Step 2: Standard Difference-in-Differences

# Two-way fixed effects DiD: absorb state and time FEs
did <- feols(Y ~ D | state + time, data = df)
cat("DiD estimate:", coef(did)["D"], "\n")
summary(did)
Requiresdid

Expected output:

=== Standard DiD (TWFE) ===
Estimated effect: ~3.05
SE:               ~0.15
True effect:      3.000

Simple 2x2 DiD:  ~3.10

Standard DiD may exhibit some bias because the DGP includes state-specific trends (trends ~ N(0, 0.05)), which mildly violate the parallel trends assumption. The estimate will be close to 3.0 but may deviate slightly depending on the realized trends.


Step 3: Synthetic Control

# Reshape panel data to matrix format for synthdid
# panel.matrices converts long-format panel into the Y, N0, T0 structure synthdid expects
setup <- panel.matrices(df, unit = "state", time = "time", outcome = "Y", treatment = "D")

# SC estimate
sc_est <- sc_estimate(setup$Y, setup$N0, setup$T0)
cat("SC estimate:", sc_est, "\n")
Requiressynthdid

Expected output:

=== Synthetic Control ===
Estimated effect:     ~3.10
Pre-treatment RMSE:   ~0.05

The synthetic control matches the treated unit's pre-treatment trajectory by placing positive weights on a sparse subset of control states. The small pre-treatment RMSE indicates a close fit.

Concept Check

Standard DiD uses equal weights for all control units, while synthetic control uses optimized weights. When would you expect SC to substantially outperform DiD?


Step 4: Synthetic Difference-in-Differences

SDID combines unit weights (from SC) with time weights and applies a DiD-style differencing.

# SDID using the synthdid package
sdid_est <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
cat("SDID estimate:", sdid_est, "\n")

# Compare all three
did_est <- did_estimate(setup$Y, setup$N0, setup$T0)
sc_est2 <- sc_estimate(setup$Y, setup$N0, setup$T0)

cat("\n=== Comparison ===\n")
cat("DiD:", did_est, "\n")
cat("SC:", sc_est2, "\n")
cat("SDID:", sdid_est, "\n")
cat("True: 3.0\n")

# Plot
plot(sdid_est, main = "Synthetic DiD")
Requiressynthdiddid

Expected output:

MethodEstimateTrue Effect
DiD (TWFE)~3.053.0
Synthetic Control~3.103.0
Synthetic DiD~3.023.0
=== Comparison ===
DiD:  ~3.05
SC:   ~3.10
SDID: ~3.02
True: 3.000

In settings where both unit heterogeneity and divergent pre-trends are present, SDID often produces estimates closer to the true effect than either DiD or SC alone, because it combines reweighting with differencing (Arkhangelsky et al., 2021). The SDID unit weights are sparse (a few donor states receive large weights), and the time weights concentrate on the most informative pre-treatment periods.


Step 5: Placebo Inference

# Placebo inference via synthdid
se_sdid <- sqrt(vcov(sdid_est, method = "placebo"))
cat("SDID estimate:", sdid_est, "\n")
cat("Placebo SE:", se_sdid, "\n")
cat("t-stat:", c(sdid_est) / se_sdid, "\n")

# The placebo method reassigns treatment to each control unit
# and computes the distribution of placebo effects
Requiressynthdid

Expected output:

Placebo p-value: ~0.03
Concept Check

SDID uses both unit weights and time weights. What role do the time weights play?


Exercises

  1. Multiple treated units. Modify the DGP so that 5 states are treated. Compare DiD, SC, and SDID. Which handles multiple treated units best?

  2. Staggered adoption. Have different states adopt the policy in different periods. How does SDID perform compared to standard staggered DiD estimators?

  3. Vary pre-treatment periods. Reduce T_pre from 15 to 5. How does this affect the quality of unit weights and the SDID estimate?

  4. Covariates. Add a time-varying covariate to the DGP and include it in the SDID estimation. Does this improve precision?


Summary

In this lab you learned:

  • Standard DiD relies on parallel trends; synthetic control matches pre-treatment levels; SDID combines both approaches
  • SDID uses unit weights (to select comparable controls) and time weights (to select informative pre-periods)
  • In simulations, SDID is often at least as good as whichever of DiD or SC performs better
  • Inference for SDID uses placebo-based or jackknife standard errors, not conventional OLS standard errors
  • SDID is particularly useful when you have a single (or few) treated units and doubt that parallel trends hold exactly
  • The synthdid package in R provides a clean implementation; Python and Stata implementations are also available