Module 5 — pandas: Series, DataFrame and indexing
pandas is the tool a data practitioner spends most of their day in. Conceptually it is simple: a DataFrame is a two-dimensional NumPy array whose rows and columns carry labels. All the power — and the few traps — follow from that idea.
Series and DataFrame: the two objects
A Series is one column: NumPy values plus an index of labels. A DataFrame is a set of Series sharing the same index.
import pandas as pd
df = pd.read_csv("orders.csv", parse_dates=["date"])
df.shape # (12480, 6) — rows, columns
df.dtypes # each column's type — READ THIS SYSTEMATICALLY
df["amount"] # one column → Series
df[["client", "amount"]] # several columns → DataFrame
The first reflex on any unknown file fits in four commands:
df.head() # the first 5 rows — what does the data look like?
df.info() # types, non-nulls, memory — are columns typed correctly?
df.describe() # numeric column statistics — magnitudes, extremes
df["country"].value_counts() # a categorical's distribution — values, typos
These four commands surface most problems before they become bugs: the amount column read as text (because of an "N/A" or a decimal comma), the date still a string, the category spelled three different ways.
loc and iloc: selecting without ambiguity
pandas offers two explicit accessors, and the discipline of using them avoids most confusion:
df.loc[42, "amount"] # by LABELS: index label 42, column "amount"
df.iloc[0, 3] # by POSITIONS: first row, fourth column
df.loc[df["country"] == "Morocco", ["client", "amount"]] # filter + columns
df.iloc[:100] # the first 100 rows
The difference becomes critical as soon as the index is no longer 0, 1, 2…: after a sort or a filter, loc[5] refers to label 5 (which can be anywhere), iloc[5] to the sixth row. Simple rule: loc by default, iloc when you are thinking in positions.
Filtering: boolean masks, like NumPy
The module 4 mechanism applies as is, labels included:
large = df[df["amount"] > 1000]
target = df[(df["amount"] > 1000) & (df["country"] == "Canada")] # mandatory parentheses
recent = df[df["date"] >= "2026-01-01"] # direct date comparisons
europe = df[df["country"].isin(["France", "Belgium", "Switzerland"])]
no_email = df[df["email"].isna()]
isin replaces chains of |; isna()/notna() test for missing values. Proportions read directly: (df["amount"] > 1000).mean().
Creating and transforming columns
df["amount_incl_tax"] = df["amount"] * 1.15 # vectorized
df["year"] = df["date"].dt.year # dates accessor
df["domain"] = df["email"].str.split("@").str[1] # strings accessor
df["segment"] = np.where(df["amount"] > 1000, "premium", "standard")
The .dt (dates) and .str (strings) accessors vectorize operations that would otherwise require a loop. apply with a row-wise function exists, but it is the last resort: a hundred times slower than vectorized operations, justified only for genuinely irreducible logic.
Modifying the result of a filter (sub_df = df[mask] then sub_df["x"] = …) triggers pandas' most famous warning: pandas does not guarantee whether you are modifying a copy or the original table. The two gestures that avoid it: an explicit .copy() when you want an independent subset, and df.loc[mask, "x"] = value when you want to modify the original. It is the direct consequence of the views/references seen in modules 2 and 4.
Sorting, renaming, reindexing
df.sort_values("amount", ascending=False) # simple sort
df.sort_values(["country", "amount"], ascending=[True, False]) # multiple sort
df.rename(columns={"amt": "amount"}) # readable renaming
df.reset_index(drop=True) # index reset to 0..n-1 after filter/sort
An important architectural point: these methods return a new DataFrame rather than modifying the original. The idiomatic style chains the transformations, each step remaining inspectable:
top_clients = (
df[df["date"] >= "2026-01-01"]
.groupby("client")["amount"].sum()
.sort_values(ascending=False)
.head(20)
)
This chain — filter, aggregate, sort, head — is the canonical sentence of pandas analysis; the groupby driving it is the subject of module 7.
Key takeaways
- A DataFrame = a NumPy array + row labels (index) and column labels; a Series = one column.
- On every new file:
head,info,describe,value_counts— four commands that surface wrong types, missing values and dirty categories. locby labels,ilocby positions; boolean masks with parentheses,isin,isna.- New columns through vectorized operations and the
.dt/.straccessors;applyas a last resort. .copy()ordf.loc[mask, col] = …against the SettingWithCopyWarning; chained transformations, each step inspectable.
Next module: systematic cleaning — missing values, duplicates and types — the step that decides the reliability of everything downstream.