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.
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.
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
- You are starting something new. The README says it plainly: patsy is no longer under active development, no new features are planned, and the maintainers recommend migrating to Formulaic. The 2026 commits are dependabot bumps and pre-commit updates, not fixes
- You want speed or sparse output. patsy materializes dense numpy arrays and only understands numpy and pandas; Formulaic is the successor written for performance and covers more dataframe backends
- Your model lives in scikit-learn. ColumnTransformer plus OneHotEncoder is the idiomatic path there, it survives cross-validation and pickling, and patsy's DesignInfo does not plug into the sklearn estimator API
- You would build formula strings from user input. patsy evaluates factor expressions as Python in the caller's stack frame, so "__import__('os').system('...')" in a formula is a shell, not a syntax error
- You only need one-hot encoding of two columns. Installing a formula parser and learning Wilkinson notation to replace three lines of pandas is not a trade that pays off
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()) # TrueThis 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 insteadC() 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 nameFormula 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 owncenter, 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 splineSplines 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 valuesThe 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_infoThis 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 equivalentFormulaic'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
| Package | Registry | Pick it when |
|---|---|---|
| formulaic | PyPI | You are writing new code; the patsy maintainers point here as the successor and it is the one still getting features. |
| statsmodels | PyPI | You want the formulas and the models together; smf.ols and friends call patsy for you and hand back a fitted result. |
| scikit-learn | PyPI | Your encoding feeds a machine learning pipeline; ColumnTransformer and OneHotEncoder fit, transform, and serialize the sklearn way. |
| pandas | PyPI | You need dummy columns and nothing else; get_dummies is already imported and there is no formula grammar to learn. |