df.columns is not a list — it is an Index you can set, compare, and string-process your way to clean headers in one line.
That KeyError on a column you can plainly see? The header said 'Order Date' and you asked for 'order_date'. df.columns is where that gets settled.
df.columns exposes the column axis as a pd.Index — the sibling of the row index, one label per column. Read it (df.columns.tolist()), test it ('revenue' in df.columns), or reassign it (df.columns = [...]) and every column is relabeled in place, no copy of the data involved. Because it is an Index, it carries its own toolkit: .str processing, set arithmetic, duplicated(), and it feeds straight back into column selection: df[list(df.columns[:3])] takes the first three columns by name.
Real-world headers arrive as 'Sales Region', 'Order Value', 'Order-Date' — spaces, caps and dashes that make df['Order Value'] a quoting minefield and df.query('Order Value > 0') a syntax error. Cleaning them once on the way in turns every later line into clean attribute-style access. .columns is also the tool for the three jobs .rename can't do alone: bulk programmatic renames, reordering or subsetting a wide table, and flattening the MultiIndex a pivot or groupby-agg leaves behind. And it is your duplicate-name alarm: pandas allows 'id' twice, pandas 3.0's arrow engine only surfaces one of them, and duplicated() catches it.
# snake_case a whole header the moment you load a report
sales = pd.DataFrame({
"Sales Region": ["North", "South", "North", "East"],
"Order Value": [1250, 980, 2210, 640],
"Order-Date": ["2026-01-14", "2026-01-15", "2026-01-17", "2026-01-21"],
})
print(sales.columns.tolist())
sales.columns = (sales.columns
.str.lower()
.str.replace(" ", "_")
.str.replace("-", "_"))
print(sales)['Sales Region', 'Order Value', 'Order-Date'] sales_region order_value order_date 0 North 1250 2026-01-14 1 South 980 2026-01-15 2 North 2210 2026-01-17 3 East 640 2026-01-21
One line of .str cleanup and every later expression is df.query('order_value > 1000') instead of a quoting fight. Verify the rename with tolist(), not by eye.
# pick a subset of columns for a report — by list, not by loc
sensor = pd.DataFrame({
"device": ["s-01", "s-02", "s-03", "s-04"],
"temp_c": [21.4, 19.8, 24.2, 20.1],
"humidity": [44, 51, 39, 47],
"battery": [98, 64, 91, 12],
})
keep = sensor[["device", "temp_c", "battery"]]
print(keep.columns.tolist())['device', 'temp_c', 'battery']
Column selection via df[list_of_names] — list(df.columns[:3]) turns 'first three columns' into one expression.
# rename one column without retyping the other three
sensor = sensor.rename(columns={"battery": "battery_pct"})
print(sensor.columns.tolist())
print("temp_c" in sensor.columns, "temp_f" in sensor.columns)['device', 'temp_c', 'humidity', 'battery_pct'] True False
.rename() maps single names precisely; the membership test ('col' in df.columns) is the check before df['col'] explodes.
# flatten the MultiIndex a pivot leaves behind
sales = pd.DataFrame({
"city": ["Oslo", "Oslo", "Lima", "Lima"],
"year": [2025, 2026, 2025, 2026],
"revenue": [410, 512, 388, 402],
"units": [120, 141, 118, 123],
})
wide = sales.pivot(index="city", columns="year")
print(wide.columns.tolist())
wide.columns = [f"{m}_{y}" for m, y in wide.columns]
print(wide)[('revenue', 2025), ('revenue', 2026), ('units', 2025), ('units', 2026)]
revenue_2025 revenue_2026 units_2025 units_2026
city
Lima 388 402 118 123
Oslo 410 512 120 141
Pivot two value columns and the header becomes (measure, year) tuples. A list comprehension over .columns flattens it into export-ready names.
# catch duplicate column names before they bite buggy = pd.DataFrame([[1, 2]], columns=["id", "id"]) print(buggy.columns.tolist()) print(buggy.columns.duplicated().tolist()) print(buggy["id"])
['id', 'id'] [False, True] id id 0 1 2
pandas tolerates duplicate labels; df['id'] then returns a DataFrame, not a Series. duplicated() finds the second 'id' so you can fix or merge it.
| Flag | Meaning |
|---|---|
df.columns = [...] | Set all names at once; len must match or ValueError('Length mismatch: Expected axis has 2 elements, new values have 1 elements'). |
df.rename(columns={...}) | 8 params (mapper, index, columns, axis, copy, inplace, level, errors) — surgical rename without touching other labels. |
df.columns.str.* | The Index carries .str like a Series: lower, strip, replace, split — the standard header-cleanup idiom. |
df.filter(items=..., like=..., regex=...) | Subset columns by exact match, substring, or pattern — regex='^temp_' grabs every temperature column at once. |
df.add_prefix() / df.add_suffix() | Bulk-namespace columns after joins: ['sales_qty', 'sales_price'] from add_prefix('sales_'). |
df.set_axis(labels, axis=1) | Set names with explicit axis and optional copy/inplace — the functional cousin of df.columns = labels. |
df.columns.duplicated() | Boolean mask of second-and-later occurrences of each name — pair with .any() as a pre-join sanity check. |
columns appears in the v0.4.0 (September 2011) frame.py — the same release where shape first appears — predating the whatsnew changelog, which begins at v0.4.x. The v0.4.0 source already documents the two-sides-of-the-same-coin structure: self.index (rows) and self.columns (cols), both Index objects.
Early pandas stored column labels as plain numpy arrays; over time they consolidated into the Index class with its own .str accessor, set operations, and equality semantics. Two later changes shaped daily use: rename() gained explicit index= and columns= parameters (the mapper/axis form is the older interface), and the pandas 2.0–3.0 string-dtype work redefined how label equality is computed under PyArrow-backed strings.
df.columns returns an immutable, hashable pd.Index — usually a RangeIndex-free object of object (string) or arrow-string dtype backed by a single numpy or PyArrow array. Because it is array-backed, a .str chain compiles to a few array operations over the whole label axis instead of per-name Python calls; because it is hashable, it can serve as dict keys and as the columns argument of another DataFrame. Assignment goes through pandas' set_axis(axis=1) machinery: it validates length, checks hashability, and swaps the axis object on the manager — data blocks are never copied, which is why renaming a million-row frame costs microseconds, not milliseconds.