EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Dropping constant features

A constant feature is a column that holds the same value in every row, so it has zero variance and can tell a model nothing; VarianceThreshold finds and removes it.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

Why features are dropped showed what useless columns cost. The cheapest ones to find are those that never change, because they can be spotted without looking at the target at all.

Syntax: VarianceThreshold is a transformer like any other, with fit, transform and a mask of what it kept.

python
from sklearn.feature_selection import VarianceThreshold

selector = VarianceThreshold(threshold=0)   # 0 keeps anything that varies at all
selector.fit(X)
selector.get_support()                      # a boolean per column, True means kept
selector.get_feature_names_out()            # the names of the kept columns

Finding the columns that never change

A frame with two dead columns

Four columns, two of which hold one value throughout. C is all zeros and D is all ones, and neither can separate one row from another.

python
import pandas as pd

data = pd.DataFrame({"A": [1, 2, 4, 1, 2, 4],
                     "B": [4, 5, 6, 7, 8, 9],
                     "C": [0, 0, 0, 0, 0, 0],
                     "D": [1, 1, 1, 1, 1, 1]})
ExampleTwo columns kept, two dropped
import pandas as pd
from sklearn.feature_selection import VarianceThreshold

data = pd.DataFrame({"A": [1, 2, 4, 1, 2, 4],
                     "B": [4, 5, 6, 7, 8, 9],
                     "C": [0, 0, 0, 0, 0, 0],
                     "D": [1, 1, 1, 1, 1, 1]})

selector = VarianceThreshold(threshold=0).fit(data)
print("kept:   ", selector.get_support())
print("names:  ", list(selector.get_feature_names_out()))
print("dropped:", [c for c in data.columns if c not in selector.get_feature_names_out()])
print()
print("selector.variances_:", selector.variances_)
print("pandas data.var():")
print(data.var().to_string())
  • get_support() returns one boolean per column in the frame's order, True for kept. It is the mask every selector in this part shares.
  • C and D are dropped because their variance is exactly 0.
  • The two variance numbers differ for A and B. selector.variances_ divides by n, which is the population variance; pandas .var() divides by n minus 1, which is the sample variance. For A that is 1.5556 against 1.8667.
  • The difference never changes the decision. Both are zero for a constant column and both are positive otherwise, so the mask is the same either way.

Returning a DataFrame instead of an array

Every scikit-learn transformer returns a NumPy array by default, which loses the column names. set_config(transform_output="pandas") makes every transformer in the session return a DataFrame instead, which saves rebuilding one by hand after each step.

ExampleThe same transformer, two return types
from sklearn import set_config
from sklearn.feature_selection import VarianceThreshold

print("default output:")
print(VarianceThreshold(threshold=0).fit_transform(data)[:2])
set_config(transform_output="pandas")
print()
print("with transform_output='pandas':")
print(VarianceThreshold(threshold=0).fit_transform(data).head(2))

Looking for constants in the house price data

A real dataset rarely has a column that is literally constant, and running the check anyway takes one line. The Ames house price data has 1460 rows and 81 columns.

ExampleNothing is exactly constant
import pandas as pd
from sklearn.feature_selection import VarianceThreshold

AMES = ("https://raw.githubusercontent.com/krishnaik06/"
        "Advanced-House-Price-Prediction-/master/train.csv")
df = pd.read_csv(AMES)
numeric = df.select_dtypes(include="number").drop(columns=["Id"])

selector = VarianceThreshold(threshold=0).fit(numeric.fillna(0))
dropped = [c for c in numeric.columns if c not in selector.get_feature_names_out()]
print("numeric columns:", numeric.shape[1])
print("zero variance:  ", dropped)

Nothing is dropped, which is the usual answer on a curated dataset and the reason this step is often skipped. It should not be: the useless columns are there, they are nearly constant rather than exactly constant, and VarianceThreshold at 0 cannot see them.

Finding near-constant columns

A column where one value covers 99 per cent of the rows is as useless as a constant for nearly every model, and it will not show up as zero variance. The share of the commonest value finds it, and unlike variance it works on text columns too.

python
share = df.apply(lambda s: s.value_counts(normalize=True, dropna=False).max())
quasi = share[share > 0.99].sort_values(ascending=False)
ExampleFour columns are one value in 99 per cent of rows
share = df.apply(lambda s: s.value_counts(normalize=True, dropna=False).max())
quasi = share[share > 0.99].sort_values(ascending=False)
print(quasi.round(4).to_string())
print()
print("columns where one value covers over 95 per cent:", int((share > 0.95).sum()))
print()
print(df["Utilities"].value_counts().to_string())

Sorting those shares and drawing them puts the cut-off where it belongs, as a judgement about a continuous scale rather than a rule.

ExampleWhere to draw the line, seen as a scale
import matplotlib.pyplot as plt

top = share.sort_values(ascending=False).head(15)

plt.figure(figsize=(7, 5))
plt.barh(range(len(top)), top.values, color="#9370DB")
plt.yticks(range(len(top)), top.index)
plt.ylim(14.6, -1.8)                  # largest at the top, with room for the two labels
plt.axvline(0.99, color="#d64541", linestyle="--")
plt.axvline(0.95, color="#e08a1e", linestyle="--")
plt.text(0.99, -1.1, "0.99", color="#d64541", ha="center", fontsize=10)
plt.text(0.95, -1.1, "0.95", color="#e08a1e", ha="center", fontsize=10)
plt.xlim(0.5, 1.01)
plt.xlabel("share of rows holding the commonest value")
plt.title("The 15 least varied columns in the Ames data")
plt.show()
print("above 0.99:", int((share > 0.99).sum()), " above 0.95:", int((share > 0.95).sum()))
A horizontal bar chart of the 15 Ames columns with the highest share of rows holding their commonest value, from Utilities at 0.9993 down. Dashed vertical lines mark 0.99 in red and 0.95 in orange. Four bars pass the 0.99 line and twelve pass the 0.95 line, and the bars fall away gradually rather than in a step.
  • Utilities is the same value in 1459 of 1460 rows. One house differs. That column cannot help a model and will almost certainly be constant in any test split.
  • Twelve columns are over 95 per cent one value, including text columns that VarianceThreshold cannot read at all.
  • dropna=False counts missing values as a value. Without it a column that is 99 per cent missing looks varied, because only the handful of real values are counted.
  • Where to draw the line is a decision, not a default. At 0.99 four columns go; at 0.95 twelve do, and some of those may still matter for a rare but important case.

Zero variance against near-constant

VarianceThreshold(0)Share of the commonest value
FindsColumns with exactly one valueColumns with one dominant value
Works on textNo, it raises ValueErrorYes
Needs the targetNoNo
ThresholdA variance, which depends on the column's scaleA share from 0 to 1, comparable across columns
On the Ames dataDrops nothingFinds Utilities, Street and two more

Where this check belongs

  • First, before anything expensive. It needs no target and no model, so it costs one pass over the data.
  • After one hot encoding. Encoding a column with a rare label creates a dummy that is 0 in 99.9 per cent of rows, and that dummy is exactly what this finds.
  • As a data check, not only a selection step. A column that is suddenly constant in new data usually means a broken feed rather than a useless feature.
Watch out. VarianceThreshold reads numbers only. Handing it a frame with a text column raises ValueError: could not convert string to float: 'RL'. Select the numeric columns first with select_dtypes(include="number"), and remember that a variance threshold above 0 is not comparable across columns on different scales, because variance carries the column's units squared.
Try it yourself
  • Set VarianceThreshold(threshold=0.01) on the Ames numeric frame and list what it drops; then scale the frame with MinMaxScaler first and run it again to see how much the answer depends on units.
  • Lower the 0.99 in the near-constant check to 0.97 and read the extra columns it finds.
  • Run the share check on pd.get_dummies(df, dtype=int) and count how many dummy columns are over 99 per cent zero.

Slow is fine. Stopping is the only problem.