Skip to content

pandas Series

One column of values, one label for each of them — and the labels are the point.

What makes a series different from a Python list? Both hold values in order, both slice, both know their length. The difference is that a series carries a second array alongside the values: the index, one label per value. That index is not decoration. It decides what .loc[] accepts, it decides what happens when you combine two series, and it is what lets a series act as a lookup table.

You already have series everywhere: every column of a data frame is one, and so is the result of value_counts or groupby(...).mean(). Building one by hand is rarer, and the constructor's full tour lives on the pd.DataFrame page. This page is about the index.

Official documentation: pandas.Series.

The parts that earn their keep

pd.Series([4.1, 4.2, 4.3])                      # values, plus a RangeIndex you did not ask for
pd.Series(values, index=labels, name='unrate')  # labels of your own, and a name
pd.Series({'us': 'USGS', 'ak': 'Alaska'})       # a dict: keys become the index
s.index, s.name                                 # the two things a list does not have

name is worth setting. It becomes the column heading the moment the series joins a frame — through to_frame, reset_index, or pd.concat — and an unnamed series lands as a column called 0.

A worked example, on real data

The US unemployment rate, monthly since 1948, straight from FRED. Read one column out of the file and what you have is a series:

import pandas as pd

url = 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=UNRATE'

s = (pd
     .read_csv(url, index_col='observation_date', parse_dates=True)
     ['UNRATE'])

s.tail(4)
observation_date
2026-04-01    4.3
2026-05-01    4.3
2026-06-01    4.2
2026-07-01    4.1
Name: UNRATE, dtype: float64

FRED adds a row every month, so your tail will run past mine. The index here is a DatetimeIndex, which means .loc[] now speaks dates:

s.loc['2020-03':'2020-06']
observation_date
2020-03-01     4.4
2020-04-01    14.8
2020-05-01    13.2
2020-06-01    11.0
Name: UNRATE, dtype: float64

Arithmetic aligns on labels, not on position

Here is the behavior that catches everyone once. Ask how much worse 2025 was than 2024:

s.loc['2025'] - s.loc['2024']
observation_date
2024-01-01   NaN
2024-02-01   NaN
2024-03-01   NaN
2024-04-01   NaN
Name: UNRATE, dtype: float64

Twenty-four rows, every one of them NaN. Two twelve-element series went in; Pandas matched them by label, found that January 2025 has no counterpart in 2024, and returned the union of both indexes with nothing in it. A list would have subtracted position by position and handed you twelve numbers. A series refuses, because it does not believe the fifth row of one thing is the same observation as the fifth row of another — which is exactly what keeps you from accidentally comparing March against July. When you want the year-over-year change, move the labels instead:

(s - s.shift(12)).tail(4).round(1)
observation_date
2026-04-01    0.1
2026-05-01    0.0
2026-06-01    0.1
2026-07-01   -0.2
Name: UNRATE, dtype: float64

A series with a meaningful index is a lookup table

Group the same series by decade and the result is a series whose index is a decade:

by_decade = s.groupby(s.index.year // 10 * 10).mean().round(1)

by_decade
observation_date
1940    4.9
1950    4.5
1960    4.8
1970    6.2
1980    7.3
1990    5.8
2000    5.5
2010    6.2
2020    4.8
Name: UNRATE, dtype: float64

That is a translation table, and map takes one directly — it matches your values against the index and returns the values:

(s
 .to_frame()
 .assign(decade=lambda df_: df_.index.year // 10 * 10)
 .assign(normal=lambda df_: df_['decade'].map(by_decade))
 .tail(3))
                  UNRATE  decade  normal
observation_date                        
2026-05-01           4.3    2020     4.8
2026-06-01           4.2    2020     4.8
2026-07-01           4.1    2020     4.8

Every month now carries its decade's average next to its own rate. No merge, no join key, no second data frame — the index did the work.

Three mistakes people make

Reading s[0] as "the first value." With a RangeIndex it looks like it is, which is how the habit forms. Give a series any other integer index and the square brackets do a label lookup: pd.Series([10, 20, 30], index=[2, 1, 0])[0] returns 30, while .iloc[0] returns 10. Use .iloc[] when you mean position and .loc[] when you mean a label, and never let the bare brackets decide.

Mapping against an index with duplicates. A lookup table needs unique keys. If two rows carry the same label, map refuses with InvalidIndexError: Reindexing only valid with uniquely valued Index objects. Check s.index.is_unique before trusting a series you built from data.

Throwing the index away to make things line up. Reaching for .values or .reset_index(drop=True) to force two series to combine does work, and it turns an alignment error into a silent mismatch. Fix the labels instead: shift, reindex, or a real merge.

Where it shows up in Bamboo Weekly

Bamboo Weekly #71: Holidays ends a chain with .groupby('holiday')['country'].count() and then filters that series by its own values — .loc[lambda s_: s_ > 1] — before sorting. The result is a series indexed by holiday name, and every step after the groupby is series work. Free to read.

Bamboo Weekly #115: Sahm rule pulls this same unemployment series from the FRED API and runs s.info() on it, which reports DatetimeIndex: 927 entries, 1948-01-01 to 2025-03-01. It then builds a frame from two series that share that index, relying on alignment to put a shifted rolling mean beside the raw rate.

Bamboo Weekly #130: Jobs reporting returns pd.Series({'first_revision': ..., 'second_revision': ...}) from a function applied across rows. The dict keys become the index of each little series, and those labels become the column names of the frame that comes back.

Practice it

Work through a Series exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/series/

Go deeper

pd.DataFrame covers building both structures by hand, map is the lookup above in full, and loc is label-based selection everywhere it appears. set_index is how a column becomes labels in the first place, and value_counts is the series you will meet most often.

More Pandas videos on Python and Pandas with Reuven Lerner.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

See it on real data

Below are the 7 Bamboo Weekly exercises that use Series on real-world data — try each one, then study the worked solution.

Part of the Pandas Methods Index. See also practice by skill.