Panel Surveys in Python with svy

One long Sample, a case id, an ordered wave column, and the variance of change comes out right

Tutorials
Panel Surveys
Weighting
Estimation
Python
Analyze longitudinal (panel) survey data in Python with svy: stack the waves into one long Sample, estimate levels and net change with the right variance, compute transitions and individual change with lag(), and build longitudinal weights with a nonresponse adjustment per wave.
Author

Mamadou S. Diallo, Ph.D.

Published

September 6, 2026

Modified

September 6, 2026

Keywords

panel survey Python, longitudinal survey analysis Python, panel data survey weights Python, attrition weighting Python, net change variance panel survey, transition probabilities survey Python, percent change delta method survey, longitudinal weights nonresponse adjustment, svy combine_samples panel, repeated measures survey design Python

What a panel is in svy

A panel survey follows the same cases (persons, households, firms) over several waves. In svy a panel is not a new object: it is an ordinary Sample in long format, one row per case and wave, whose Design names two extra columns:

  • case_id — the column identifying the followed case. It must be non-null and unique within each wave.
  • wave — the column ordering a case’s rows. Any increasing codes work (1, 2, 3 or 2019, 2021, 2023).

Everything else is the estimation and weighting API you already know. Two things happen automatically once both columns are declared:

  1. The case is the variance PSU when no PSU is declared. A case’s rows are repeated measures of one sampling unit, so they must form one cluster. That is what makes mean("y", by="wave") and its contrasts carry the covariance between waves, so the standard error of a change is the standard error of the individual changes — not the naive “sum of two independent variances”.
  2. Pairing is validated. Duplicated ids within a wave, design columns that vary within a case (a mover must keep the base-wave stratum and PSU), and two waves with no case in common are all refused with a message naming the fix.

What svy does not guess is the estimand: which waves and which weight. You say it with the same where= and use_weight() you use elsewhere.

The bundled panel

svy ships a small synthetic three-wave panel, panel_syn_2026: 1200 cases at wave 1 in 6 strata × 10 PSUs, about 15% attrition per wave, producer longitudinal weights, a binary outcome (emp) and a continuous one (inc). See Datasets for how bundled data works.

import polars as pl
import svy

from svy import col, estd, Cat

data = svy.datasets.load("panel_syn_2026", source="bundled")
info = svy.datasets.describe("panel_syn_2026", source="bundled")
print(info.design)
data.head(6)
{'case_id': 'case_id', 'wave': 'wave', 'stratum': 'stratum', 'psu': 'psu', 'wgt': 'w1'}
shape: (6, 13)
case_id wave stratum psu urban age_grp sex w1 resp lw_12 lw_123 emp inc
i64 i64 i64 i64 i64 i64 i64 f64 str f64 f64 i64 f64
1 1 1 101 1 2 2 50.0 "rr" 61.320755 65.0 0 2312.23
1 2 1 101 1 2 2 50.0 "rr" 61.320755 65.0 0 1705.9
1 3 1 101 1 2 2 50.0 "rr" 61.320755 65.0 0 2223.37
2 1 1 101 1 3 1 50.0 "rr" 61.363636 69.230769 0 2617.62
2 2 1 101 1 3 1 50.0 "rr" 61.363636 69.230769 0 3125.24
2 3 1 101 1 3 1 50.0 "rr" 61.363636 69.230769 1 2369.75

The registry’s design already names the case, the wave, the base-wave strata and PSUs, and the base weight w1:

panel = svy.Sample(data, svy.Design(**info.design))
print(panel.design)
╭────────── Design ──────────╮
│ Case id            case_id │
│ Wave               wave    │
│ Stratum            stratum │
│ PSU                psu     │
│ SSU                None    │
│ Weight             w1      │
│ With replacement   False   │
│ Prob               None    │
│ Hit                None    │
│ MOS                None    │
│ Population size    None    │
│ Replicate weights  None    │
╰────────────────────────────╯

Every row of a case carries the same w1, stratum and psu: the sampling design is the wave-1 design, and later waves are follow-ups of the same units. The response column resp is "rr" on interviewed rows and "nr" on rows where the case was contacted but not interviewed; cases lost to follow-up simply have no row at later waves.

data.group_by("wave").agg(
    pl.len().alias("rows"),
    (pl.col("resp") == "nr").sum().alias("not_interviewed"),
).sort("wave")
shape: (3, 3)
wave rows not_interviewed
i64 u32 u32
1 1200 0
2 1086 116
3 869 101

Stacking waves with combine_samples

Producers often deliver one file per wave. svy.combine_samples(kind="panel", case_id=...) stacks them into the long form above and runs the pairing checks. Here we split the bundled panel into per-wave samples to show the round trip:

waves = [
    svy.Sample(
        data.filter(pl.col("wave") == t).drop("wave"),
        svy.Design(stratum="stratum", psu="psu", wgt="w1"),
    )
    for t in (1, 2, 3)
]
stacked = svy.combine_samples(waves, kind="panel", case_id="case_id")
print(stacked.design)
╭──────────── Design ─────────────╮
│ Case id            case_id      │
│ Wave               wave         │
│ Stratum            (stratum,)   │
│ PSU                (psu,)       │
│ SSU                None         │
│ Weight             combined_wgt │
│ With replacement   False        │
│ Prob               None         │
│ Hit                None         │
│ MOS                None         │
│ Population size    None         │
│ Replicate weights  None         │
╰─────────────────────────────────╯

Caller order is time order; the wave column gets codes 1..k labelled "wave 1".."wave k" (an existing wave column present in every input is reused instead). kind="panel" differs from the default kind="cross_sectional" in three ways: the strata are not wave-qualified (the same units recur, so waves are not independent), weights are not averaged over waves (a person is not half a person for appearing twice), and the pairing is validated. The checks:

  • case_id is present, non-null and unique in every input;
  • consecutive waves overlap — an empty overlap means two unrelated cross-sections were stacked, and is an error; an overlap under 50% warns;
  • design columns are constant within a case across waves;
  • a later wave’s (stratum, PSU) set is a subset of wave 1’s; a PSU with no row at a later wave is a real panel event and warns.

The overlap between consecutive waves is part of the design summary:

print(stacked)
╭───────────────────────── Sample ──────────────────────────╮
│ Survey Data                                               │
│   Rows     : 3155                                         │
│   Columns  : 14                                           │
│   Strata   : 6                                            │
│   PSUs     : 60                                           │
│                                                           │
│ Survey Design                                             │
│   Case id            case_id                              │
│   Wave               wave                                 │
│   Stratum            (stratum,)                           │
│   PSU                (psu,)                               │
│   SSU                None                                 │
│   Weight             combined_wgt                         │
│   With replacement   False                                │
│   Prob               None                                 │
│   Hit                None                                 │
│   MOS                None                                 │
│   Population size    None                                 │
│   Replicate weights  None                                 │
│   Waves              1 -> 2: 1086 common, 114 lost, 0 new │
│   Waves              2 -> 3: 869 common, 217 lost, 0 new  │
╰───────────────────────────────────────────────────────────╯

Levels and net change

A level per wave is a domain estimate with by="wave":

inc_by_wave = panel.estimation.mean("inc", by="wave", drop_nulls=True)
print(inc_by_wave)
╭──────────────────── Estimate: MEAN (TAYLOR) ─────────────────────╮
│ y: inc                                                           │
│                                                                  │
│  wave          est        se          lci          uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  1      2,904.1620   37.4524   2,829.0746   2,979.2495     1.29  │
│  2      3,015.6023   46.6617   2,922.0513   3,109.1533     1.55  │
│  3      3,166.9406   48.3625   3,069.9796   3,263.9015     1.53  │
│                                                                  │
╰──────────────────────────────────────────────────────────────────╯

drop_nulls=True zero-weights the rows without an interview (inc is null on "nr" rows); it does not drop them, so the design structure is intact. The net change between two waves is a contrast between these estimates, and the covariance between waves is already in the result:

change = inc_by_wave.contrast(
    {
        "wave 2 − wave 1": estd(2) - estd(1),
        "wave 3 − wave 1": estd(3) - estd(1),
        "% change 1 → 3": estd(3) / estd(1) - 1,
    }
)
print(change)
╭───────────────────────────────── Contrast (TAYLOR, df=54) ──────────────────────────────────╮
│                                                                                             │
│  contrast               est        se   cv (%)         t     p_value        lci        uci  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  wave 2 − wave 1   111.4403   20.3456    18.26    5.4774   1.157e-06    70.6497   152.2308  │
│  wave 3 − wave 1   262.7785   23.4756     8.93   11.1937   1.087e-15   215.7128   309.8442  │
│  % change 1 → 3      0.0905    0.0079     8.74   11.4388   4.746e-16     0.0746     0.1063  │
│                                                                                             │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

Differences are linear contrasts (exact). The ratio and the percent change are estimated by the delta method on the same design degrees of freedom, matching R’s svycontrast(..., quote((w3 - w1) / w1)). Products, quotients, constants, .log() and .exp() all work inside an estd() expression.

Why the covariance matters

Stack the same three waves as if they were independent cross-sections and the change looks far less precise, because the between-wave covariance that a panel earns is thrown away:

as_cs = svy.combine_samples(waves, kind="cross_sectional", adjust="none")
naive = as_cs.estimation.mean("inc", by="wave", drop_nulls=True).contrast(estd(2) - estd(1))

print("panel SE  :", round(change.estimates[0].se, 2))
print("naive SE  :", round(naive.estimates[0].se, 2))
panel SE  : 20.35
naive SE  : 59.83

The panel SE is the standard error of the individual changes, which is what a paired analysis on a wide file would give. The cross-sectional SE treats the two means as independent and is wrong for a panel.

Two estimands, two weights

by="wave" with the base weight w1 estimates each wave’s mean over the cases present at that wave. Attrition is not random, so a comparison across waves mixes real change with the changing composition of respondents. Panel producers ship longitudinal weights for exactly this: lw_12 is nonzero only for cases observed at both waves 1 and 2 and re-inflates them to the wave-1 population; lw_123 does the same through wave 3. They are ordinary columns, selected with use_weight():

survivors = panel.use_weight("lw_123")
inc_survivors = survivors.estimation.mean("inc", by="wave", drop_nulls=True)
print(inc_survivors.contrast({"% change 1 → 3, survivors": estd(3) / estd(1) - 1}))
╭─────────────────────────────────── Contrast (TAYLOR, df=54) ───────────────────────────────────╮
│                                                                                                │
│  contrast                       est       se   cv (%)         t     p_value      lci      uci  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  % change 1 → 3, survivors   0.0710   0.0047     6.65   15.0328   6.031e-21   0.0616   0.0805  │
│                                                                                                │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯

The two answers are different questions: with w1, how did the population’s mean income move (entrants and leavers included); with lw_123, how did income change for the people followed through wave 3. svy does not choose for you.

Transitions and individual change with lag()

Anything that pairs a case’s value at one wave with its value at another goes through wrangling.lag(), the one panel primitive. It adds <col>_lag<n>, the value of the column at the case’s wave n steps back — null at the first wave, and null across a skipped wave (a case with rows at waves 1 and 3 gets no lag1 at wave 3, as Stata’s L.y; pass gaps="skip" for the previous observed row instead). Value labels and the variable type carry over. Negative n is a lead.

lagged = panel.wrangling.lag(["emp", "inc"])
lagged.data.select("case_id", "wave", "emp", "emp_lag1", "inc", "inc_lag1").head(6)
shape: (6, 6)
case_id wave emp emp_lag1 inc inc_lag1
i64 i64 i64 i64 f64 f64
1 1 0 null 2312.23 null
1 2 0 0 1705.9 2312.23
1 3 0 0 2223.37 1705.9
2 1 0 null 2617.62 null
2 2 0 0 3125.24 2617.62
2 3 1 0 2369.75 3125.24

Transition probabilities are conditional proportions: the distribution of the current status within each lagged status, on the target wave.

transitions = lagged.estimation.prop(
    "emp", by="emp_lag1", where=col("wave") == 2, drop_nulls=True
)
print(transitions)
╭──────────────────── Estimate: PROP (TAYLOR) ─────────────────────╮
│ where: wave == 2                                                 │
│                                                                  │
│  emp (lag 1)   emp      est       se      lci      uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  0             0     0.7262   0.0239   0.6758   0.7714     3.29  │
│  0             1     0.2738   0.0239   0.2286   0.3242     8.73  │
│  1             0     0.1863   0.0167   0.1552   0.2221     8.94  │
│  1             1     0.8137   0.0167   0.7779   0.8448     2.05  │
│                                                                  │
╰──────────────────────────────────────────────────────────────────╯

Here where= picks the wave and drop_nulls=True zero-weights the rows whose lag is null (the first wave) — again a domain, not a subset, so the design df is unchanged. The joint table with its chi-square test is tabulate on the same domain; its where= has R’s subset() semantics, and a null lag outside the domain is not missing data:

print(
    lagged.categorical.tabulate(
        "emp_lag1", "emp", units="percent", where=col("wave") == 2, drop_nulls=True
    )
)
╭────────────────────── Table: emp (lag 1) × emp ───────────────────────╮
│                                                                       │
│  emp (lag 1)   Col   Estimate   Std Err       CV     Lower     Upper  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│            0   0      31.5693    1.7649   0.0559   28.1419   35.2095  │
│            0   1      11.9017    1.1412   0.0959    9.7971   14.3862  │
│            1   0      10.5335    0.9835   0.0934    8.7182   12.6744  │
│            1   1      45.9955    1.8660   0.0406   42.2839   49.7521  │
│                                                                       │
╰───────────────────────────────────────────────────────────────────────╯

(drop_nulls=True is still needed here for the "nr" rows inside wave 2, whose outcomes are null.)

Individual change is a paired test on the target wave, with the same where=:

paired = lagged.categorical.ttest(
    "inc", y_pair="inc_lag1", where=col("wave") == 2, drop_nulls=True
)
print(paired)
╭──────────────── T-Test: One-sample ────────────────╮
│ Y = 'svy_inc_minus_inc_lag1'                       │
│ H₀: μ = 0.0000                                     │
│                                                    │
│                                                    │
│  Estimate   Std Err       CV     Lower      Upper  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│   97.2344   14.1584   0.1456   68.8362   125.6326  │
│                                                    │
│                                                    │
│ Test statistic                                     │
│                                                    │
│     diff        t        df   p_value              │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━              │
│  97.2344   6.8676   53.0000   <0.0001              │
│                                                    │
╰────────────────────────────────────────────────────╯

and the distribution of individual changes is a quantile of the difference:

d = lagged.wrangling.mutate({"d_inc": col("inc") - col("inc_lag1")})
print(d.estimation.quantile("d_inc", p=(0.25, 0.5, 0.75), where=col("wave") == 2, drop_nulls=True))
╭───────── Estimate: QUANTILE (TAYLOR, q_method=higher) ─────────╮
│ y: d_inc                                                       │
│ where: wave == 2                                               │
│                                                                │
│  prob           est        se         lci        uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  0.2500   -150.3300   22.7295   -187.6100   -96.4700   -15.12  │
│  0.5000    112.3000   16.3077     76.4200   141.8100    14.52  │
│  0.7500    368.7500   15.7740    333.5800   396.8300     4.28  │
│                                                                │
╰────────────────────────────────────────────────────────────────╯
WarningWhich weight is on the row?

ttest(y_pair=), quantile and the transition tables read the weight of the rows in where=. With the base weight w1 that is the cross-sectional wave-2 weight; if you want the survivors’ estimand, select the longitudinal weight first with use_weight("lw_12"). The lag itself does not care — it copies values.

A pooled model across waves

Because the sample is long, a model with the wave as a factor is one glm.fit call, and the case clustering is handled by the design:

fit = panel.glm.fit("inc", x=[Cat("wave"), Cat("age_grp"), "urban"])
print(fit)
╭────────────────────────────── GLM: Gaussian (identity) ──────────────────────────────╮
│ Modeling: inc                                                                        │
│                                                                                      │
│ Observations        2938  AIC            48028.3125                                  │
│ DF Residuals          48  BIC                     -                                  │
│ Deviance      2.1424e+09  Scale         730942.3217                                  │
│ R-squared        0.17839  R-sq (adj)        0.17671                                  │
│                           Iterations              2                                  │
│ F-stat (adj)    40.70693  Prob (F-adj)       <0.001                                  │
│                                                                                      │
│                                                                                      │
│  Term               Coef.    Std.Err.          t    P>|t|       [0.025       0.975]  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  _intercept_   2217.04598    69.82851   31.74987   <0.001   2076.64635   2357.44561  │
│  wave_2.0        81.72739    20.38218    4.00975   <0.001     40.74628    122.70850  │
│  wave_3.0       203.43916    22.28252    9.12999   <0.001    158.63715    248.24118  │
│  age_grp_2.0    440.90857    65.41638    6.74003   <0.001    309.38012    572.43702  │
│  age_grp_3.0    630.62303    79.49694    7.93267   <0.001    470.78372    790.46233  │
│  age_grp_4.0    981.90612   101.48815    9.67508   <0.001    777.85051   1185.96172  │
│  urban          422.02660    58.92804    7.16173   <0.001    303.54383    540.50937  │
│                                                                                      │
╰──────────────────────────────────────────────────────────────────────────────────────╯

The wave coefficients are the adjusted net changes against wave 1; fit.contrast(estd("wave_3") - estd("wave_2")) compares two later waves directly.

Building longitudinal weights

When the producer ships no longitudinal weight, or you want to rebuild it with your own cells, the chain is one nonresponse adjustment per wave, scoped with where= to the target wave. On a panel weighting.adjust() does two things it does not do on a cross-section:

  • a case with earlier rows but none in scope is a nonrespondent — attriters lost to follow-up have no row to filter on, so a plain row filter would never see them and the factor would be wrong;
  • the factor is applied to the case: every row of the case gets its own weight times the case’s factor, so a case-level base weight stays case-level — one longitudinal weight per case, whichever of its rows you analyze — and a nonrespondent case (interviewed nowhere in scope, or lost) gets 0 on all its rows. A weight column that already varies by wave keeps that variation. Cases outside every cell keep their weight.

respondents_only=True (the default) then drops the nonrespondent rows at the scope waves only; earlier rows stay, since those cases responded then.

lw = panel.weighting.adjust(
    "resp",
    cells=["stratum", "age_grp"],
    where=col("wave") == 2,
    wgt_name="my_lw_12",
    respondents_only=False,
)
lw = lw.weighting.adjust(
    "resp",
    cells=["stratum", "age_grp"],
    where=col("wave") == 3,
    wgt_name="my_lw_123",
    respondents_only=False,
)
print(lw.design)
╭─────────── Design ───────────╮
│ Case id            case_id   │
│ Wave               wave      │
│ Stratum            stratum   │
│ PSU                psu       │
│ SSU                None      │
│ Weight             my_lw_123 │
│ With replacement   False     │
│ Prob               None      │
│ Hit                None      │
│ MOS                None      │
│ Population size    None      │
│ Replicate weights  None      │
╰──────────────────────────────╯

The bundled producer weights were built by this very chain, so they agree to machine precision:

check = lw.data.select(
    (pl.col("my_lw_12") - pl.col("lw_12")).abs().max().alias("max |Δ lw_12|"),
    (pl.col("my_lw_123") - pl.col("lw_123")).abs().max().alias("max |Δ lw_123|"),
)
check
shape: (1, 2)
max |Δ lw_12| max |Δ lw_123|
f64 f64
0.0 0.0

The scope must be a set of whole waves for the missing-in-scope rule to apply; a where= mixing other conditions still adjusts, but warns that attriters without a row were not added. Replicate weights, if the design carries them, are adjusted column by column and written back per case in the same way, and each step is recorded on design_history. rake() or calibrate() to base-wave or wave-t controls can follow, exactly as on a cross-section.

TipPropensity classes

For a model-based adjustment, fit glm.fit(..., family="binomial") on the base-wave covariates with a response indicator built from lag(["resp"], n=-1) (the next wave’s status on the wave-1 row), categorize the fitted probabilities into quintiles with wrangling.categorize, and pass the class column as cells=.

Declaring a panel on a long file

A producer’s long file needs no stacking — declare the two columns on the Design:

panel = svy.Sample(df, svy.Design(case_id="person_id", wave="year", stratum="strat", psu="psu", wgt="w"))

The same validation runs at construction. If the file is wide (one row per case, inc_2019, inc_2021, …), unpivot it with polars before creating the Sample; a to_long convenience is on the list.

Summary

Question Call
Stack per-wave files svy.combine_samples(waves, kind="panel", case_id=...)
Level per wave estimation.mean("y", by="wave")
Net change, percent change .contrast(estd(2) - estd(1)), .contrast(estd(2) / estd(1) - 1)
Change among survivors use_weight("lw_12") first
Transitions wrangling.lag("y"), then tabulate("y_lag1", "y", where=wave == t) or prop("y", by="y_lag1", where=wave == t, drop_nulls=True)
Individual change ttest("y", y_pair="y_lag1", where=wave == t, drop_nulls=True)
Adjusted change glm.fit("y", x=[Cat("wave"), ...])
Longitudinal weights weighting.adjust("resp", cells=..., where=wave == t) per wave

Next steps

  • Weighting for the nonresponse, raking and calibration steps that follow the attrition adjustment.
  • Estimation for by/where domains, contrasts, and replicate designs.
  • Categorical analysis for tables and tests on the target wave.