Tooling · data libraries Section 2.2 · items 5–6 of 17

pandas Series
& DataFrame

A NumPy array knows where things are. A pandas object knows what things are called. That one addition — a label attached to every row and every column — is the whole difference, and it explains every convenience and every surprise the library has to offer.

Operations align on labels rather than positions. Once that clicks, the alignment behaviour stops being magic and the NaNs stop being mysterious.

The two objects

Series — one labeled column. Values plus an index.

DataFrame — a dict of Series that all share one index.

That's it. A DataFrame column is a Series. A DataFrame row, pulled out, becomes a Series too — with the column names as its index.

Every way to reach in

One small DataFrame with a string index, so label and position genuinely differ. Pick an expression and watch what it selects, what type comes back, and what the returned object's own index looks like.

Expression
Returns
Shape
Result

01Anatomy

Four parts, and they're all separate objects

A DataFrame is not one thing. It's an index, a set of column labels, the values underneath, and a dtype for each column — and each of those can be inspected and replaced independently.

Series

import pandas as pd

s = pd.Series([412, 388, 501],
              index=["r-01", "r-02", "r-03"],
              name="hours")

s.values     # the numpy array underneath
s.index      # Index(['r-01','r-02','r-03'])
s.name       # 'hours' — becomes the column name
s.dtype      # int64

A Series is one-dimensional but never anonymous. Its name is what a column is called once it's inside a DataFrame.

DataFrame

df = pd.DataFrame({
    "site":   ["lisbon", "porto"],
    "hours":  [412, 388],
}, index=["r-01", "r-02"])

df.index       # row labels
df.columns     # column labels — also an Index!
df.dtypes      # one dtype per column
df.shape       # (2, 2)
df.info()      # the first thing to run on new data

df.columns is itself an Index object — the same class as the row index. Rows and columns are symmetric in pandas, which is why axis= works the way it does.

The mental model that pays off: a DataFrame is a dictionary of Series that have been forced to share one index. Column operations are cheap because each column is its own contiguous array. Row operations are expensive because a row cuts across every one of them — which is why iterrows is slow and column-wise vectorization is fast.
02The index

The label layer, and what it buys you

The index is not a column. It's a separate structure sitting alongside the data, and it does four jobs.

What it's for

lookup
Fetch by name instead of counting positions. Backed by a hash table, so it's fast.
alignment
Operations between objects match on labels, not positions. Section 04.
grouping
GroupBy results are indexed by the group key. Resample and rolling use it too.
joining
join and concat use it by default to decide what lines up with what.

Kinds of index

RangeIndex
The default 0…n−1. Cheap, and carries no information.
Index
Arbitrary labels — strings, dates, anything hashable.
DatetimeIndex
Unlocks resample, partial-string slicing like df.loc["2026-03"], and timezone handling.
CategoricalIndex
Memory-efficient for repeated labels.
MultiIndex
Several levels at once — the result of grouping by more than one key.
# moving between index and column
df = df.set_index("robot_id")        # column becomes the index
df = df.reset_index()                # index becomes a column again
df = df.reset_index(drop=True)      # …or just throw it away
df = df.rename_axis("robot")         # name the index itself
df = df.sort_index()                 # sort by label, not by value

# checks worth running before you trust a join or a lookup
df.index.is_unique                   # duplicates cause silent row multiplication
df.index.has_duplicates
df.index.is_monotonic_increasing     # required for some slice operations
The index does not have to be unique, and this is where quiet disasters begin. df.loc["r-02"] returns a Series when the label appears once and a DataFrame when it appears twice — so downstream code that assumed a Series breaks on a data change rather than on a code change. Assert uniqueness whenever you rely on it.
When to set a meaningful index: repeated lookups by key, time series work, and joins on that key. When not to: most of the time. A plain RangeIndex with the key kept as a normal column is simpler, survives groupby and merge more predictably, and is easier for other people to read.
03Selection

Six ways in, and the rules that separate them

AccessorIndexes byUse for
df["col"]Column labelOne column → Series. The everyday case
df[["a","b"]]List of labelsSeveral columns → DataFrame. Note the double brackets
df[mask]Boolean arrayFiltering rows
df.loc[r, c]LabelsThe general-purpose accessor. Rows and columns, both by name
df.iloc[r, c]PositionsWhen you genuinely mean "the third one"
df.at / df.iatLabel / positionA single cell. Faster than loc/iloc, scalars only
df.query("a > 3")Expression stringReadable multi-condition filters
Label slices include the endpoint. Position slices don't. df.loc["r-02":"r-04"] returns three rows; df.iloc[1:3] returns two. This looks inconsistent and isn't — a label slice can't know what comes "one past" the end, so pandas includes what you named. Try both in the explorer above and count.
Bare df["x"] means a column, but bare df[0:2] means rows. The single-bracket operator guesses from what you hand it. That ambiguity is exactly why .loc and .iloc exist, and why they're worth using in anything you'll maintain.
# chained assignment: the classic pandas trap
df[df["errors"] > 5]["flag"] = True       # ← may silently do nothing

# the first [] may return a copy, so the second [] writes to
# a temporary that is then discarded. one accessor, not two:
df.loc[df["errors"] > 5, "flag"] = True    # ← correct

# pandas 2.x offers Copy-on-Write as an opt-in and 3.0 makes it
# the default, turning "sometimes silently works" into
# "reliably never works" — better, but write it right anyway.
pd.options.mode.copy_on_write = True
04Alignment

The behaviour that makes pandas pandas

When you combine two labeled objects, pandas does not line them up by position. It lines them up by label, takes the union of what it finds, and fills the gaps with NaN. Toggle labels in or out of each Series and watch the result reorganize itself.

Series a

Series b

a + b

This is the number one source of unexplained NaNs. You subtract two columns that came from different sources, the answer is full of nulls, and nothing errored. The cause is almost always that the indexes didn't match — often because one of them still carried a RangeIndex from before a filter, which left gaps in the numbering.
Two escapes when you want positional behaviour. Use .to_numpy() to drop the labels and get raw arrays, or call .reset_index(drop=True) on both sides so the labels agree by construction. The first is a blunt instrument; the second is usually what you meant.
# alignment is usually protecting you
a = df["hours"].sort_values()          # reordered — labels travel with values
b = df["errors"]

a - b                                # still correct! matched on label
a.to_numpy() - b.to_numpy()          # WRONG — positional, ignores the sort

# explicit alignment, when you want to see it happen
a, b = a.align(b, join="inner")      # or "outer", "left", "right"
s.reindex(["r-01", "r-09"], fill_value=0)

Note the third line. Reaching for .to_numpy() to "make it simpler" is how you discard the protection and get a silently wrong answer instead of a visible NaN.

05dtypes

What's actually in the column

Each column has one dtype, and it determines speed, memory, and which operations exist. df.dtypes should be the second thing you look at, right after df.shape.

dtypeHoldsNotes
int64 / int32IntegersCannot hold NaN. Adding a null silently converts the column to float
float64 / float32RealsNaN lives here. float32 halves memory when precision allows
boolTrue / FalseAlso cannot hold nulls — becomes object if you introduce one
objectAnything, via Python pointersThe historical home of strings. Slow, memory-hungry, and a common sign of a parsing problem
stringTextA real string dtype with proper null handling. Prefer it over object for text
categoryRepeated labelsStores codes plus a lookup. Dramatic memory savings on low-cardinality columns
datetime64[ns]TimestampsUnlocks .dt accessors, resampling, date slicing
timedelta64[ns]DurationsProduced by subtracting datetimes
Int64, Float64, booleanNullable versionsCapitalized. Hold real nulls without changing type — worth adopting
An object column is a diagnosis, not a dtype. It usually means a numeric column contained a stray string — a footnote marker, an "N/A", a thousands separator — so pandas fell back to storing Python objects. Find the culprits with pd.to_numeric(col, errors="coerce").isna() & col.notna(), which shows exactly which values refused to parse.
Category is the cheapest big win available. A column of a million rows holding five distinct site names goes from megabytes of pointers to a byte per row plus a tiny lookup. Check the damage with df.memory_usage(deep=True) — without deep=True, object columns lie about their size.
df = df.astype({"site": "category", "hours": "int32"})
df["ts"] = pd.to_datetime(df["ts"], errors="coerce", utc=True)
df["n"]  = pd.to_numeric(df["n"], errors="coerce")

df.memory_usage(deep=True).sum() / 1e6     # MB, honestly counted
df.select_dtypes("object").columns          # the suspects
06Missing data

Several kinds of nothing

NaN

A float value from IEEE 754. It is not equal to itself — np.nan == np.nan is False, which is why you must use .isna() rather than == None.

None / NaT / pd.NA

Python's null, the datetime null, and pandas' own dtype-agnostic null. .isna() catches all of them, which is the reason to use it exclusively.

The int problem

Introduce one null into an int64 column and the whole column becomes float64. Your ids grow decimal points. The nullable Int64 dtype exists to prevent exactly this.

# finding it
df.isna().sum()                      # nulls per column — run this early, always
df.isna().mean().sort_values()       # as a proportion, which is more informative

# removing it
df.dropna()                          # any null in the row → gone. brutal
df.dropna(subset=["hours"])         # only where it matters
df.dropna(thresh=3)                  # keep rows with ≥3 non-null values

# filling it
df["hours"].fillna(df["hours"].median())
df["site"].fillna("unknown")
df.sort_index().ffill()              # carry forward — sort first, or it's nonsense
df.interpolate()                     # numeric only, assumes an ordering

# the habit worth forming: record that it was missing
df["hours_missing"] = df["hours"].isna()
df["hours"] = df["hours"].fillna(df["hours"].median())
Aggregations skip nulls by default, and that default hides things. df["x"].mean() quietly averages whatever happens to be present, so a column that is 90% missing returns a confident-looking number computed from a tenth of the data. Pass skipna=False when you want to be told, and always look at .isna().mean() before trusting any summary statistic.
07Reshaping

Wide and long

The same data can be laid out with one row per entity and many columns, or one row per observation with the variable name in a column. Most analysis wants long; most humans want wide; you will convert between them constantly.

melt goes wide → long · pivot goes long → wide
FunctionDirectionNotes
meltWide → longid_vars stay put, everything else collapses into variable/value pairs
pivotLong → wideRaises if index/column pairs are duplicated. That error is a feature
pivot_tableLong → wideAggregates duplicates instead of complaining. Defaults to mean, which surprises people
stackColumns → indexMoves a column level down into the row MultiIndex
unstackIndex → columnsThe reverse. The usual way to reshape a groupby result
transpose / .TSwap axesFine for display, dangerous for mixed dtypes — everything becomes object
explodeList cells → rowsOne row per element of a list-valued column
crosstabTwo columns → contingency tableCounts by default, and takes normalize=
pivot_table silently averages. If your long data has two rows for the same entity and variable, pivot raises an error and pivot_table returns their mean without comment. The error is the more useful behaviour — reach for pivot first and only switch once you've decided how duplicates should be handled.
08Combining

merge, join, concat

Which rows survive each join type

merge — the general tool

out = left.merge(
    right,
    on="robot_id",          # or left_on / right_on
    how="left",              # inner, left, right, outer, cross
    validate="one_to_one",   # ← use this
    indicator=True,         # adds a _merge column
    suffixes=("_a", "_b"),
)

validate= is the most underused parameter in the library. It raises immediately if the key cardinality isn't what you claimed, instead of letting you discover it three steps later when your row count is wrong.

The row-count explosion. If the key is duplicated on both sides, merge produces every matching pair — two on the left and three on the right gives six rows. This is correct relational behaviour and almost never what you wanted. Check len(df) before and after every merge.
Keys must match in dtype as well as in value. An int64 id will not match a string "123", and neither will match if one side has trailing whitespace. A merge that returns zero rows is nearly always a dtype or whitespace problem rather than genuinely absent data.
# concat: stacking, not joining
pd.concat([df1, df2])                       # rows, aligning on columns
pd.concat([df1, df2], axis=1)               # columns, aligning on index ←!
pd.concat([df1, df2], ignore_index=True)     # drop the old indexes
pd.concat({"a": df1, "b": df2}, names=["src"])  # tag the source

# build a list and concat ONCE — never concat inside a loop
frames = [transform(p) for p in paths]
all_data = pd.concat(frames, ignore_index=True)
09GroupBy

Split, apply, combine

Partition the rows by some key, run a computation within each partition, and reassemble. It's the single most useful operation in the library, and the four ways to finish it do genuinely different things.

FinisherReturnsUse when
.agg()One row per groupSummarizing — means, counts, sums
.transform()Same shape as inputBroadcasting a group statistic back onto every row
.filter()Subset of the original rowsDropping whole groups by a condition on the group
.apply()Whatever you returnLast resort. Flexible, slow, unpredictable in shape
# named aggregation — readable, and names the output columns for you
summary = (df.groupby("site", as_index=False, observed=True)
             .agg(total_hours=("hours", "sum"),
                  mean_uptime=("uptime", "mean"),
                  n_robots=("hours", "size"),
                  worst=("errors", "max")))

# transform: the group's mean, on every row, without a merge
df["site_mean"] = df.groupby("site")["uptime"].transform("mean")
df["vs_site"]   = df["uptime"] - df["site_mean"]

# filter: keep only sites with more than two robots
df.groupby("site").filter(lambda g: len(g) > 2)

# two keys → a MultiIndex result
df.groupby(["site", "shift"])["errors"].sum().unstack(fill_value=0)
transform is the one people don't know they need. Any time you find yourself grouping, aggregating, and then merging the result back onto the original frame — that's a transform, in one line, without the merge and without the risk of a cardinality accident.
Two defaults to set deliberately. dropna=True means rows with a null key vanish from the output entirely. And with categorical keys, observed=False produces a row for every category combination that could exist, which can turn a small result into an enormous one.
10Time series

What a DatetimeIndex unlocks

Time is the case pandas was originally built for — the library came out of finance — and it shows. Put timestamps in the index and a set of operations becomes available that have no equivalent elsewhere.

df = df.set_index(pd.to_datetime(df["ts"], utc=True)).sort_index()

# partial string slicing — reads like what it does
df.loc["2026-03"]                  # all of March
df.loc["2026-03-01":"2026-03-15"]   # inclusive, as label slices always are

# resample: groupby, but the groups are time bins
df.resample("D")["errors"].sum()      # daily totals
df.resample("W").agg({"hours": "sum", "uptime": "mean"})

# rolling and expanding windows
df["uptime_7d"] = df["uptime"].rolling("7D").mean()
df["cumulative"] = df["errors"].expanding().sum()

# shifting — the basis of every lag feature
df["prev"]   = df["errors"].shift(1)
df["change"] = df["errors"].diff()
df["growth"] = df["errors"].pct_change()

# the .dt accessor works on any datetime column, index or not
df["hour"]    = df.index.hour
df["weekday"] = df.index.day_name()
Sort before you shift, roll, or forward-fill. None of these operations check that the index is ordered. On unsorted data they produce plausible numbers computed from the wrong neighbours, and nothing warns you. assert df.index.is_monotonic_increasing costs nothing.
Store UTC, display local. Naive timestamps silently assume something, and the assumption differs between machines. Parse with utc=True, keep everything in UTC through the pipeline, and convert with tz_convert only at the point of display.
11Idioms & speed

Writing pandas that reads well and runs fast

Instead ofWriteWhy
for i, row in df.iterrows()Vectorized column operationsiterrows builds a Series per row and loses dtypes. Often 100× slower
df.apply(f, axis=1)np.where / np.select / arithmeticRow-wise apply is a Python loop wearing a method's clothing
df = df.append(row)Collect in a list, concat onceEach append copies the whole frame. Quadratic time
df["a"][0] = 5df.loc[0, "a"] = 5Chained indexing may write to a temporary copy
Repeated .loc lookupsA single merge or mapOne vectorized join beats a thousand lookups
df[df.a==1][df.b==2]df[(df.a==1) & (df.b==2)]One pass, one intermediate. Note the parentheses — & binds tighter than ==
Deep chains of reassignmentMethod chaining with .assign / .pipeNo intermediate names, no accidental mutation of an earlier frame
# method chaining: one expression, top to bottom, nothing mutated
clean = (
    pd.read_csv("telemetry.csv")
      .rename(columns=str.lower)
      .astype({"site": "category"})
      .assign(
          ts=lambda d: pd.to_datetime(d["ts"], utc=True),
          error_rate=lambda d: d["errors"] / d["hours"],
      )
      .query("hours > 0")
      .sort_values("ts")
      .reset_index(drop=True)
      .pipe(add_custom_features)          # your own function, inline
)
Why .assign takes lambdas. Each lambda receives the frame as it exists at that point in the chain, so you can build a column from one you created two steps earlier. That's what makes long chains possible without intermediate variables.
Know when to leave. pandas is single-threaded and holds everything in memory. Past a few gigabytes, or when you need multiple cores, look at Polars, DuckDB, or Dask. DuckDB in particular queries pandas frames directly with SQL and is often dramatically faster for joins and aggregations.
12Practice

Drills, and the ritual for new data

The first five minutes with any dataset

df.shape                    # how much
df.head(10)                 # what it looks like
df.dtypes                   # any surprise objects?
df.isna().mean()            # how much is missing, proportionally
df.describe(include="all")   # ranges, and impossible values
df.index.is_unique          # can I trust a lookup?
for c in df.select_dtypes(["object", "category"]):
    print(c, df[c].nunique(), df[c].unique()[:5])
#Do thisYou'll know it when
1Build a Series and a DataFrame by hand from dicts and lists, with a custom indexYou can predict the index without printing it
2Work every expression from the hero explorer on your own dataloc versus iloc stops requiring thought
3Deliberately create the NaN-from-misalignment bug, then fix it three waysYou recognize it on sight in someone else's code
4Take a CSV with a broken numeric column and find every unparseable valueAn object dtype becomes a lead rather than a nuisance
5Do one aggregation with agg, transform, and a merge — compare shapes and timingsYou reach for transform automatically
6Merge two frames with duplicate keys and watch the row count explodeYou add validate= without being told
7Convert a table wide → long → wide and confirm you got the original backmelt and pivot are inverses in your head
8Rewrite an iterrows loop as vectorized operations and time bothThe performance gap is visceral rather than theoretical
9Convert a messy script into one method chainYou stop naming intermediate frames df2

The one-paragraph summary

A Series is a labeled one-dimensional array and a DataFrame is a set of Series sharing a single index, which makes the index — not the values — the thing that distinguishes pandas from NumPy. Operations between labeled objects align on those labels and fill the gaps with nulls, which is simultaneously the library's best feature and the source of most unexplained NaNs. Select with .loc for labels and .iloc for positions, remembering that label slices include their endpoint and positional ones do not, and never chain two bracket operations when you intend to assign. Watch the dtypes: an object column usually means a parsing failure, category is nearly free memory, and one null turns an integer column into floats. GroupBy splits, applies and combines — use agg to summarize and transform to broadcast a group statistic back onto every row. Check the row count before and after every merge, pass validate=, and prefer vectorized column operations to any form of row iteration.