A DataFrame is just a dict of Series in a trench coat — learn the Series and the DataFrame stops being mysterious.
Every column you have ever filtered in pandas is a Series, every row a method hands you is a Series — and arithmetic between two of them aligns by label before it computes, which explains half the NaNs you have ever seen.
pd.Series(data, index, dtype, name) builds a one-dimensional labeled array — NumPy's array plus an index and an optional name. A list gives you a default 0..n-1 index; a dict turns keys into index labels; index= overrides or supplies the labels; name= attaches the label that travels with the object (df['col'] gets its column name this way). Two Series meeting in an operator align on the index first, compute second — mismatched labels produce NaN, not an error. And it cuts both ways: a DataFrame column is a Series, but so is any single row you pull with .loc.
Every pandas skill you already have — filtering with between() and isin(), cleaning with fillna(), aggregating with sum() — is really a Series skill: df['col'] hands you one, the operation runs over it, and the result is usually another Series you feed onward. Understanding the Series is what makes index alignment click: q1 + q2 over cities matches Berlin to Berlin and puts NaN wherever a label exists on one side only, which is exactly the union-outer-join behavior the DataFrame constructor inherited. Once you think in Series, the DataFrame's quirks stop being quirks.
import pandas as pd
# a dict builds a Series: keys become the index, the name is up to you
prices = pd.Series({"berlin": 810, "vienna": 640, "zurich": 590})
print(prices)
print(prices.index.tolist(), prices.name)berlin 810 vienna 640 zurich 590 dtype: int64 ['berlin', 'vienna', 'zurich'] None
Dict input maps keys to index labels. There is no name unless you pass name= — a Series is one labeled column, not a table.
q1 = pd.Series({"berlin": 120, "vienna": 85, "zurich": 95})
q2 = pd.Series({"berlin": 110, "vienna": 90, "milano": 40})
print(q1 + q2)berlin 230.0 milano NaN vienna 175.0 zurich NaN dtype: float64
The alignment rule that explains a thousand mystery NaNs: pandas unions the two indexes and computes only where both sides have the label. milano and zurich exist on one side each, so they get NaN — and int64 upcasts to float64 the moment NaN appears.
sales = pd.DataFrame({
"order_id": ["A-1042", "A-1043", "A-1044", "A-1045", "A-1046"],
"city": ["Berlin", "Vienna", "Berlin", "Zurich", "Berlin"],
"amount_eur": [89.50, 42.00, 130.25, 61.75, 15.00],
})
s = sales["amount_eur"]
print(type(s), s.name)
print((s > 50).sum())
print(s.sum())<class 'pandas.core.series.Series'> amount_eur 3 338.5
A DataFrame column IS a Series — same object type, and it carries its column name along, which is why filtered columns keep a useful label. Mask it, sum it, feed it to anything: the whole one-dimensional toolkit applies.
row = sales.loc[2] print(row) print(row["city"], row["amount_eur"])
order_id A-1044 city Berlin amount_eur 130.25 Name: 2, dtype: object Berlin 130.25
Rows are Series too — the column names become the index and the row label becomes the name. One data structure, two orientations, which is why so many pandas operations work identically on rows and columns.
a = pd.Series([10, 20], name="revenue_2026") print((a * 2).name) print((a + pd.Series([1, 2], name="cost_2026")).name)
revenue_2026 None
The name attribute survives unary arithmetic but vanishes the moment two differently-named Series meet — pandas refuses to guess which label the result deserves. Useful as a quick 'which columns fed this?' probe in notebooks.
| Flag | Meaning |
|---|---|
pd.Series([1, 2, 3]) | list input: values in, default RangeIndex 0..n-1 |
pd.Series({'a': 1}) | dict input: keys become index labels, values become the data |
index=['x', 'y'] | explicit labels; also accepts a DatetimeIndex from pd.date_range for time series |
name='amount_eur' | label attached to the Series — becomes the column name when placed in a DataFrame |
dtype='float64' | force a dtype at construction instead of letting inference decide |
s1 + s2 | arithmetic aligns on index first — missing labels become NaN, never an error |
df['col'] / df.loc[row] | both return Series: columns by name, rows as transposed label->value pairs |
The Series class predates the 0.1.0 release: it sits in Wes McKinney's original SVN import of pandas on Google Code, alongside the first DataFrame, and appears in the 0.1.0 docs of December 2009 as the library's core 1-D structure. The original class already carried what still defines it — a data array, an index, and arithmetic that aligns on labels rather than positions.
Series inherited its index-aligned arithmetic straight from R's data frames and from AQR's internal tooling, where Wes McKinney built pandas as a quant analyst. Aligning labels before computing was the deliberate break from NumPy, which aligns by position. That decision explains both the NaN behavior in mixed indexes and the int-to-float upcast — and it is why reindexing, joining, and time-series resampling all feel native in pandas rather than bolted on.
A Series wraps two NumPy arrays: the values block and the Index object that labels it. Element-wise ops ride NumPy directly on the values; the label work happens before and after — Index.union() to merge two indexes, take-based reindexing to line values up with the union, then the arithmetic. That is why the aligned add costs roughly a hash-union plus a gather, and why a same-index op skips straight to the NumPy fast path. Under pandas 3's Copy-on-Write, slicing a Series is a lazy view that copies only on first write — so s = df['col'] stays cheap until you actually write to it.