One object holding every step, so that what is fitted on the training fold stays fitted on the training fold - by construction rather than by discipline.
Overview
What it is
Most machine-learning workflows have several preprocessing steps before the model. Imputing missing values, encoding categories, scaling features. Without a pipeline, each step is a separate call. Each call must be applied in the right order, to the right data, at the right time. Mistakes in that sequence are common and hard to spot.
A pipeline bundles those steps into a single object. It applies them in order during fit and again during predict. Because the whole sequence is one object, it can be passed to cross_val_score, GridSearchCV or any other tool that expects an estimator. The preprocessing runs inside the cross-validation loop, which is the only way to get an honest score.
The alternative -- scaling the full dataset, then splitting, then fitting -- leaks information from the test set into the training set. That leak inflates the score. A pipeline prevents it by design. Each fold sees only the training rows during fit_transform and applies the learned transformation to the test rows during transform.
Pipelines also make deployment simpler. You save one object that contains both the preprocessing and the model. At prediction time, you call predict on the pipeline and it handles every step. There is no risk of forgetting to scale or encode at serving time.
This page covers building a pipeline, inspecting its steps, and combining it with a column transformer for mixed-type data. It also covers the make_pipeline shortcut and the set_output method that preserves DataFrame column names.
A Pipeline chains transformers together and ends with an estimator. The result behaves as a single estimator.
That last sentence is the key one. A pipeline has fit, predict and score. Anywhere a model can go. cross_val_score, GridSearchCV, a pickle file, a check loop. A whole pipeline can go instead, with no changes to the nearby code.
What it does inside is straightforward. fit calls fit_transform on each step in turn, passing the output of one to the next. fit on the final estimator. predict calls only transform on each step, then predict on the last.
Worth knowing
A pipeline is an estimator: it has fit, predict and score, and goes anywhere one goes.
fit calls fit_transform on each step and fit on the last; predict calls only transform.
That asymmetry is the whole point - the held-out fold is transformed, never fitted on.
make_pipeline names the steps after their classes; Pipeline lets you name them, which makes the search grid readable.
step__parameter, with two underscores, is how a search reaches a setting inside a step.
Pickle the pipeline, not the model - a model deployed without its preprocessing gets raw features and is confidently wrong.
Pipelines: A Practical Guide
A pipeline isn't a convenience. It's the mechanism that makes each score in this track honest. Using one turns a rule you've to note into a property of the code.
Two ways to build one
The same object; the difference is whether you choose the step names.
example_01.pyscikit-learn
from sklearn.pipeline import make_pipeline, Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
auto = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
named = Pipeline([("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=1000))])
print("make_pipeline names them for you:", [n for n, _ in auto.steps])
print("Pipeline lets you choose :", [n for n, _ in named.steps])
print()
print("both are a single estimator with fit / predict / score")
print("has fit?", hasattr(auto, "fit"), " has predict?", hasattr(auto, "predict"))
Output
Reaching inside a fitted pipeline
The steps are still there, fitted, and gettable three different ways.
example_02.pyscikit-learn
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_wine
X, y = load_wine(return_X_y=True)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)
scaler = pipe.named_steps["standardscaler"]
model = pipe.named_steps["logisticregression"]
print("the fitted scaler's first three means:", scaler.mean_[:3].round(3))
print("the fitted model's shape :", model.coef_.shape)
print()
print("pipe[-1] is the last step:", type(pipe[-1]).__name__)
print("pipe[0] is the first :", type(pipe[0]).__name__)
Output
Three steps, refitted inside every fold
Imputation and scaling both learn from data, and both are inside.
example_03.pyscikit-learn
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.datasets import load_wine
import numpy as np
X, y = load_wine(return_X_y=True)
X = X.copy()
rng = np.random.RandomState(0)
X[rng.rand(*X.shape) < 0.1] = np.nan # 10% missing
pipe = make_pipeline(SimpleImputer(strategy="median"),
StandardScaler(),
LogisticRegression(max_iter=1000))
print("three steps, one call:", [n for n, _ in pipe.steps])
print("cross-validated accuracy:", round(cross_val_score(pipe, X, y, cv=5).mean(), 4))
print()
print("the imputer's medians and the scaler's means are both")
print("recomputed on the training part of every fold.")
Output
Tuning a parameter inside a step
Two underscores between the step name and the parameter, and a search can reach anything.
example_04.pyscikit-learn
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.datasets import load_wine
X, y = load_wine(return_X_y=True)
pipe = Pipeline([("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=5000))])
# stepname__parameter - two underscores
grid = {"clf__C": [0.01, 0.1, 1, 10],
"scale__with_mean": [True, False]}
search = GridSearchCV(pipe, grid, cv=5).fit(X, y)
print("best parameters:", search.best_params_)
print("best score :", round(search.best_score_, 4))
print()
print("the double underscore reaches into a step:")
print(" clf__C means the C of the step named clf")
Output
Saving the whole thing
The preprocessing is part of the model, so it has to travel with it.
example_05.pyscikit-learn
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
import pickle
X, y = load_wine(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0)
pipe = make_pipeline(StandardScaler(),
LogisticRegression(max_iter=1000)).fit(X_tr, y_tr)
blob = pickle.dumps(pipe)
restored = pickle.loads(blob)
print("saved bytes:", len(blob))
print("same predictions after a round trip:",
(restored.predict(X_te) == pipe.predict(X_te)).all())
print()
print("the scaler travels with the model, which is the point -")
print("a model saved without it would receive raw features and be wrong.")
Output
Switching a step off
'passthrough' turns a step into a no-op, which makes the step itself something a search can choose.
example_06.pyscikit-learn
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_wine
from sklearn.model_selection import cross_val_score
X, y = load_wine(return_X_y=True)
pipe = Pipeline([("scale", StandardScaler()),
("pca", PCA(n_components=5)),
("clf", LogisticRegression(max_iter=1000))])
print("with PCA :", round(cross_val_score(pipe, X, y, cv=5).mean(), 4))
pipe.set_params(pca="passthrough")
print("passthrough:", round(cross_val_score(pipe, X, y, cv=5).mean(), 4))
print()
print("'passthrough' disables a step without rebuilding the pipeline,")
print("which is how a search can treat a whole step as a hyperparameter.")
Output
The asymmetry is the point
Look again at those two sentences: fitfits the transformers. predict only transforms.
That skew is what makes a pipeline more than plumbing. Inside a cross-validation loop, fit is called on the training folds. So the scaler finds its mean there, the imputer its median, the encoder its category list. Then predict runs on the held-out fold. This Is transformed using those numbers and never adds to them.
Doing the same thing by hand means remembering, each time, to fit each transformer on the training part only. To apply it to the test part without refitting. That is four or five opportunities per test to make a mistake that inflates the score and makes no error.
The cross-validation module measured what that mistake costs. Feature pick applied outside the folds reported 0.825 fit on data that was pure noise. A pipeline makes that given mistake hopeless to write.
Two constructors
make_pipeline(StandardScaler(), LogisticRegression()) names the steps automatically, lowercasing the class names. It's shorter and right for quick work.
Pipeline([("scale", StandardScaler()), ("clf", LogisticRegression())]) lets you name them. The names matter once you're tuning. They appear in each param key: clf__C is far more readable than logisticregression__C. It doesn't change if you swap the classifier for a different one.
The rule of thumb: make_pipeline while exploring, Pipeline with short names once there's a search grid.
each step except the last must be a transformer. It must have transform. The last can be any of it, including another transformer if the pipeline's purpose is prep rather than guess.
Getting at the fitted parts
A fitted pipeline still holds its fitted steps, and there are three ways in.
pipe.named_steps["scale"] uses the name. pipe["scale"] is the shorter form of the same thing. pipe[0] and pipe[-1] index by spot. pipe[-1] for the final estimator is the one you'll write most.
This is how you read coef_ off the model at the end, or check what the scaler learned, or pull get_feature_names_out() from an encoder in the middle. The pipeline hides nothing. It only changes how you reach it.
pipe[:-1] slices, returning a new pipeline of all except the last step. At times handy for inspecting what the model really receives.
Tuning through a pipeline
Settings inside a pipeline are handled as stepname__parameter, with two underscores.
{"clf__C": [0.01, 0.1, 1]} tunes C on the step named clf. Nesting goes deeper with more underscores. A param inside a step inside a column transformer is preprocess__num__imputer__strategy. Which looks unwieldy and is fully mechanical.
The consequence worth appreciating: a search can tune the prep and the model. Whether to scale with the mean. This Imputation strategy to use, how many PCA components to keep. How the model is configured, all in one grid, all tested with the same honest cross-validation.
"passthrough" extends this to whole steps. Setting a step to that string makes it a no-op. {"pca": ["passthrough", PCA(5), PCA(10)]} treats the existence of the PCA step as itself a setting. The same trick lets a search choose between two fully different models in one grid.
Deployment is where it pays off
A model without its prep isn't a model.
Saving only the estimator and applying scaling by hand at guess time means reimplementing the change a place else, with the numbers copied across. Keeping the two in step forever. each one of those is a place for the training and serving paths to diverge. And when they do, nothing raises. The model receives features on a different scale from the ones it was fitted on and makes confident nonsense.
Pickling the pipeline saves the transformers and their learned params alongside the model. The object that comes back does the whole job from raw features to guess. The editor above round-trips one and confirms the guesses are the same.
Two cautions on pickles that apply to any scikit-learn object. They aren't a secure format. Unpickling untrusted data executes code. And they aren't portable across versions. A pipeline pickled with one scikit-learn and loaded with another may fail or, worse, behave differently. Recording the version alongside the file is the min. skops is the option for any of it that has to be shared.
When not to reach for one
Pipelines handle a sequence of steps applied to all the columns. Two situations need a thing else.
Different steps for different columns. Scaling the numeric ones and encoding the cat ones — is what ColumnTransformer is for. It slots into a pipeline as a single step.
any of it that changes the number of rows. Dropping outliers, resampling for imbalance, aggregating. Pipelines transform features and pass the target through untouched. A step that removed rows would leave X and y out of step. The imbalanced-learn package gives its own pipeline for just this case. Row-level filtering or else belongs before the pipeline, applied to the training data only.
Caching, when the fitting gets slow
A grid search over a pipeline refits each step for each combination. Most of that work is repeated. Tuning only the classifier means the scaler and the imputer are refitted the same way for each pick. This Is wasted time on any of it costly.
Pipeline(steps, memory="./cache") caches the fitted transformers, keyed on the step and the data they were given. The first combination pays for the prep. The rest reuse it. On a pipeline whose costly part is the prep. Text vectorisation, a costly feature build. This can be the difference between a search that finishes and one that doesn't.
Two caveats. The cache is on disk and isn't cleaned up automatically. A long-running project builds up directories. And it only helps when the transformer's inputs are truly the same. So It does nothing for a grid that tunes an early step.
Building your own step
A pipeline accepts any of it with fit and transform. This Includes classes you write.
For a stateless change — a log, a ratio between two columns, a date part — FunctionTransformer(func) wraps a plain function and needs nothing else. It's the shortest route into a pipeline and covers a surprising amount.
When the step needs to *learn* a thing, subclassing BaseEstimator and TransformerMixin gives you the rest of the interface for free: fit stores what it learned on self with a trailing underscore, transform applies it. TransformerMixin supplies fit_transform. get_params and set_params arrive from BaseEstimator. This Is what makes the custom step tunable by a search like any other.
The rule that keeps a custom step honest is the same as for the built-in ones. All learned from data must be learned in fit. transform must use only what fit stored. A transformer that finds a statistic inside transform recomputes it on the test fold. This Is the leak the pipeline was supposed to prevent.
Can a pipeline end in a transformer? Yes. It then has transform rather than predict. This Is handy when the pipeline exists to prepare features for a thing else.
How do I see what the model really receives?pipe[:-1].transform(X) runs each step except the last and hands back the array the estimator was given.
Does a pipeline slow any of it down? No. It calls the same methods in the same order. The only cost is a few field lookups.
Can I add a step to a fitted pipeline? You can modify pipe.steps. The result is unfitted from that point on. Building a new pipeline is clearer.
Reading a pipeline someone else wrote
An unseen pipeline is quicker to understand than an unseen script. The steps are the structure and they're listed in order.
Three questions cover it. What are the steps?[name for name, _ in pipe.steps] prints the sequence. The order is the order data flows through. What does each one learn? each step's fitted fields end in an underscore. pipe["scale"].mean_ and pipe["encode"].categories_ say what it took from the data. What comes out?pipe[:-1].transform(X).shape tells you how many features the model is really seeing. This Is often surprising after one-hot encoding.
The display helps too. Printing a pipeline in a notebook renders a diagram of the steps. sklearn.set_config(display="diagram") makes that the default. In a plain terminal, print(pipe) gives the nested repr. This Is dense but complete. each step and each non-default setting.
What to look for when reviewing one: whether any of it that learns from data sits *outside* it. A script that scales, imputes or selects features before building the pipeline has moved that step out of the folds. The pipeline's presence gives a false impression of safety. The pipeline only protects what is inside it.
The order of the steps, again
Steps run in the order given, and some orders are wrong.
Imputation before scaling, because a scaler can't find a mean across missing values. Encoding before scaling, because a scaler can't subtract a mean from a string. Both before feature pick, because the selector needs numeric, complete columns to score. And pick before the model. The model sees what was chosen.
The sequence that covers most tabular problems is. Impute, encode, transform any skew, scale, select, fit. Not each problem needs all six. The scaled order of the ones you use should follow that list.
Getting it wrong often raises. This Is fortunate. The exception is putting pick before scaling when the selector's criterion is scale-sensitive. That runs. Quietly selects the columns with the largest units.
Things to try with Pipelines in scikit-learn
Confirm the interface. In the first editor, note that a pipeline has fit and predict. It's an estimator, not a wrapper you unwrap.
Reach inside. In the second editor, try pipe[:-1].transform(X)[:2] to see just what the model receives.
Tune the prep. In the fourth editor, add "scale__with_std": [True, False] to the grid and see whether it changes the winner.
Break it on purpose. Fit the scaler outside a pipeline on all of X, then cross-validate the model alone. Compare against the pipeline version.
Questions people ask about Pipelines in scikit-learn
Why use a pipeline instead of separate steps? It ensures each step is fitted on training data only and replays the same steps on new data, which removes a common source of leakage.
What is the difference between Pipeline and make_pipeline?make_pipeline names the steps automatically from their class names. Pipeline lets you name them yourself.
How do I tune a step inside a pipeline? Use the step name, two underscores and the parameter, for example clf__C, in the grid passed to the search.
How do I get at a fitted step? Use pipe.named_steps['name'] or index it, such as pipe[-1], to read coefficients or learned attributes.
Can the last step be a transformer? Yes. Only the final step may lack predict, and then the pipeline acts as a transformer.
Can I save a pipeline? Yes. Persist the whole fitted pipeline with joblib.dump so preprocessing and model travel together.
Where this leaves you
One object, fitted once, holding each step that learns any of it. It makes cross-validation honest, makes the prep tunable. Makes deployment a single file. There's no situation in this track where doing the steps by hand is preferable. Forming the habit early costs nothing.
Check yourself
0 of 4
Answer without scrolling back up.
What does a pipeline's predict() do to the transformers?
fit fits them; predict only transforms. That asymmetry is what stops the held-out data influencing the transformation.
How do you tune C on a step named clf?
Two underscores between the step name and the parameter. Nesting goes deeper with more of them.
What does setting a step to "passthrough" do?
It turns the step into an identity, which lets a search treat the existence of the step as a hyperparameter.
Why pickle the pipeline rather than the model?
A model fitted on scaled features and served raw ones produces confident nonsense, and nothing raises to tell you.
Cheat sheet
Pipelines in scikit-learn
Chain preprocessing and a model into one Pipeline so every transformation fits on training data only and every score stays honest.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.