mrkeyoor.com_
Fri 07 Aug 20:52 UTC
PyPIDataupdated 07 Aug 2026

patsy

patsy turns R-style model formulas into numeric design matrices. You hand it a string like "y ~ x + C(group) + np.log(size)" plus a DataFrame, and it returns numpy arrays or DataFrames with the intercept column added, categorical variables dummy-coded against a reference level, interactions expanded, and every column given a readable name. The important trick is that it remembers what it did: the DesignInfo object attached to the result stores the category levels it saw, the mean it centered by, and the spline knots it picked, so you can rebuild the exact same columns for new rows at prediction time. It is the formula engine underneath statsmodels, which is why most people install it without ever importing it directly.

Verdict

Still the most convenient way to get an R-style design matrix in Python, and it is unavoidable if you use statsmodels formulas. For new code, take the maintainers at their word and use Formulaic instead.

API stability5/5The dmatrix and dmatrices signatures have not moved in years and 1.0.0 in November 2024 was mostly a formalization of the existing surface; a frozen project is a stable one.
Docs4/5patsy.readthedocs.io has a proper narrative overview, a formula grammar reference, and a page on model prediction with stored DesignInfo; it stops short of covering the scipy requirement for splines or the migration path off patsy.
Maintenance2/5The README declares no new feature development since August 2021 and recommends Formulaic; 67 open issues (78 counting PRs) and the 2026 commits are CI and pre-commit bumps rather than fixes.
Ecosystem4/5Around 9M weekly downloads and a hard dependency of statsmodels, which keeps it in nearly every Python statistics environment; almost none of that traffic is direct use, and the extension ecosystem never grew.

Use it if

  • You already use statsmodels formula API (smf.ols("y ~ x + C(g)", data=df)); statsmodels 0.14 imports patsy across dozens of its modules, so it is already in your environment and worth understanding
  • You need training-time and prediction-time encoding to match exactly: build_design_matrices(design_info, new_df) replays the same category levels, reference cells, centering means, and spline knots instead of re-fitting them on the new rows
  • You are porting model code from R and want the same formula semantics: y ~ x*g for main effects plus interaction, y ~ x - 1 to drop the intercept, C(g, Treatment(reference='b')) to set the baseline, I(x + 1) to escape arithmetic
  • You want dummy columns with names you can read in a coefficient table (g[T.b], x:g[T.c]) rather than the positional output of pandas.get_dummies stitched together by hand
Skip it if

Setup reality

pip install patsy pulls only numpy. Two surprises follow. First, spline functions (bs, cr, cc, te) raise ImportError: spline functionality requires scipy, because scipy is a test-only extra and not a runtime dependency, so you install it yourself. Second, patsy imports the packaging module at import time whenever pandas is importable, and packaging is not in its declared dependencies; in a full data stack something else always pulls it in, but a minimal patsy plus pandas environment fails with ModuleNotFoundError. Beyond that there is no build step, no compiled code, and Python 3.6 and up is claimed. The real cost is learning the formula mini-language and remembering that patsy is in maintenance mode.

Patterns

Turn a formula and a DataFrame into y and Xbuild-design-matrices

import pandas as pd
from patsy import dmatrices

df = pd.DataFrame({"y": [1., 2, 3, 4], "x": [1., 2, 3, 4], "g": list("aabb")})

y, X = dmatrices("y ~ x + g", data=df, return_type="dataframe")
print(X.columns.tolist())
# ['Intercept', 'g[T.b]', 'x']

dmatrices splits on the ~ and returns (lhs, rhs). Use dmatrix for the right side only. Without return_type="dataframe" you get a DesignMatrix, which is a numpy ndarray subclass, and you lose the pandas index.

Apply the training encoding to new rowsreuse-encoding-on-new-data

from patsy import dmatrices, build_design_matrices

y, X = dmatrices("y ~ x + g", data=train_df, return_type="dataframe")
design_info = X.design_info

(X_new,) = build_design_matrices([design_info], new_df, return_type="dataframe")
print(X_new.columns.tolist() == X.columns.tolist())  # True

This is the reason to use patsy at all. Re-running dmatrix on new_df would re-derive category levels and centering means from that data, so a batch missing group 'c' would silently produce a narrower matrix and break your model.

Choose the reference level for a categoricalcategorical-contrasts

from patsy import dmatrix, Treatment, Sum

m = dmatrix("C(g, Treatment(reference='b'))", df)
print(m.design_info.column_names)
# ['Intercept', "C(g, Treatment(reference='b'))[T.a]", ...]

dmatrix("C(g, Sum)", df)   # sum-to-zero coding instead

C() also forces a numeric column to be treated as categorical, which matters for integer-coded groups. The [T.x] in a column name means treatment contrast against the omitted reference level; the reference level has no column.

Write interactions and main effectsinteractions

from patsy import dmatrix

dmatrix("x*g", df).design_info.column_names
# ['Intercept', 'g[T.b]', 'g[T.c]', 'x', 'x:g[T.b]', 'x:g[T.c]']

dmatrix("x:g", df).design_info.column_names
# ['Intercept', 'x:g[a]', 'x:g[b]', 'x:g[c]']

a*b expands to a + b + a:b, while a:b is the interaction alone. Note the encoding changes: with no main effect for g, patsy switches g to full dummy coding to keep the matrix full rank.

Fit without an interceptdrop-intercept

from patsy import dmatrix

dmatrix("x - 1", df).design_info.column_names   # ['x']
dmatrix("0 + x", df).design_info.column_names   # ['x']

dmatrix("0 + g", df).design_info.column_names   # ['g[a]', 'g[b]']

- 1 and 0 + are the same thing. Dropping the intercept flips categoricals from treatment coding (k-1 columns) to full dummy coding (k columns), which changes what your coefficients mean.

Transform and escape variables inside the formulainline-transforms

import numpy as np
from patsy import dmatrix

dmatrix("np.log(x)", df)          # any callable in the caller's scope
dmatrix("I(x + 1)", df)           # I() means arithmetic, not formula '+'
dmatrix("Q('total sales')", df)   # Q() quotes an awkward column name

Formula code is evaluated with eval in your calling frame, so np works because you imported it. Without I(), the + is formula syntax and adds a term. Q() is the only way to reference a column with a space or a dash.

Center and standardize with remembered statisticsstateful-transforms

from patsy import dmatrix, build_design_matrices

train = dmatrix("center(x) + standardize(x)", df)
(scored,) = build_design_matrices([train.design_info], new_df)
# new rows are centered by the TRAINING mean, not their own

center, standardize, and scale are stateful transforms: the statistic is captured during the first pass and stored in design_info. Doing the same with a plain np expression would recompute the mean on every dataset and leak information.

Add a B-spline basis to a continuous termsplines

from patsy import dmatrix

m = dmatrix("bs(x, df=4)", df)   # needs scipy installed
print(m.shape)                   # (n, 5) including the intercept

dmatrix("cr(x, df=4)", df)       # natural cubic regression spline

Splines raise PatsyError wrapping ImportError: spline functionality requires scipy, because scipy is a test extra rather than a runtime dependency. Knot placement is also a stateful transform, so build_design_matrices reuses the training knots.

Control what happens to NaN rowsmissing-data

from patsy import dmatrices, NAAction

y, X = dmatrices("y ~ x", df_with_nan, return_type="dataframe")
print(X.index.tolist())   # NaN rows silently dropped by default

dmatrices("y ~ x", df_with_nan, NA_action=NAAction(on_NA="raise"))
# patsy.PatsyError: factor contains missing values

The default is on_NA="drop", and it drops from both y and X together so they stay aligned. It is silent, so check X.shape against len(df) if row count matters, or pass on_NA="raise" to make the data problem visible.

Map coefficients back to termsinspect-design-info

di = dmatrix("x + g", df).design_info

print(di.describe())      # '1 + g + x'
print(di.term_names)      # ['Intercept', 'g', 'x']
print(di.column_names)    # ['Intercept', 'g[T.b]', 'g[T.c]', 'x']
print(di.slice("g"))      # slice(1, 3, None)

One term can span several columns, so di.slice(term) is how you pull all coefficients belonging to a categorical for a joint test. term_names and column_names are different lengths whenever a categorical is present.

Use patsy through statsmodels instead of directlystatsmodels-formula

import statsmodels.formula.api as smf

result = smf.ols("y ~ x + C(g)", data=df).fit()
print(result.params.index.tolist())
# ['Intercept', 'C(g)[T.b]', 'x']

result.predict(new_df)   # reuses the stored design_info

This is how almost everyone actually uses patsy. predict() on a fitted result replays the training design_info for you, so unseen category levels raise instead of quietly shifting columns.

Move a formula off patsymigrate-to-formulaic

from formulaic import model_matrix

y, X = model_matrix("y ~ x + g", df)
print(X.columns.tolist())      # ['Intercept', 'x', 'g[T.b]']

X_new = X.model_spec.get_model_matrix(new_df)   # the design_info equivalent

Formulaic's ModelSpec plays the role of patsy's DesignInfo. Column names match for common formulas but ordering does not, so anything that indexes coefficients by position needs rechecking after the switch.

Alternatives

PackageRegistryPick it when
formulaicPyPIYou are writing new code; the patsy maintainers point here as the successor and it is the one still getting features.
statsmodelsPyPIYou want the formulas and the models together; smf.ols and friends call patsy for you and hand back a fitted result.
scikit-learnPyPIYour encoding feeds a machine learning pipeline; ColumnTransformer and OneHotEncoder fit, transform, and serialize the sklearn way.
pandasPyPIYou need dummy columns and nothing else; get_dummies is already imported and there is no formula grammar to learn.