Pandas — Python Data Analysis Library
Pandas (from "panel data") provides fast, flexible labelled data structures built on NumPy. It is the single most-used tool in a data analyst's day.
The Two Core Data Structures
| Structure | Dimensions | Description |
|---|---|---|
| Series | 1-D | A labelled array — like a single column with an index |
| DataFrame | 2-D | A labelled table — rows and named columns, like a spreadsheet or SQL table |
Series
import pandas as pd
import numpy as np
# From a list — automatic integer index
s1 = pd.Series([10, 20, 30, 40])
print(s1)
# 0 10
# 1 20
# 2 30
# 3 40
# dtype: int64
# With a custom index
s2 = pd.Series([88, 76, 91, 54], index=["Riya", "Zoya", "Kabir", "Aarav"], name="marks")
print(s2)
print(s2["Kabir"]) # 91 label-based access
print(s2.iloc[2]) # 91 position-based access
print(s2[s2 > 70]) # boolean filter
# From a dictionary — keys become the index
s3 = pd.Series({"Delhi": 32000, "Noida": 28000, "Gurgaon": 35000})
print(s2.values) # underlying NumPy array
print(s2.index) # the index object
print(s2.mean(), s2.max(), s2.std().round(2))
DataFrame
# From a dictionary of lists — the most common construction
df = pd.DataFrame({
"name": ["Riya", "Zoya", "Kabir", "Aarav", "Meera"],
"age": [19, 21, 20, 22, 20],
"marks": [88, 76, 91, 54, 79],
"city": ["Delhi", "Noida", "Delhi", "Gurgaon", "Noida"],
"attendance": [92, 78, 95, 61, 85],
})
print(df)
# From a list of dictionaries (e.g. an API response)
records = [{"name": "Riya", "marks": 88}, {"name": "Zoya", "marks": 76}]
print(pd.DataFrame(records))
# From a NumPy array
print(pd.DataFrame(np.random.randn(3, 3), columns=["A", "B", "C"]))
Inspecting a DataFrame
print(df.head(3)) # first 3 rows
print(df.tail(2)) # last 2 rows
print(df.shape) # (5, 5)
print(df.columns.tolist()) # column names
print(df.index) # row index
print(df.dtypes) # data type of each column
print(df.info()) # dtypes + non-null counts + memory
print(df.describe()) # summary statistics for numeric columns
print(df.describe(include="all")) # including categorical columns
print(df.nunique()) # distinct values per column
print(df.memory_usage(deep=True))
Selecting Data
# COLUMNS
print(df["marks"]) # a Series
print(df[["name", "marks"]]) # a DataFrame (note the double brackets)
print(df.marks) # attribute access (only for valid identifiers)
# ROWS — .loc (label-based) and .iloc (position-based)
print(df.loc[0]) # row with index label 0
print(df.iloc[0]) # first row by position
print(df.loc[1:3]) # labels 1,2,3 — INCLUSIVE of the end
print(df.iloc[1:3]) # positions 1,2 — EXCLUSIVE of the end
# BOTH rows and columns
print(df.loc[0:2, ["name", "marks"]])
print(df.iloc[0:2, 1:3])
print(df.at[0, "marks"]) # fast scalar access by label
print(df.iat[0, 2]) # fast scalar access by position
.loc vs .iloc is the most common Pandas exam question..locuses labels and its slice end is inclusive;.ilocuses integer positions and its slice end is exclusive (like normal Python slicing).
Filtering
print(df[df["marks"] > 80])
print(df[(df["marks"] > 75) & (df["age"] < 21)]) # AND — use &, not 'and'
print(df[(df["city"] == "Delhi") | (df["marks"] > 85)]) # OR — use |
print(df[~(df["city"] == "Delhi")]) # NOT — use ~
print(df[df["city"].isin(["Delhi", "Noida"])])
print(df[df["name"].str.startswith("R")])
print(df[df["marks"].between(70, 90)])
# query() — SQL-like readable syntax
print(df.query("marks > 75 and age < 21"))
print(df.query("city in ['Delhi', 'Noida']"))
Adding, Modifying and Removing
df2 = df.copy()
# New columns
df2["percentage"] = df2["marks"] # simple copy
df2["grade"] = pd.cut(df2["marks"], bins=[0, 60, 75, 85, 100],
labels=["D", "C", "B", "A"])
df2["passed"] = df2["marks"] >= 60
df2["score_index"] = (df2["marks"] * 0.7 + df2["attendance"] * 0.3).round(2)
# assign() — chainable
df2 = df2.assign(age_group=lambda d: np.where(d["age"] >= 21, "Senior", "Junior"))
# Modify values
df2.loc[df2["marks"] < 60, "remark"] = "Needs improvement"
df2["city"] = df2["city"].replace({"Gurgaon": "Gurugram"})
# Remove
df2 = df2.drop(columns=["percentage"])
df2 = df2.drop(index=[4])
print(df2)
# Rename
df2 = df2.rename(columns={"marks": "total_marks", "attendance": "attendance_pct"})
print(df2.columns.tolist())
Sorting
print(df.sort_values("marks", ascending=False))
print(df.sort_values(["city", "marks"], ascending=[True, False]))
print(df.sort_index())
print(df.nlargest(3, "marks")) # top 3 — faster than sort + head
print(df.nsmallest(2, "marks"))
Handling Missing Data
messy = pd.DataFrame({
"name": ["Riya", "Zoya", "Kabir", "Aarav"],
"marks": [88, np.nan, 91, 54],
"city": ["Delhi", "Noida", None, "Delhi"],
})
print(messy.isnull()) # element-wise boolean mask
print(messy.isnull().sum()) # count per column
print(messy.notnull().sum())
print(messy.dropna()) # drop rows with ANY missing value
print(messy.dropna(subset=["marks"])) # only if 'marks' is missing
print(messy.dropna(axis=1)) # drop COLUMNS with missing values
print(messy.dropna(thresh=2)) # keep rows with at least 2 non-null values
print(messy.fillna({"marks": messy["marks"].median(), "city": "Unknown"}))
print(messy.ffill()) # forward fill
print(messy["marks"].interpolate()) # linear interpolation
GroupBy — Split-Apply-Combine
sales = pd.DataFrame({
"region": ["North","South","North","East","South","East","North","South"],
"product": ["A","A","B","B","B","A","A","B"],
"revenue": [1200, 950, 1400, 800, 1100, 700, 1350, 1050],
"units": [30, 25, 35, 20, 28, 18, 34, 27],
})
print(sales.groupby("region")["revenue"].sum())
print(sales.groupby("region")["revenue"].agg(["count", "sum", "mean", "max"]).round(2))
# Multiple keys
print(sales.groupby(["region", "product"])["revenue"].sum())
# Different aggregations per column
print(sales.groupby("region").agg(
total_revenue=("revenue", "sum"),
avg_revenue=("revenue", "mean"),
total_units=("units", "sum"),
n_orders=("revenue", "count"),
).round(2))
# transform() — result has the SAME shape as the input (great for feature engineering)
sales["region_avg"] = sales.groupby("region")["revenue"].transform("mean").round(2)
sales["pct_of_region"] = (sales["revenue"] / sales["region_avg"] * 100).round(1)
print(sales)
# filter() — keep only groups meeting a condition
print(sales.groupby("region").filter(lambda g: g["revenue"].sum() > 2500))
Pivot Tables and Cross-Tabs
print(sales.pivot_table(values="revenue", index="region", columns="product",
aggfunc="sum", margins=True, fill_value=0))
# A B All
# region
# East 700 800 1500
# North 2550 1400 3950
# South 950 2150 3100
# All 4200 4350 8550
print(pd.crosstab(sales["region"], sales["product"]))
print(pd.crosstab(sales["region"], sales["product"], normalize="index").round(3))
# melt() — wide to long format (the inverse of pivot)
wide = sales.pivot_table(values="revenue", index="region", columns="product", aggfunc="sum")
print(wide.reset_index().melt(id_vars="region", var_name="product", value_name="revenue"))
Merging and Joining
students = pd.DataFrame({"roll": [1, 2, 3, 4], "name": ["Riya","Zoya","Kabir","Aarav"]})
scores = pd.DataFrame({"roll": [1, 2, 3, 5], "marks": [88, 76, 91, 65]})
print(pd.merge(students, scores, on="roll", how="inner")) # only matching rolls
print(pd.merge(students, scores, on="roll", how="left")) # all students
print(pd.merge(students, scores, on="roll", how="right")) # all scores
print(pd.merge(students, scores, on="roll", how="outer")) # everything
# concat — stacking
print(pd.concat([students.head(2), students.tail(2)])) # rows
print(pd.concat([students, scores.drop(columns="roll")], axis=1)) # columns
| Join type | Keeps |
|---|---|
| inner | Only keys present in both DataFrames |
| left | All rows from the left; NaN where the right has no match |
| right | All rows from the right |
| outer | All rows from both; NaN wherever either side is missing |
Applying Functions
df3 = df.copy()
df3["marks_scaled"] = df3["marks"].apply(lambda x: x / 100) # element-wise
df3["name_upper"] = df3["name"].str.upper() # vectorised string
df3["initials"] = df3["name"].str[0]
# Row-wise apply (axis=1) — flexible but slow; prefer vectorised operations
df3["summary"] = df3.apply(lambda r: f"{r['name']} ({r['city']}): {r['marks']}", axis=1)
# map() on a Series with a dictionary
df3["city_tier"] = df3["city"].map({"Delhi": 1, "Noida": 2, "Gurgaon": 2})
print(df3[["name", "marks_scaled", "initials", "city_tier", "summary"]])
Working with Dates
ts = pd.DataFrame({
"date": pd.date_range("2026-01-01", periods=90, freq="D"),
"sales": np.random.randint(800, 2200, 90),
})
ts["year"] = ts["date"].dt.year
ts["month"] = ts["date"].dt.month
ts["weekday"] = ts["date"].dt.day_name()
ts["is_weekend"] = ts["date"].dt.dayofweek >= 5
ts["quarter"] = ts["date"].dt.quarter
print(ts.groupby("weekday")["sales"].mean().round(0))
print(ts.set_index("date").resample("ME")["sales"].sum()) # monthly totals
print(ts.set_index("date")["sales"].rolling(7).mean().tail()) # 7-day moving average
String Operations
text_df = pd.DataFrame({"email": ["Riya@Gmail.com ", " zoya@YAHOO.in", "kabir@outlook.com"]})
text_df["clean"] = text_df["email"].str.strip().str.lower()
text_df["username"] = text_df["clean"].str.split("@").str[0]
text_df["domain"] = text_df["clean"].str.split("@").str[1]
text_df["provider"] = text_df["domain"].str.extract(r"^([a-z]+)\.")
text_df["is_gmail"] = text_df["clean"].str.contains("gmail")
print(text_df)
The next lesson covers getting data into and out of Pandas — the import/export step of every real project.