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.

By CrunchCubeUpdated 21 October 20265 min read

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
int64

Three 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     bool

Three 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.666667

If 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: object

A 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)  # object

Pandas 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())  # 1020x

No 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.0

errors="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     NaN

Pandas 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

QuestionSeriesDataFrame
ShapeOne dimensionTwo dimensions
DtypesExactly oneOne per column
Get onedf["col"], df.iloc[0]df[["col"]], df.iloc[[0]]
.mean() returnsA numberA 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.