csdid news

csdid 2.0.0

A rewritten estimation engine. Everything below is what changes for someone upgrading from csdid Version 1.82 — the SSC release dated 2025-10-05. The command surface is deliberately the same, so most existing do-files run unchanged.

Changes that can affect your results

Not-yet-treated is now the default comparison group. Version 1.82 defaults to never-treated. Version 2.0.0 also uses later-treated cohorts while they remain eligible controls, accounting for the base period and anticipation(). This can use more of the data and does not require a never-treated group. nevertreated restores the old comparison group.

One consequence: the refusal described below, when the never-treated group is too small, no longer fires by default. That is correct — notyet is precisely the remedy that refusal recommends.

Universal base period is now the default. Version 1.82 defaults to a varying base period. Version 2.0.0 uses one reference period per cohort: the last observed period before treatment can affect outcomes, or g-1 on a consecutive calendar without anticipation.

This is the layout an event-study plot assumes, and event studies are how these results are nearly always presented. Post-treatment effects are identical under either choice; only the pre-treatment cells differ, and universal additionally reports the g-1 normalisation row. Use base_period(varying) when pre-testing: each pre-treatment cell is then its own one-period comparison, so a violation shows up in the period where it happens rather than being carried forward into every later cell.

Standard errors are bootstrapped by default, with simultaneous confidence bands. Version 1.82 reported pointwise analytical standard errors unless you asked for wboot.

This is deliberate. A staggered design produces one estimate per cohort and period — often dozens — and pointwise intervals do not account for looking at all of them at once. Reading a 95% pointwise band as though it covered the whole event study understates uncertainty, and it is the most common way these results are over-read. The default is now the multiplier bootstrap with simultaneous bands over 1,000 iterations, so the interval you are shown on each estimated effect is the one that covers them jointly. (An aggregation’s overall summary effect is the exception: being a single number, it is reported with a pointwise interval.)

analytical (or vce(analytical)) restores analytical standard errors; an aggregation’s per-effect rows still carry a simultaneous band — its critical value is bootstrapped, with a note, because there is no other way to compute one — unless pointwise is added. pointwise gives pointwise intervals from either inference. Point estimates are unaffected by any of this.

One effect is deliberately exempt: the overall summary of an aggregation — Post_avg on the event study, Overall on the group and calendar aggregations, ATT on the simple one, and the ATT(Average) row of estat tidy — is reported with a pointwise interval even when the effects it summarizes carry a simultaneous band. A simultaneous band answers whether a whole set of effects lies inside its intervals at once; a single summary number has no set to be simultaneous over. An estat event table can therefore show both kinds of interval at once, and it says so beneath the table.

Unbalanced panels are balanced, and say so. Version 1.82 dropped, without comment, the units not observed in both periods of each comparison — silently changing the estimand. Version 2.0.0 makes the choice explicit and reports it. bal() takes three modes:

   
bal(full) drop units not observed in every period, once, for all comparisons. Default.
bal(pair) balance each 2x2 separately, keeping the units observed in both of its periods. This is what Version 1.82 did silently; ask for it to reproduce a result from that version.
bal(none) keep every unit and use the repeated-cross-section computation.

Whenever a mode discards observations, csdid reports how many units and how many observations went. e(panel_mode) records the resolved layout. unbalanced is a supported synonym of bal(none), for when that reads better than a mode inside bal(); allowunbalanced and allow_unbalanced are the longhand forms of the same setting. All three are typed in full — no abbreviation of them is an option.

Repeated cross sections can be declared, not just inferred. Use the new rcs option to declare this sampling structure explicitly. Previously the only way to say “these are cross sections” was to omit ivar(), which forced anyone whose cross sections carried an identifier to withhold a real variable. With rcs you keep it: it is validated and used to exclude observations where it is missing, but each observation is its own unit. cluster() is what puts that identifier back into the standard errors.

A too-small never-treated group is now refused. csdid stops when the never-treated group is smaller than #covariates + 5, and warns about any small group. Group size is measured as rows divided by periods — the average number of units per period — not as distinct units. The two agree on balanced panels and differ only on unbalanced ones, where the guard now fires in cases earlier versions estimated. If it fires, notyet uses not-yet-treated units as the comparison group and does not depend on the never-treated group being large. This changes whether the command runs, never an estimate.

A panel that is not shaped like a panel is refused, and the message says which variable is at fault. With ivar() supplied, Version 2.0.0 makes three checks before it estimates anything, each its own error: a unit may appear at most once per period, gvar() must be constant within a unit (treatment timing is irreversible), and cluster(), when given, must be constant within a unit too.

The middle one is new. Version 1.82 estimated a panel in which a unit’s cohort changed from one period to the next — a treatment history that has no reading. The other two were already stopped, but by the machinery underneath the command rather than by the command: a duplicated row surfaced as repeated time values within panel and a moving cluster as panels are not nested within clusters, neither of which mentions csdid or the option that caused it. The sample must also contain at least two distinct units; that case used to fail with a conformability error from inside the estimator.

All three are judged on the data as if and in leave it, before any row is set aside for carrying a missing value and before bal(full) balances the panel. A duplicated row is still a duplicated row when the second copy has no outcome, no weight or no covariate, and a fault inside a unit that balancing would drop is still reported instead of disappearing with the unit. The two-distinct-units requirement is counted the other way round — after every reduction and after bal(full) — because it describes the sample that will actually be estimated. Like the refusal above, this changes whether the command runs, never an estimate.

An outcome that never changes is refused. When the outcome takes the same value in every observation of the estimation sample, Version 2.0.0 stops with r(459) and names the variable and the value. Version 1.82 estimated it, and returned a table in which every ATT(g,t) was exactly 0 with a missing standard error — which reads like a precisely estimated null rather than like an empty result. Warnings did fire, but each named the symptom (standard errors could not be computed) and none named this cause.

The test is exact equality of the extremes, and it is applied to the estimation sample: an outcome flattened by an if is refused even when the variable varies elsewhere, and an outcome that varies by one part in a million is degenerate-but-estimable and still runs. This is the third refusal that changes whether the command runs, never an estimate.

Stored results

e() carries the estimation contract; unit-level objects stay internal. The influence functions — one row per unit, one column per ATT(g,t) — drive every standard error, aggregation, and bootstrap, and they live inside the estimation engine. Every feature computes from that internal copy: estat, csdid_stats, test/lincom (with full covariances), csdid_plot, and saverif(). This is the same division of labor official Stata commands use for unit-level quantities, and it is what keeps large estimations fast: copying an n-unit matrix into e() costs quadratic time in the number of units. Two explicit routes expose the influence functions when you want them — storeall materializes e(inffunc), e(unit_group), and e(cluster_vec) as Stata matrices, and saverif() writes the durable dataset that csdid_stats using aggregates in any later session.

Options that now error instead of being accepted quietly

Option 2.0.0
wboot(wtype(mammen\|gaussian\|normal)) Errors. Only the Rademacher multiplier is supported; these used to be coerced to it silently
wboot(reps(#)) with # ≤ 20 Errors. reps() must exceed 20; 1,000 is the default
pscoretrim(#) with # ≤ 0 Errors. Omit it for the default of .995, or pass 1 (or more) for no trimming
gvar() with negative values Errors. gvar() is 0 for never-treated units and 1 or more for treated cohorts
time() below 1 Errors. Add the same constant to time and treated cohort codes; leave never-treated codes at zero
from() No longer supported. Use window(# #) on estat event for event-time windows
dryrun Rejected; it was never a documented option

New

Performance

Version 2.0.0 is a rewritten engine, and speed at scale was a design goal alongside accurate estimation and inference.

Bootstrap acceleration after clear all. Seeded estimation and aggregation continue to use available acceleration without restarting Stata, avoiding an unintended slower fallback after clearing the session.

Against Version 1.82, on identical data with 2.0.0 pinned to that version’s own defaults so both versions compute the same numbers: gains range from 10x to 308x, depending on the design — this range comes from designs whose size is varied on purpose (periods, cohorts, rows), which is a different measurement from the fixed-size workload table in the README. The gain grows with the number of periods and the number of cohorts, because those are what drive the number of ATT(g,t) cells: 27x at five periods and 308x at forty, 106x at three cohorts and 194x at six. It is smallest on repeated cross sections, which was Version 1.82’s fastest path, at 10x. At one million rows Version 1.82 could not be timed at all inside a two-minute per-call ceiling, where 2.0.0 takes 1.53 seconds.

These scaling comparisons were measured on 21 August 2026 with StataNow/MP 19.5 on a 10-core Apple M1 Max. The Version 1.82 comparison documents their settings. In the same measurement campaign, the within-package comparison records:

Separate measurements on 7 August 2026, also using StataNow/MP 19.5 on Apple Silicon, cover the following workflows. Their recorded timings identify the measured design and whether a warmup was discarded.

The design rule behind these numbers: no object with one row per unit ever crosses into Stata’s classic-matrix layer, whose cost is quadratic in a matrix’s longest dimension. Unit-level results live in the engine, in variables, or in files — never in e() matrices, unless you ask with storeall.

Legacy commands

csgvar (and its helper _gcsgvar) is carried forward and supported: it builds the gvar() cohort variable from a treatment indicator.

csdid_rif, csdid_table, dipt and tsvmat still ship so existing do-files keep running, but are deprecated and will be removed in a future release. Each prints a notice when called. They are not covered by the numerical test suite. help csdid_legacy documents what to use instead — in short, estat attgt, saving() for a results dataset, with the saved-RIF path still supported through csdid_stats using.

Compatibility

These are accepted and map to the documented spelling. Most warn; the ones marked as supported below do not, because they are current names rather than deprecations. New code should use the names in help csdid.

Accepted Canonical
id() ivar()
vce(cluster var) cluster(var)
notyettreated notyet, the default comparison group
storeall, store_all storeall
balance() bal(), the same option unabbreviated
unbalanced bal(none). Supported and silent, not deprecated: the documented synonym, for when it reads better than a mode inside bal(). Typed in full; unbal is not an option, since it would read as the refused bal(unbal). Combining it with a conflicting bal() is an error
allowunbalanced, allow_unbalanced bal(none), same as unbalanced. Supported, silent, not deprecated, and typed in full
baseperiod(), bare universal / varying base_period()
method(dripw), method(stdipw) method(dr), method(ipw)
wboot reps(#) seed(#) wboot(reps(#) rseed(#))
asinr no-op; use notyet
long, long2 deprecated; imply baseperiod(universal) when baseperiod() is omitted
agg(event), csdid_stats event dynamic aggregation

Spellings that are not options

These have never been options in any release, so there is nothing to be compatible with: they are absent from Version 1.82 and 2.0.0 is the first release of this rewrite. Each is refused as an unknown option (return code 198).

Not an option Use instead
bal(unbal), bal(unbalanced), bal(allow_unbalanced) bal(none), or the unbalanced spelling of it (longhand allowunbalanced)
balanceall, bal(all) bal(full)
balancepair bal(pair)
lean, performance() in every form nothing to type: storage is internal at every sample size, and storeall is the one switch that changes it

unbalanced and allow_unbalanced are not in this table: as options they are supported (see above). What is refused is unbalanced or allow_unbalanced as a value inside bal(). e(allow_unbalanced) and the allow_unbalanced value of e(panel_mode) are stored-result names and are unaffected.

Upgrading

The migration guide covers the migration in full, including how to compare Version 1.82 and 2.0.0 output on your own data.