Skip to content

9.3 Parallel Trends Assumption

The Core Prerequisite for DID Validity


Learning Objectives

  • Deeply understand the definition and intuitive meaning of the parallel trends assumption
  • Master methods for pre-trend tests
  • Learn to draw event study plots
  • Master multiple testing methods and clustering adjustments of standard errors
  • Learn to handle violations of parallel trends
  • Understand robustness checks and alternative methods

Mathematical Expression: Identification assumption of DID

Meaning: In the counterfactual scenario without policy intervention, the trends in outcome variables for treatment and control groups are parallel.

Key Understanding

  1. We don't need the levels of treatment and control groups to be the same—fixed differences are allowed
  2. What matters is that before the policy, the change trends of both groups must be the same
  3. The parallel trends assumption is about the counterfactual—not directly observable

Illustrative Example:

python
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

time = np.arange(0, 10)
treatment_time = 5

# Scenario 1: Parallel trends satisfied
control_1 = 50 + 2 * time
treatment_1 = 55 + 2 * time
treatment_1[treatment_time:] += 15

axes[0].plot(time, control_1, 'o-', label='Control Group', linewidth=2)
axes[0].plot(time[:treatment_time], treatment_1[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[0].plot(time[treatment_time:], treatment_1[treatment_time:], 's-', linewidth=2)
axes[0].plot(time[treatment_time:], 55 + 2 * time[treatment_time:], '--', color='red', alpha=0.6, label='Counterfactual')
axes[0].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[0].set_title('✓ Parallel Trends Satisfied', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Scenario 2: Pre-policy violation of parallel trends
control_2 = 50 + 2 * time
treatment_2 = 55 + 3 * time  # Different trend
treatment_2[treatment_time:] += 15

axes[1].plot(time, control_2, 'o-', label='Control Group', linewidth=2)
axes[1].plot(time[:treatment_time], treatment_2[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[1].plot(time[treatment_time:], treatment_2[treatment_time:], 's-', linewidth=2)
axes[1].plot(time[treatment_time:], 55 + 3 * time[treatment_time:], '--', color='red', alpha=0.6, label='True Counterfactual')
axes[1].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[1].set_title('✗ Parallel Trends Violated (Different Pre-trends)', fontsize=14, fontweight='bold', color='red')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# Scenario 3: Parallel pre-policy but dynamic post-policy effects
control_3 = 50 + 2 * time
treatment_3 = 55 + 2 * time
treatment_3[treatment_time:] = 55 + 2 * treatment_time + 4 * (time[treatment_time:] - treatment_time)

axes[2].plot(time, control_3, 'o-', label='Control Group', linewidth=2)
axes[2].plot(time[:treatment_time], treatment_3[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[2].plot(time[treatment_time:], treatment_3[treatment_time:], 's-', linewidth=2)
axes[2].plot(time[treatment_time:], 55 + 2 * time[treatment_time:], '--', color='red', alpha=0.6, label='Counterfactual Without Policy')
axes[2].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[2].set_title('⚠️  Post-policy Dynamic Trend Changes (Acceptable)', fontsize=14, fontweight='bold', color='orange')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Scenario Explanations

  1. Scenario 1 (Green ✓): Fully satisfies DID, unbiased estimation
  2. Scenario 2 (Red ✗): Pre-policy trends already differ, DID estimator is biased
  3. Scenario 3 (Orange ⚠️): Parallel pre-policy but post-policy trend changes—an allowable situation

II. Pre-Trend Tests

Method 1: Visual Inspection

Most Intuitive Method: Plotting

python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Generate simulated data
np.random.seed(42)
n_units = 50
n_pre_periods = 8  # Pre-policy periods
n_post_periods = 5  # Post-policy periods
treatment_time = n_pre_periods

data = []
for unit in range(n_units):
    treated = 1 if unit >= n_units // 2 else 0
    unit_effect = 10 * treated + np.random.normal(0, 3)

    for period in range(n_pre_periods + n_post_periods):
        time_effect = 2 * period
        post = 1 if period >= treatment_time else 0

        # Policy effect (only post-policy)
        treatment_effect = 20 * treated * post

        y = 50 + unit_effect + time_effect + treatment_effect + np.random.normal(0, 4)

        data.append({
            'unit': unit,
            'period': period,
            'treated': treated,
            'post': post,
            'y': y
        })

df = pd.DataFrame(data)

# Calculate group means
means_by_group = df.groupby(['treated', 'period'])['y'].mean().unstack()

# Plot trends
fig, ax = plt.subplots(figsize=(14, 8))

ax.plot(means_by_group.columns, means_by_group.loc[0], 'o-',
        label='Control Group', linewidth=3, markersize=8, color='blue')
ax.plot(means_by_group.columns, means_by_group.loc[1], 's-',
        label='Treatment Group', linewidth=3, markersize=8, color='red')

# Policy implementation time
ax.axvline(treatment_time - 0.5, color='green', linestyle='--',
           linewidth=2, alpha=0.7, label='Policy Implementation Time')

# Add background regions
ax.axvspan(-0.5, treatment_time - 0.5, alpha=0.1, color='gray', label='Pre-Policy')
ax.axvspan(treatment_time - 0.5, n_pre_periods + n_post_periods - 0.5,
           alpha=0.1, color='yellow', label='Post-Policy')

ax.set_xlabel('Period', fontsize=14, fontweight='bold')
ax.set_ylabel('Average Outcome', fontsize=14, fontweight='bold')
ax.set_title('Parallel Trends Visual Inspection: Treatment vs Control', fontsize=16, fontweight='bold')
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print("=" * 70)
print("Visual Inspection Results")
print("=" * 70)
print("✓ Observation: Trends of both groups are roughly parallel pre-policy")
print("✓ Observation: No obvious divergent trends pre-policy")
print("⚠️  But we need statistical tests to confirm")

Method 2: Pre-Trend Test Regression

Approach: Test whether treatment and control groups have different time trends pre-policy

Regression Equation (using only pre-policy data):

Null Hypothesis: (treatment and control groups have the same time trend)

python
import statsmodels.formula.api as smf

# Use only pre-policy data
df_pre = df[df['period'] < treatment_time].copy()

# Regression: test interaction term
model_pre_trend = smf.ols('y ~ treated + period + treated:period', data=df_pre).fit()

print("=" * 70)
print("Pre-Trend Test Regression")
print("=" * 70)
print(model_pre_trend.summary())
print("\n")

# Extract key coefficient
interaction_coef = model_pre_trend.params['treated:period']
interaction_pval = model_pre_trend.pvalues['treated:period']

print("=" * 70)
print("Test Results")
print("=" * 70)
print(f"Interaction Coefficient: {interaction_coef:.4f}")
print(f"p-value: {interaction_pval:.4f}")

if interaction_pval > 0.05:
    print("✓ Conclusion: Cannot reject parallel trends assumption (p > 0.05)")
else:
    print("✗ Conclusion: Reject parallel trends assumption (p < 0.05), different trends exist")

Considerations

  1. Roth (2022) Warning: "Pretest with Caution"
    • Pre-trend tests have limited statistical power, failing to reject null doesn't mean assumption holds
    • Significance tests are easily affected by sample size
  2. Recommendation: Combine visualization with multiple tests

III. Event Study Plots

What is Event Study

Definition: Estimate dynamic treatment effects for each period before and after the policy

Model Equation

where:

  • indicates individual at time is periods away from policy implementation
  • serves as the baseline period (normalization)
  • is the treatment effect for relative period

Interpretation

  • : Pre-policy, should be close to 0 (parallel trends test)
  • : Post-policy, represents dynamic effects of policy

Complete Event Study Python Implementation

python
from linearmodels.panel import PanelOLS

# Construct relative time variable
df['rel_time'] = df['period'] - treatment_time

# Construct relative time only for treatment group
df['rel_time_treated'] = df['rel_time'] * df['treated']

# Generate leads and lags dummies (t=-1 as baseline)
for k in range(-n_pre_periods, n_post_periods):
    if k != -1:  # Baseline group
        df[f'lead_lag_{k}'] = ((df['rel_time_treated'] == k)).astype(int)

# Convert to panel format
df_panel = df.set_index(['unit', 'period'])

# Construct regression formula
lead_lag_vars = [f'lead_lag_{k}' for k in range(-n_pre_periods, n_post_periods) if k != -1]

# Event study regression
model_es = PanelOLS(
    dependent=df_panel['y'],
    exog=df_panel[lead_lag_vars],
    entity_effects=True,
    time_effects=True
).fit(cov_type='clustered', cluster_entity=True)

print("=" * 70)
print("Event Study Regression Results")
print("=" * 70)
print(model_es.summary)
print("\n")

# Extract coefficients and construct plotting data
event_study_results = []
for k in range(-n_pre_periods, n_post_periods):
    if k == -1:
        # Baseline period
        event_study_results.append({
            'rel_time': k,
            'coef': 0,
            'ci_lower': 0,
            'ci_upper': 0
        })
    else:
        var_name = f'lead_lag_{k}'
        coef = model_es.params[var_name]
        ci = model_es.conf_int().loc[var_name]
        event_study_results.append({
            'rel_time': k,
            'coef': coef,
            'ci_lower': ci[0],
            'ci_upper': ci[1]
        })

es_df = pd.DataFrame(event_study_results)

# Plot event study
fig, ax = plt.subplots(figsize=(16, 8))

ax.plot(es_df['rel_time'], es_df['coef'], 'o-',
        linewidth=3, markersize=10, color='navy', label='DID Coefficient', zorder=3)
ax.fill_between(es_df['rel_time'], es_df['ci_lower'], es_df['ci_upper'],
                alpha=0.3, color='navy', label='95% Confidence Interval')

# Zero line
ax.axhline(y=0, color='black', linestyle='--', linewidth=1.5, alpha=0.7)

# Policy implementation time
ax.axvline(x=-0.5, color='red', linestyle='--', linewidth=2.5, alpha=0.8, label='Policy Implementation Time')

# Background regions
ax.axvspan(-n_pre_periods, -0.5, alpha=0.08, color='gray')
ax.axvspan(-0.5, n_post_periods, alpha=0.08, color='yellow')

ax.text(-n_pre_periods/2, ax.get_ylim()[1] * 0.9, 'Pre-Policy',
        fontsize=14, ha='center', bbox=dict(boxstyle='round', facecolor='gray', alpha=0.3))
ax.text(n_post_periods/2, ax.get_ylim()[1] * 0.9, 'Post-Policy',
        fontsize=14, ha='center', bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.3))

ax.set_xlabel('Time Relative to Policy Implementation', fontsize=14, fontweight='bold')
ax.set_ylabel('Estimated Coefficient', fontsize=14, fontweight='bold')
ax.set_title('Event Study: Dynamic Treatment Effects', fontsize=16, fontweight='bold')
ax.legend(fontsize=12, loc='upper left')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Test pre-trend coefficients
print("=" * 70)
print("Pre-Trend Test (Examine Pre-Policy Coefficients)")
print("=" * 70)
pre_treatment_coefs = es_df[es_df['rel_time'] < 0]
print(pre_treatment_coefs)
print("\n")

# Joint test: Are all pre-trend coefficients zero?
from scipy import stats

pre_coefs = [model_es.params[f'lead_lag_{k}'] for k in range(-n_pre_periods, -1)]
pre_vcov = model_es.cov[lead_lag_vars].loc[
    [f'lead_lag_{k}' for k in range(-n_pre_periods, -1)],
    [f'lead_lag_{k}' for k in range(-n_pre_periods, -1)]
]

# F-test (joint test)
# F = (R * beta)' * (R * V * R')^{-1} * (R * beta) / q
# Simplified using Wald test here

print("Note: If all pre-trend coefficients are significantly non-zero, parallel trends assumption is violated")
print("Recommendation: Examine the plot and confidence intervals of pre-trend coefficients to judge violations")

Interpreting Event Study Plots

Key Interpretation Points

  1. Pre-Policy ()

    • ✓ All coefficients should be close to 0 and insignificant
    • ✗ If coefficients are significantly different from 0, parallel trends are violated
  2. Policy Implementation Period ()

    • Does the policy effect appear immediately?
    • What is the magnitude of the effect?
  3. Post-Policy ()

    • Does the effect persist?
    • Does the effect strengthen/weaken/remain stable over time?
  4. Confidence Intervals

    • Check if confidence intervals for all post-policy periods exclude 0
    • Widening confidence intervals indicate insufficient sample size or increased heterogeneity

Solution 1: Select More Similar Control Groups

Approach: Restrict control group to units most similar to treatment group

python
# Select similar control groups based on pre-policy average outcomes
df_pre_avg = df[df['period'] < treatment_time].groupby('unit')['y'].mean().reset_index()
df_pre_avg.columns = ['unit', 'pre_avg']

df = df.merge(df_pre_avg, on='unit')

# Select control groups similar to treatment group (e.g., based on pre_avg distribution)
# Simplified here to select control groups within quantile ranges
control_units = df[(df['treated'] == 0) &
                   (df['pre_avg'] > df[df['treated'] == 1]['pre_avg'].quantile(0.25)) &
                   (df['pre_avg'] < df[df['treated'] == 1]['pre_avg'].quantile(0.75))]['unit'].unique()

df_matched = df[(df['treated'] == 1) | (df['unit'].isin(control_units))]

print(f"Original control group size: {(df['treated'] == 0).sum() // (n_pre_periods + n_post_periods)}")
print(f"Matched control group size: {len(control_units)}")

Solution 2: Control for Linear Trend Interactions

Model Equation

Allows treatment group to have a different linear trend

python
# Generate linear trend interaction
df['treated_time'] = df['treated'] * df['period']

model_trend = PanelOLS(
    df.set_index(['unit', 'period'])['y'],
    df.set_index(['unit', 'period'])[['did', 'treated_time']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='clustered', cluster_entity=True)

print("=" * 70)
print("DID Controlling for Linear Trends")
print("=" * 70)
print(model_trend.summary)

Warning

  • May suffer from over-control problems
  • If trend differences are long-term effects of the policy, controlling for trends will underestimate true policy effects

Solution 3: Use Synthetic Control Method

Approach: Use a weighted combination of multiple control group units to construct a "synthetic control group" that maximally matches the treatment group pre-policy

Pseudocode Example

python
# Requires installing relevant packages, e.g., pySynth or synthdid

print("=" * 70)
print("Using Synthetic Control Method")
print("=" * 70)
print("Alternative approach when parallel trends assumption doesn't hold:")
print("")
print("Basic Idea:")
print("1. Use weighted combinations of control group units to construct a 'synthetic control'")
print("2. Weights chosen to maximize matching of treatment group pre-policy")
print("3. Post-policy: Compare real treatment group to synthetic control to estimate policy effect")
print("")
print("Applicable Scenarios:")
print("- Treatment group has only 1 (or very few) units")
print("- Difficult to find fully similar control groups")
print("- Parallel trends assumption is weak")
print("")
print("Python Packages:")
print("- pip install pySynth")
print("- Or use R's Synth package")

Wrapper Code

python
def did_parallel_trends_analysis(df, treatment_time, outcome, unit_var, time_var, treated_var):
    """
    Complete parallel trends analysis

    Parameters:
    - df: dataframe
    - treatment_time: policy implementation time
    - outcome: outcome variable name
    - unit_var: unit variable name
    - time_var: time variable name
    - treated_var: treatment group variable name
    """
    import matplotlib.pyplot as plt
    import seaborn as sns
    from linearmodels.panel import PanelOLS

    print("=" * 70)
    print("Complete DID Parallel Trends Analysis")
    print("=" * 70)

    # 1. Visual inspection
    print("\n[1/4] Plotting trends...")
    means = df.groupby([treated_var, time_var])[outcome].mean().unstack()

    fig, ax = plt.subplots(figsize=(14, 8))
    ax.plot(means.columns, means.loc[0], 'o-', label='Control Group', linewidth=2.5, markersize=8)
    ax.plot(means.columns, means.loc[1], 's-', label='Treatment Group', linewidth=2.5, markersize=8)
    ax.axvline(treatment_time - 0.5, color='red', linestyle='--', linewidth=2, label='Policy Implementation Time')
    ax.legend(fontsize=12)
    ax.set_title('Parallel Trends Visual Inspection', fontsize=16, fontweight='bold')
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()

    # 2. Pre-trend test regression
    print("\n[2/4] Running pre-trend test regression...")
    df_pre = df[df[time_var] < treatment_time].copy()
    formula = f'{outcome} ~ {treated_var} + {time_var} + {treated_var}:{time_var}'
    model_pre = smf.ols(formula, data=df_pre).fit()

    interaction_pval = model_pre.pvalues[f'{treated_var}:{time_var}']
    print(f"Interaction term p-value: {interaction_pval:.4f}")
    if interaction_pval > 0.05:
        print("✓ Pre-trend test result: Cannot reject parallel trends assumption")
    else:
        print("✗ Pre-trend test result: Reject parallel trends assumption")

    # 3. Event study
    print("\n[3/4] Running event study plot...")
    df['rel_time'] = df[time_var] - treatment_time
    df['rel_time_treated'] = df['rel_time'] * df[treated_var]

    # Generate leads and lags (specific implementation omitted, refer to complete code above)

    # 4. Provide further recommendations
    print("\n[4/4] Further recommendations:")
    print("  - Check if control group matching is needed")
    print("  - Check if post-policy dynamic effects match expectations")
    print("  - Control for linear trends or other covariates")
    print("  - Consider using synthetic control method")

    print("\n" + "=" * 70)
    print("Analysis Complete")
    print("=" * 70)

# Usage example
# did_parallel_trends_analysis(df, treatment_time=8, outcome='y',
#                                unit_var='unit', time_var='period', treated_var='treated')

VI. Section Summary

Key Takeaways

  1. Parallel Trends Assumption is the most core, irreplaceable assumption for DID identification

  2. Testing Methods

    • Visual inspection: Intuitive observation
    • Pre-trend test regression: Statistical testing
    • Event study plot: Most comprehensive method (recommended)
  3. Event Study Best Practices

    • Parallel trends test: Observe pre-policy coefficients (close to 0 and insignificant)
    • Dynamic effects: Observe post-policy coefficients
    • Provides richer information
  4. Handling Violations of Parallel Trends

    • Select more similar control groups
    • Control for linear trends
    • Use synthetic control method
    • Consider other identification strategies

Best Practices

StepMethod
1. Visual inspectionPlot and observe trends
2. Pre-trend testUse statistical tests (but be cautious per Roth 2022)
3. Event studyUse complete dynamic effects
4. RobustnessCombine multiple validation methods

Next Steps

Proceed to Section 4: Placebo Tests for further validation:

  • Fake policy tests
  • Fake control group tests
  • Recommended best practices and robustness check scenarios

The parallel trends assumption is the soul of DID!


Previous: 9.2 DID Fundamentals | Next: 9.4 Placebo Tests

Released under the MIT License. Content © Author.