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 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
Six ways in, and the rules that separate them
| Accessor | Indexes by | Use for |
|---|---|---|
| df["col"] | Column label | One column → Series. The everyday case |
| df[["a","b"]] | List of labels | Several columns → DataFrame. Note the double brackets |
| df[mask] | Boolean array | Filtering rows |
| df.loc[r, c] | Labels | The general-purpose accessor. Rows and columns, both by name |
| df.iloc[r, c] | Positions | When you genuinely mean "the third one" |
| df.at / df.iat | Label / position | A single cell. Faster than loc/iloc, scalars only |
| df.query("a > 3") | Expression string | Readable multi-condition filters |
# 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
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
# 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.
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.
| dtype | Holds | Notes |
|---|---|---|
| int64 / int32 | Integers | Cannot hold NaN. Adding a null silently converts the column to float |
| float64 / float32 | Reals | NaN lives here. float32 halves memory when precision allows |
| bool | True / False | Also cannot hold nulls — becomes object if you introduce one |
| object | Anything, via Python pointers | The historical home of strings. Slow, memory-hungry, and a common sign of a parsing problem |
| string | Text | A real string dtype with proper null handling. Prefer it over object for text |
| category | Repeated labels | Stores codes plus a lookup. Dramatic memory savings on low-cardinality columns |
| datetime64[ns] | Timestamps | Unlocks .dt accessors, resampling, date slicing |
| timedelta64[ns] | Durations | Produced by subtracting datetimes |
| Int64, Float64, boolean | Nullable versions | Capitalized. Hold real nulls without changing type — worth adopting |
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
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())
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.
| Function | Direction | Notes |
|---|---|---|
| melt | Wide → long | id_vars stay put, everything else collapses into variable/value pairs |
| pivot | Long → wide | Raises if index/column pairs are duplicated. That error is a feature |
| pivot_table | Long → wide | Aggregates duplicates instead of complaining. Defaults to mean, which surprises people |
| stack | Columns → index | Moves a column level down into the row MultiIndex |
| unstack | Index → columns | The reverse. The usual way to reshape a groupby result |
| transpose / .T | Swap axes | Fine for display, dangerous for mixed dtypes — everything becomes object |
| explode | List cells → rows | One row per element of a list-valued column |
| crosstab | Two columns → contingency table | Counts by default, and takes normalize= |
merge, join, concat
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.
# 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)
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.
| Finisher | Returns | Use when |
|---|---|---|
| .agg() | One row per group | Summarizing — means, counts, sums |
| .transform() | Same shape as input | Broadcasting a group statistic back onto every row |
| .filter() | Subset of the original rows | Dropping whole groups by a condition on the group |
| .apply() | Whatever you return | Last 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)
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()
Writing pandas that reads well and runs fast
| Instead of | Write | Why |
|---|---|---|
| for i, row in df.iterrows() | Vectorized column operations | iterrows builds a Series per row and loses dtypes. Often 100× slower |
| df.apply(f, axis=1) | np.where / np.select / arithmetic | Row-wise apply is a Python loop wearing a method's clothing |
| df = df.append(row) | Collect in a list, concat once | Each append copies the whole frame. Quadratic time |
| df["a"][0] = 5 | df.loc[0, "a"] = 5 | Chained indexing may write to a temporary copy |
| Repeated .loc lookups | A single merge or map | One 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 reassignment | Method chaining with .assign / .pipe | No 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 )
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 this | You'll know it when |
|---|---|---|
| 1 | Build a Series and a DataFrame by hand from dicts and lists, with a custom index | You can predict the index without printing it |
| 2 | Work every expression from the hero explorer on your own data | loc versus iloc stops requiring thought |
| 3 | Deliberately create the NaN-from-misalignment bug, then fix it three ways | You recognize it on sight in someone else's code |
| 4 | Take a CSV with a broken numeric column and find every unparseable value | An object dtype becomes a lead rather than a nuisance |
| 5 | Do one aggregation with agg, transform, and a merge — compare shapes and timings | You reach for transform automatically |
| 6 | Merge two frames with duplicate keys and watch the row count explode | You add validate= without being told |
| 7 | Convert a table wide → long → wide and confirm you got the original back | melt and pivot are inverses in your head |
| 8 | Rewrite an iterrows loop as vectorized operations and time both | The performance gap is visceral rather than theoretical |
| 9 | Convert a messy script into one method chain | You 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.