Pandas Series vs DataFrame: Mixed Data Types, Columns and Common Surprises
Series vs DataFrame in pandas 3: why rows become object dtype, integers turn into floats, text sums concatenate and maths aligns by label.
A pandas Series looks like one spreadsheet column. A DataFrame looks like an entire table. That description is useful, but incomplete, and the missing part explains most of the "why did pandas do that?" moments beginners hit.
All examples below run on pandas 3. Where older versions differ, I'll say so.
The simple idea
A Series is one column with labels. A DataFrame is several Series glued side by side, sharing the same row labels.
Think of a cricket scorecard. Each column (runs, balls, fours) is a Series: one kind of value, all the same type. The full scorecard is the DataFrame: many columns lined up by batter name.
The key rule: a Series has exactly one dtype. A DataFrame can have a different dtype per column. Almost every surprise below comes from that rule.
A Series up close
import pandas as pd
prices = pd.Series([499, 1250, 350], name="price")
print(prices)
print(prices.dtype)0 499
1 1250
2 350
Name: price, dtype: int64
int64Three parts: the values (499, 1250, 350), the index on the left (0, 1, 2), and a single dtype for all of them (int64).
A DataFrame: columns with their own types
df = pd.DataFrame({
"product": ["Mug", "Lamp", "Pen"],
"price": [499, 1250, 350],
"in_stock": [True, False, True],
})
print(df.dtypes)product str
price int64
in_stock boolThree columns, three dtypes. Each column is its own Series, typed correctly.
On pandas 2 and earlier, product shows as object. pandas 3 gave text a dedicated str dtype, which is faster and stricter.
Surprise 1: one bracket or two?
print(type(df["price"])) # <class 'pandas.Series'>
print(type(df[["price"]])) # <class 'pandas.DataFrame'>One label gives you one column, a Series. A list of labels gives you a table, even if the list has one item. It matters because they behave differently:
print(df["price"].mean()) # 699.666... (a single number)
print(df[["price"]].mean()) # a Series: price 699.666667If a function expects a number and you pass the double-bracket version, you'll get a Series back and a confusing error two lines later.
Surprise 2: a row is a Series too, and it loses its types
Grab the first row:
row = df.iloc[0]
print(row)product Mug
price 499
in_stock True
Name: 0, dtype: objectA row is returned as a Series, and a Series can only have one dtype. This row holds text, a number and a boolean, so pandas falls back to object, the "anything goes" type.
That's why looping over rows with iterrows() is slow and loses types. Work with whole columns instead:
print(df.loc[df["in_stock"], "price"].tolist()) # [499, 350]Surprise 3: mixed values become object
mixed = pd.Series([1, "two", 3.0])
print(mixed.dtype) # objectPandas can't pick one numeric type, so it stores generic Python objects. Maths on an object column is slow, and some of it fails. This usually happens when a CSV has a stray word in a number column. The fix is in cleaning wrong types.
Surprise 4: integers turn into floats
s = pd.Series([1, 2, None])
print(s.dtype, s.tolist()) # float64 [1.0, 2.0, nan]You gave it integers and got floats. The reason: the standard NumPy integer type has no way to store "missing", but floats have NaN. So pandas upgrades the whole column.
If the numbers must stay whole (IDs, quantities), use the nullable integer type. Note the capital I:
s = pd.Series([1, 2, None], dtype="Int64")
print(s.dtype, s.tolist()) # Int64 [1, 2, <NA>]Surprise 5: summing text concatenates it
nums = pd.Series(["10", "20", "x"])
print(nums.sum()) # 1020xNo error. The values are strings, and adding strings joins them. You wanted 30 and got "1020x". Convert first:
print(pd.to_numeric(nums, errors="coerce").sum()) # 30.0errors="coerce" turns "x" into NaN, and sum() skips missing values.
Surprise 6: maths lines up by label, not position
This is the one that confuses people most:
a = pd.Series([10, 20, 30], index=["a", "b", "c"])
b = pd.Series([1, 2, 3], index=["b", "c", "d"])
print(a + b)a NaN
b 21.0
c 32.0
d NaNPandas didn't add position 1 to position 1. It matched labels: b with b, c with c. Labels that exist on only one side get NaN, and because NaN appeared, the result became float (Surprise 4 again).
It's like matching players by name, not shirt position. That's powerful when your data is labelled by date or customer ID. It's a trap when the indexes don't match by accident, for example after filtering one Series but not the other. Use .reset_index(drop=True) on both if you really want position-based maths.
Quick reference
| Question | Series | DataFrame |
|---|---|---|
| Shape | One dimension | Two dimensions |
| Dtypes | Exactly one | One per column |
| Get one | df["col"], df.iloc[0] | df[["col"]], df.iloc[[0]] |
.mean() returns | A number | A Series (one per column) |
When it matters
On a small notebook, these surprises cost a few minutes. In a pipeline, they cost wrong numbers that nobody notices: IDs that became floats and no longer match, totals built from text, sums that silently aligned the wrong rows. Check df.dtypes after every load and every merge, and you'll catch most of them early.
Next, see how to plot your clean columns with Matplotlib, Seaborn or Plotly.
In one line: a Series is one labelled column with one dtype, a DataFrame is many Series sharing an index, and most pandas surprises come from that one-dtype rule and from label-based alignment.
Keep reading
New games, tools and tutorials
About one short email a week. No spam, one-click unsubscribe.