Pandas Masterclass - From Basics to Advanced

Prerequisites: Python basics, NumPy fundamentals
Install: pip install pandas openpyxl xlrd
Import convention: import pandas as pd


Table of Contents

  1. What is Pandas and Why Use It?
  2. Series - The 1D Structure
  3. DataFrame - The 2D Structure
  4. Loading and Saving Data
  5. Exploring Your Data
  6. Selecting and Filtering
  7. Adding and Modifying Columns
  8. Sorting
  9. Cleaning Data - Handling Missing Values
  10. Cleaning Data - Duplicates and Types
  11. String Operations
  12. Datetime Operations
  13. Groupby and Aggregation
  14. Merging, Joining and Concatenating
  15. Pivot Tables and Reshaping
  16. Apply, Map and Lambda Functions
  17. Plotting with Pandas
  18. Performance Tips

1. What is Pandas and Why Use It?

Pandas provides two core data structures - Series (1D) and DataFrame (2D) - that make working with labeled, tabular data as natural as working with a spreadsheet, but with the full power of Python.

What Pandas gives you

  • Read data from CSV, Excel, JSON, SQL, HTML, and more
  • Clean and transform messy real-world data
  • Filter, group, aggregate, join datasets
  • Time series analysis
  • Integrates seamlessly with NumPy, Matplotlib, and Scikit-learn

Setup

import pandas as pd
import numpy as np

# Check version
print(pd.__version__)

2. Series - The 1D Structure

A Series is a one-dimensional labeled array. Think of it as a column with an index.

Creating a Series

# From a list  -  index defaults to 0, 1, 2, ...
s = pd.Series([10, 20, 30, 40, 50])
print(s)
# 0    10
# 1    20
# 2    30
# 3    40
# 4    50
# dtype: int64

# With a custom index
s = pd.Series([10, 20, 30], index=['a', 'b', 'c'])
print(s)
# a    10
# b    20
# c    30

# From a dictionary (keys become index)
s = pd.Series({'python': 90, 'java': 75, 'c++': 60})
print(s)
# python    90
# java      75
# c++       60

# From a scalar (fills all positions)
s = pd.Series(42, index=['x', 'y', 'z'])
# x    42
# y    42
# z    42

Accessing Series values

s = pd.Series([10, 20, 30, 40], index=['a', 'b', 'c', 'd'])

# By label (index name)
s['a']        # 10
s[['a', 'c']] # returns Series with 'a' and 'c'

# By position
s.iloc[0]     # 10
s.iloc[-1]    # 40
s.iloc[1:3]   # 'b' and 'c'

# Attribute access (if index is a valid Python identifier)
s.a           # 10

Series operations

s = pd.Series([1, 2, 3, 4, 5])

# All NumPy-style operations work
s * 2          # 2 4 6 8 10
s + 10         # 11 12 13 14 15
s ** 2         # 1 4 9 16 25
s[s > 3]       # 4, 5

# Alignment on index  -  missing = NaN
s1 = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
s2 = pd.Series([10, 20, 30], index=['b', 'c', 'd'])
s1 + s2        # a=NaN, b=22, c=53, d=NaN

# Statistics
s.sum()        # 15
s.mean()       # 3.0
s.std()        # 1.58
s.min()        # 1
s.max()        # 5
s.describe()   # summary stats

# Value operations
s.unique()          # array of unique values
s.nunique()         # count of unique values
s.value_counts()    # frequency of each value
s.sort_values()     # sort by value
s.sort_index()      # sort by index

3. DataFrame - The 2D Structure

A DataFrame is a 2D table - rows and columns, each column being a Series sharing the same index.

Creating a DataFrame

# From a dictionary (most common)
df = pd.DataFrame({
    'name':   ['Ramesh', 'Mahesh', 'Suresh', 'Devesh', 'Rajesh'],
    'age':    [25, 30, 35, 28, 22],
    'salary': [55000, 72000, 85000, 61000, 48000],
    'dept':   ['Eng', 'Mkt', 'Eng', 'Mkt', 'HR'],
    'active': [True, True, True, False, True]
})

# From a list of dicts (each dict = one row)
df = pd.DataFrame([
    {'name': 'Ramesh', 'age': 25, 'city': 'NYC'},
    {'name': 'Mahesh',   'age': 30, 'city': 'LA'},
    {'name': 'Suresh', 'age': 35},   # missing 'city' → NaN
])

# From a NumPy array
arr = np.random.randint(0, 100, (5, 3))
df = pd.DataFrame(arr, columns=['A', 'B', 'C'])

# From a CSV (covered in section 4)
df = pd.read_csv('file.csv')

# Empty DataFrame with defined columns
df = pd.DataFrame(columns=['name', 'age', 'salary'])

DataFrame structure

df = pd.DataFrame({
    'name':   ['Ramesh', 'Mahesh', 'Suresh'],
    'age':    [25, 30, 35],
    'salary': [55000, 72000, 85000]
})

df.columns    # Index(['name', 'age', 'salary'])
df.index      # RangeIndex(start=0, stop=3, step=1)
df.dtypes     # name: object, age: int64, salary: int64
df.values     # NumPy array of all values
df.shape      # (3, 3)
df.size       # 9  (total elements)
df.ndim       # 2

4. Loading and Saving Data

Reading files

# CSV  -  most common
df = pd.read_csv('data.csv')

# With options
df = pd.read_csv('data.csv',
    sep=';',                      # different separator (e.g., semicolon)
    header=0,                     # row number to use as column names
    index_col='id',               # use this column as the index
    usecols=['name', 'age'],      # only load specific columns
    nrows=100,                    # only load first 100 rows
    skiprows=[1, 2],              # skip specific rows
    dtype={'age': int, 'salary': float},   # force column types
    parse_dates=['date', 'created_at'],    # auto-parse as datetime
    na_values=['?', 'N/A', 'none'],        # treat as NaN
    encoding='utf-8',             # file encoding
    thousands=',',                # '1,000' → 1000
    decimal='.',                  # decimal separator
)

# Excel
df = pd.read_excel('data.xlsx')
df = pd.read_excel('data.xlsx', sheet_name='Sheet2')
df = pd.read_excel('data.xlsx', sheet_name=0)   # first sheet

# JSON
df = pd.read_json('data.json')

# From clipboard (great for pasting from spreadsheets!)
df = pd.read_clipboard()

# SQL
import sqlite3
conn = sqlite3.connect('database.db')
df = pd.read_sql('SELECT * FROM employees', conn)

# HTML table on a webpage
tables = pd.read_html('https://example.com/table-page')
df = tables[0]   # first table found

# From a URL directly
df = pd.read_csv('https://raw.githubusercontent.com/..../data.csv')

# Parquet (fast columnar format)
df = pd.read_parquet('data.parquet')

Saving files

# CSV
df.to_csv('output.csv', index=False)         # index=False avoids saving the row numbers
df.to_csv('output.csv', sep='\t')            # tab-separated
df.to_csv('output.csv', columns=['name', 'age'])  # only these columns

# Excel
df.to_excel('output.xlsx', index=False)
df.to_excel('output.xlsx', sheet_name='Results', index=False)

# Multiple sheets
with pd.ExcelWriter('output.xlsx') as writer:
    df1.to_excel(writer, sheet_name='Sheet1', index=False)
    df2.to_excel(writer, sheet_name='Sheet2', index=False)

# JSON
df.to_json('output.json', orient='records')  # list of records

# Parquet
df.to_parquet('output.parquet', index=False)

# SQL
df.to_sql('table_name', conn, if_exists='replace', index=False)

5. Exploring Your Data

Always do these steps after loading new data.

df = pd.read_csv('employees.csv')

# --- Shape and size ---
df.shape              # (rows, cols)  e.g. (1000, 8)
len(df)               # number of rows
df.columns.tolist()   # list of column names

# --- First/last rows ---
df.head()             # first 5 rows (default)
df.head(10)           # first 10 rows
df.tail(3)            # last 3 rows
df.sample(5)          # 5 random rows
df.sample(frac=0.1)   # 10% random sample

# --- Data types and memory ---
df.info()
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 1000 entries, 0 to 999
# Data columns (total 4 columns):
#  #  Column   Non-Null Count  Dtype
# ---  ------   --------------  -----
#  0   name     1000 non-null   object
#  1   age      980 non-null    float64  ← 20 missing values!
#  2   salary   1000 non-null   int64
#  3   dept     1000 non-null   object

df.dtypes             # column data types
df.memory_usage(deep=True)  # memory per column

# --- Statistics ---
df.describe()
# count, mean, std, min, 25%, 50%, 75%, max for numeric cols

df.describe(include='all')           # include non-numeric
df.describe(include=['object'])      # only string columns
df['salary'].describe()              # for one column

# --- Missing data ---
df.isnull().sum()          # count NaN per column
df.isnull().mean() * 100   # % missing per column
df.isnull().any(axis=1)    # True for rows with any NaN
df.notnull().all(axis=1)   # True for complete rows

# --- Unique values ---
df['dept'].unique()           # array of unique values
df['dept'].nunique()          # count of unique values
df['dept'].value_counts()     # frequency table (sorted)
df['dept'].value_counts(normalize=True)  # as proportions
df['dept'].value_counts(dropna=False)    # include NaN count

# --- Correlations ---
df.corr()                          # correlation matrix (numeric cols)
df['salary'].corr(df['age'])       # correlation between two cols

6. Selecting and Filtering

Column selection

# Single column → returns Series
df['name']
df.name           # dot notation (only works if name is a valid Python identifier)

# Multiple columns → returns DataFrame
df[['name', 'salary']]
df[['name', 'age', 'dept']]

Row selection with .loc (label-based)

# .loc[row_selector, col_selector]
# row_selector: index label, list, slice, or boolean array
# col_selector: column name, list, or slice

df.loc[0]                     # row with index label 0
df.loc[2]                     # row with index label 2
df.loc[[0, 2, 4]]             # rows with labels 0, 2, 4
df.loc[0:3]                   # rows 0 THROUGH 3 (INCLUSIVE, unlike Python!)
df.loc[0, 'name']             # single value
df.loc[0, ['name', 'age']]    # row 0, specific columns
df.loc[:, 'age']              # all rows, 'age' column
df.loc[:, 'age':'salary']     # all rows, columns age through salary
df.loc[0:2, 'name':'dept']    # rows 0-2, columns name through dept

Row selection with .iloc (position-based)

# .iloc[row_pos, col_pos]   -  always uses integers, end is EXCLUSIVE

df.iloc[0]           # first row
df.iloc[-1]          # last row
df.iloc[[0, 2, 4]]   # rows at positions 0, 2, 4
df.iloc[0:3]         # rows 0, 1, 2  (3 is EXCLUDED)
df.iloc[0, 1]        # row 0, col 1
df.iloc[:, 0]        # all rows, first column
df.iloc[0:3, 1:4]    # rows 0-2, cols 1-3
df.iloc[-5:]         # last 5 rows

loc vs iloc summary:
.loc uses labels (index values, column names) - stop is inclusive
.iloc uses integers (positions 0, 1, 2…) - stop is exclusive

Boolean filtering

# Condition creates a boolean Series
df[df['salary'] > 60000]
df[df['dept'] == 'Eng']
df[df['active'] == True]
df[df['name'] != 'Bob']

# Multiple conditions  -  use & (and), | (or), ~ (not)
# MUST use parentheses around each condition!
df[(df['salary'] > 60000) & (df['dept'] == 'Eng')]
df[(df['age'] < 30) | (df['salary'] > 80000)]
df[~(df['dept'] == 'HR')]    # all except HR

# .query()  -  cleaner syntax for complex filters
df.query('salary > 60000')
df.query('dept == "Eng"')
df.query('age > 25 and salary > 60000')
df.query('dept in ["Eng", "HR"]')
df.query('salary > @threshold')    # reference a Python variable with @

# .isin()  -  check membership
df[df['dept'].isin(['Eng', 'Mkt'])]
df[~df['dept'].isin(['HR'])]       # exclude HR

# .between()  -  range filter (inclusive)
df[df['age'].between(25, 35)]
df[df['salary'].between(50000, 80000)]

# String methods in filters
df[df['name'].str.startswith('A')]
df[df['name'].str.contains('ali', case=False)]

7. Adding and Modifying Columns

df = pd.DataFrame({
    'name':   ['Alice', 'Bob', 'Carol'],
    'salary': [55000, 72000, 85000],
    'age':    [25, 30, 35]
})

# Add a new column
df['bonus'] = df['salary'] * 0.10
df['total'] = df['salary'] + df['bonus']
df['senior'] = df['age'] >= 30              # boolean column
df['constant'] = 100                         # scalar broadcasts

# Modify existing column
df['salary'] = df['salary'] * 1.05          # 5% raise
df['name'] = df['name'].str.upper()

# Conditional column with np.where
df['level'] = np.where(df['salary'] > 70000, 'Senior', 'Junior')

# Multiple conditions with np.select
conditions = [
    df['salary'] < 60000,
    df['salary'].between(60000, 80000),
    df['salary'] > 80000
]
choices = ['Junior', 'Mid', 'Senior']
df['level'] = np.select(conditions, choices, default='Unknown')

# rename columns
df.rename(columns={'name': 'full_name', 'salary': 'annual_salary'}, inplace=True)

# rename all columns at once
df.columns = ['full_name', 'annual_salary', 'age', 'bonus', 'total', 'senior', 'constant', 'level']

# drop columns
df.drop(columns=['constant', 'bonus'], inplace=True)
# or equivalently:
df = df.drop(columns=['constant'])

# reorder columns
df = df[['name', 'dept', 'salary', 'age']]   # select only these, in this order

# insert column at specific position
df.insert(2, 'city', ['NYC', 'LA', 'Chicago'])

8. Sorting

df = pd.DataFrame({
    'name':   ['Ramesh', 'Mahesh', 'Suresh', 'Devesh'],
    'age':    [25, 30, 25, 28],
    'salary': [55000, 72000, 85000, 61000],
    'dept':   ['Eng', 'Mkt', 'Eng', 'Mkt']
})

# Sort by a single column
df.sort_values('salary')                      # ascending (default)
df.sort_values('salary', ascending=False)     # descending

# Sort by multiple columns
df.sort_values(['dept', 'salary'])            # by dept, then salary within dept
df.sort_values(['dept', 'salary'],
               ascending=[True, False])       # dept asc, salary desc

# Sort by index
df.sort_index()
df.sort_index(ascending=False)

# inplace vs returning new
df.sort_values('salary', inplace=True)        # modifies df
new_df = df.sort_values('salary')             # returns new sorted df

# nlargest / nsmallest  -  get top/bottom N rows
df.nlargest(3, 'salary')        # top 3 by salary
df.nsmallest(2, 'age')          # bottom 2 by age
df.nlargest(3, ['salary', 'age'])  # sort by multiple cols

# reset index after sorting
df.sort_values('salary').reset_index(drop=True)

9. Cleaning Data - Handling Missing Values

Missing data in Pandas is represented as NaN (Not a Number) for numeric columns, or None/NaN for objects.

Detecting missing values

df.isnull()              # boolean DataFrame, True where NaN
df.notnull()             # opposite
df.isnull().sum()        # count NaN per column
df.isnull().sum(axis=1)  # count NaN per row
df.isnull().any()        # True if column has ANY NaN
df.isnull().all()        # True if column is ALL NaN

# Find rows with any missing data
df[df.isnull().any(axis=1)]

# Percentage missing
(df.isnull().sum() / len(df)) * 100

Dropping missing values

# Drop rows with ANY NaN
df.dropna()

# Drop rows where a SPECIFIC column is NaN
df.dropna(subset=['age'])
df.dropna(subset=['age', 'salary'])   # drop if EITHER is NaN

# Drop rows only if ALL values are NaN
df.dropna(how='all')

# Keep rows with at least N non-NaN values
df.dropna(thresh=3)   # keep row only if >= 3 non-null values

# Drop COLUMNS with any NaN
df.dropna(axis=1)

# inplace
df.dropna(inplace=True)

Filling missing values

# Fill all NaN with a single value
df.fillna(0)
df.fillna('unknown')
df.fillna(False)

# Fill specific columns differently
df.fillna({'age': 0, 'salary': df['salary'].mean(), 'name': 'Unknown'})

# Fill with column statistics
df['age'].fillna(df['age'].mean())
df['age'].fillna(df['age'].median())
df['dept'].fillna(df['dept'].mode()[0])   # most common value

# Forward fill  -  propagate last valid value forward
df.fillna(method='ffill')    # fill NaN with the value above it
df['salary'].ffill()         # forward fill a column

# Backward fill  -  propagate next valid value backward
df.fillna(method='bfill')

# Interpolate  -  for time series / sequential data
df['temperature'].interpolate()           # linear interpolation
df['temperature'].interpolate(method='polynomial', order=2)

# Limit  -  only fill a certain number of consecutive NaNs
df.fillna(method='ffill', limit=2)

10. Cleaning Data - Duplicates and Types

Handling duplicates

# Find duplicate rows
df.duplicated()                     # True for duplicate rows
df.duplicated(subset=['name'])      # duplicated based on 'name' only
df.duplicated(keep='first')         # keep=first: marks all but first as dup
df.duplicated(keep='last')          # keep=last: marks all but last
df.duplicated(keep=False)           # marks ALL copies as duplicates

# Count duplicates
df.duplicated().sum()

# View duplicated rows
df[df.duplicated()]

# Drop duplicates
df.drop_duplicates()                     # drop all duplicate rows
df.drop_duplicates(subset=['name'])      # drop based on 'name' column
df.drop_duplicates(subset=['name', 'dept'])
df.drop_duplicates(keep='last')          # keep the last occurrence
df.drop_duplicates(inplace=True)

Type conversion

df.dtypes   # inspect current types

# Convert to numeric
df['salary'] = df['salary'].astype(int)
df['salary'] = df['salary'].astype(float)
df['price'] = pd.to_numeric(df['price'])
df['price'] = pd.to_numeric(df['price'], errors='coerce')  # NaN on failure

# Convert to string
df['code'] = df['code'].astype(str)

# Convert to boolean
df['active'] = df['active'].astype(bool)

# Convert to datetime
df['date'] = pd.to_datetime(df['date'])
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d')
df['date'] = pd.to_datetime(df['date'], errors='coerce')   # NaN on failure

# Convert to category (saves memory for low-cardinality string cols)
df['dept'] = df['dept'].astype('category')
df['dept'].cat.categories    # view categories
df['dept'].cat.codes         # numeric code for each category

# Replace values
df.replace('N/A', np.nan)
df.replace({'dept': {'Eng': 'Engineering', 'Mkt': 'Marketing'}})
df['salary'].replace(0, np.nan)

11. String Operations

Access string methods via the .str accessor on a Series.

s = pd.Series(['Alice Smith', '  Bob Jones  ', 'carol Brown', 'DAVE WILSON'])

# Case
s.str.upper()             # 'ALICE SMITH', ...
s.str.lower()             # 'alice smith', ...
s.str.title()             # 'Alice Smith', ...

# Strip whitespace
s.str.strip()             # remove leading/trailing spaces
s.str.lstrip()            # remove leading only
s.str.rstrip()            # remove trailing only

# Check contents (returns boolean Series)
s.str.contains('Smith')                  # case-sensitive
s.str.contains('smith', case=False)      # case-insensitive
s.str.contains(r'\d+', regex=True)       # regex: contains digits
s.str.startswith('A')
s.str.endswith('n')
s.str.isdigit()
s.str.isalpha()
s.str.isnumeric()

# Extract and modify
s.str.replace('Smith', 'S.')
s.str.replace(r'\s+', ' ', regex=True)       # collapse multiple spaces
s.str.split(' ')                              # list of words
s.str.split(' ', expand=True)                # split into separate columns
s.str.split(' ').str[0]                      # first word only
s.str.get(0)                                 # first character

# Length and padding
s.str.len()
s.str.pad(width=20, side='right', fillchar='-')
s.str.zfill(5)                               # zero-pad (for numbers as strings)

# Slicing
s.str[0:5]              # first 5 characters
s.str[-3:]              # last 3 characters

# Extract with regex
emails = pd.Series(['alice@example.com', 'bob@gmail.com'])
emails.str.extract(r'(\w+)@(\w+\.\w+)')     # extract groups
emails.str.extractall(r'(\w+)')              # extract all matches
emails.str.findall(r'\w+')                   # find all matches

# Count occurrences
s.str.count('l')         # count of 'l' in each string

# Join
parts = pd.Series([['hello', 'world'], ['foo', 'bar']])
parts.str.join('-')      # 'hello-world', 'foo-bar'

12. Datetime Operations

# Parse dates
df['date'] = pd.to_datetime(df['date'])

# Create date range
pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')   # daily
pd.date_range(start='2024-01-01', periods=12, freq='ME')         # monthly end
pd.date_range(start='2024-01-01', periods=52, freq='W')          # weekly

# Access components via .dt accessor
df['date'].dt.year
df['date'].dt.month
df['date'].dt.day
df['date'].dt.hour
df['date'].dt.minute
df['date'].dt.second
df['date'].dt.day_of_week    # 0=Monday, 6=Sunday
df['date'].dt.day_name()     # 'Monday', 'Tuesday', ...
df['date'].dt.month_name()   # 'January', ...
df['date'].dt.quarter        # 1, 2, 3, or 4
df['date'].dt.is_month_end   # boolean
df['date'].dt.is_month_start

# Date arithmetic
df['date'] + pd.Timedelta(days=7)      # add 7 days
df['date'] - pd.Timedelta(weeks=1)     # subtract 1 week
df['end_date'] - df['start_date']      # timedelta between dates

# Time offsets
df['date'] + pd.DateOffset(months=1)   # add 1 month
df['date'] + pd.DateOffset(years=1)    # add 1 year

# Filtering by date
df[df['date'] > '2024-06-01']
df[df['date'].between('2024-01-01', '2024-06-30')]
df[df['date'].dt.year == 2024]
df[df['date'].dt.month == 3]           # all March rows

# Resample  -  aggregate by time period (requires datetime index)
df = df.set_index('date')
df.resample('ME').sum()         # monthly totals
df.resample('W').mean()         # weekly averages
df.resample('Q').max()          # quarterly max
df.resample('h').count()        # hourly counts

# Rolling window
df['sales'].rolling(7).mean()   # 7-day moving average
df['sales'].rolling(30).sum()   # 30-day rolling total
df['sales'].expanding().mean()  # cumulative mean

13. Groupby and Aggregation

groupby is one of Pandas’ most powerful features. The pattern is: split → apply → combine.

Basic groupby

df = pd.DataFrame({
    'name':   ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'],
    'dept':   ['Eng', 'Mkt', 'Eng', 'Mkt', 'HR', 'Eng'],
    'city':   ['NYC', 'NYC', 'LA', 'LA', 'NYC', 'LA'],
    'salary': [80000, 72000, 95000, 61000, 48000, 88000],
    'age':    [25, 30, 35, 28, 22, 32]
})

# Group by one column, apply one aggregation
df.groupby('dept')['salary'].mean()
# dept
# Eng    87666.67
# HR     48000.00
# Mkt    66500.00

df.groupby('dept')['salary'].sum()
df.groupby('dept')['salary'].max()
df.groupby('dept')['salary'].count()
df.groupby('dept')['salary'].std()

# Multiple columns
df.groupby('dept')[['salary', 'age']].mean()
df.groupby('dept').size()       # number of rows per group

Multiple aggregations with .agg()

# Multiple aggregations on one column
df.groupby('dept')['salary'].agg(['mean', 'min', 'max', 'count'])

# Different aggregations per column
df.groupby('dept').agg({
    'salary': ['mean', 'max', 'std'],
    'age':    ['mean', 'min', 'max'],
    'name':   'count'
})

# Custom aggregations with lambda
df.groupby('dept')['salary'].agg(
    avg_salary='mean',
    highest='max',
    range=lambda x: x.max() - x.min()
)

# Named aggregations (cleaner column names)
df.groupby('dept').agg(
    avg_salary=('salary', 'mean'),
    max_age=('age', 'max'),
    headcount=('name', 'count')
)

Group by multiple columns

df.groupby(['dept', 'city'])['salary'].mean()
df.groupby(['dept', 'city']).agg({'salary': 'mean', 'age': 'count'})

# Access a specific group
grouped = df.groupby('dept')
grouped.get_group('Eng')       # all Eng rows

# Iterate over groups
for dept_name, group_df in df.groupby('dept'):
    print(f"\n{dept_name}:")
    print(group_df)

transform - keep original shape

# transform returns a Series with same length as the original df
# Perfect for adding computed columns back

df['dept_avg_salary'] = df.groupby('dept')['salary'].transform('mean')
df['dept_total'] = df.groupby('dept')['salary'].transform('sum')
df['salary_rank'] = df.groupby('dept')['salary'].transform('rank')

# Salary above or below dept average?
df['above_avg'] = df['salary'] > df.groupby('dept')['salary'].transform('mean')

# Normalize salary within department
df['salary_norm'] = df.groupby('dept')['salary'].transform(
    lambda x: (x - x.mean()) / x.std()
)

filter - include or exclude entire groups

# Keep only departments with more than 2 employees
df.groupby('dept').filter(lambda x: len(x) > 2)

# Keep only groups where max salary > 80000
df.groupby('dept').filter(lambda x: x['salary'].max() > 80000)

14. Merging, Joining and Concatenating

pd.merge - SQL-style joins

employees = pd.DataFrame({
    'emp_id':  [1, 2, 3, 4],
    'name':    ['Alice', 'Bob', 'Carol', 'Dave'],
    'dept_id': [10, 20, 10, 30]
})
departments = pd.DataFrame({
    'dept_id':   [10, 20, 40],
    'dept_name': ['Engineering', 'Marketing', 'Finance']
})

# INNER join  -  only matching rows in both (default)
pd.merge(employees, departments, on='dept_id')
# Alice, Bob, Carol  -  Dave is excluded (dept_id 30 not in departments)

# LEFT join  -  all rows from left, NaN where no match on right
pd.merge(employees, departments, on='dept_id', how='left')
# All 4 employees, Dave gets NaN for dept_name

# RIGHT join  -  all rows from right, NaN where no match on left
pd.merge(employees, departments, on='dept_id', how='right')
# Engineering, Marketing, Finance  -  Finance has no employees (NaN)

# OUTER join  -  all rows from both
pd.merge(employees, departments, on='dept_id', how='outer')

# Join on columns with different names
pd.merge(employees, departments,
         left_on='dept_id', right_on='id')

# Join on multiple columns
pd.merge(df1, df2, on=['name', 'date'])

# Suffix when column names clash
pd.merge(df1, df2, on='id', suffixes=('_left', '_right'))

pd.concat - stacking DataFrames

# Stack vertically (add rows)  -  same columns
df_all = pd.concat([df_jan, df_feb, df_mar])

# Reset index (avoid duplicate index values)
df_all = pd.concat([df_jan, df_feb], ignore_index=True)

# Add a key to identify each source
df_all = pd.concat([df1, df2], keys=['file1', 'file2'])

# Stack horizontally (add columns)  -  same rows
df_wide = pd.concat([df1, df2], axis=1)

# Handling mismatched columns
df_all = pd.concat([df1, df2], join='outer')   # NaN for missing (default)
df_all = pd.concat([df1, df2], join='inner')   # only common columns

.join - index-based merge

df1 = pd.DataFrame({'salary': [80000, 72000]}, index=['Alice', 'Bob'])
df2 = pd.DataFrame({'age': [25, 30]}, index=['Alice', 'Bob'])

df1.join(df2)         # join on index
df1.join(df2, how='left')

15. Pivot Tables and Reshaping

Pivot table

df = pd.DataFrame({
    'dept':    ['Eng', 'Eng', 'Mkt', 'Mkt', 'HR'],
    'city':    ['NYC', 'LA',  'NYC', 'LA',  'NYC'],
    'salary':  [80000, 95000, 72000, 61000, 48000],
    'headcount': [5, 3, 4, 2, 3]
})

# Like Excel pivot tables
pd.pivot_table(df,
    values='salary',        # what to aggregate
    index='dept',           # row grouping
    columns='city',         # column grouping
    aggfunc='mean',         # aggregation function
    fill_value=0            # replace NaN with 0
)
#       city        LA       NYC
# dept
# Eng          95000.0   80000.0
# HR               0.0   48000.0
# Mkt          61000.0   72000.0

# Multiple values
pd.pivot_table(df,
    values=['salary', 'headcount'],
    index='dept',
    columns='city',
    aggfunc={'salary': 'mean', 'headcount': 'sum'}
)

melt - wide to long format

# Wide format (one row per person, multiple time columns)
wide = pd.DataFrame({
    'name':  ['Alice', 'Bob'],
    'Jan':   [100, 200],
    'Feb':   [150, 250],
    'Mar':   [120, 230]
})

# Convert to long (tidy) format
long = pd.melt(wide,
    id_vars=['name'],              # columns to keep
    value_vars=['Jan', 'Feb', 'Mar'],  # columns to melt
    var_name='month',              # name for the new "variable" column
    value_name='sales'             # name for the new "value" column
)
#     name month  sales
# 0  Alice   Jan    100
# 1    Bob   Jan    200
# 2  Alice   Feb    150
# ...

stack and unstack

# unstack  -  move row index level to columns
df.set_index(['dept', 'city'])['salary'].unstack()

# stack  -  move column level to row index
df.stack()     # columns become row labels

crosstab - frequency table

pd.crosstab(df['dept'], df['city'])           # counts
pd.crosstab(df['dept'], df['city'],
            values=df['salary'], aggfunc='mean')  # with values
pd.crosstab(df['dept'], df['city'], normalize='index')  # row percentages

16. Apply, Map and Lambda Functions

.apply() - apply a function to rows or columns

df = pd.DataFrame({
    'salary': [55000, 72000, 85000],
    'tax_rate': [0.2, 0.25, 0.3]
})

# Apply to a column (element-wise for Series)
df['salary'].apply(lambda x: x * 1.1)       # 10% raise
df['salary'].apply(lambda x: f'${x:,}')     # format as string

# Apply a named function
def categorize(salary):
    if salary < 60000:
        return 'Junior'
    elif salary < 80000:
        return 'Mid'
    else:
        return 'Senior'

df['level'] = df['salary'].apply(categorize)

# Apply across rows (axis=1)  -  each row is passed as a Series
df['take_home'] = df.apply(
    lambda row: row['salary'] * (1 - row['tax_rate']),
    axis=1
)

# Apply across columns (axis=0 default)  -  returns aggregate per column
df.apply('mean')         # mean of each column
df.apply(lambda x: x.max() - x.min())   # range of each column

.map() - element-wise mapping on Series

s = pd.Series(['Eng', 'Mkt', 'HR', 'Eng'])

# Map with a dictionary (like a lookup/replace)
dept_map = {'Eng': 'Engineering', 'Mkt': 'Marketing', 'HR': 'Human Resources'}
s.map(dept_map)

# Map with a function
s.map(lambda x: x.lower())
s.map(len)              # length of each string

# Missing values in dict → NaN
s.map({'Eng': 'Engineering'})   # Mkt and HR → NaN

.applymap() / .map() on DataFrame - element-wise

# Apply a function to every single element in a DataFrame
df.map(lambda x: round(x, 2) if isinstance(x, float) else x)
df.map(str)     # convert everything to string

Vectorized operations vs apply - performance

# SLOW  -  use apply only when necessary
df['salary'].apply(lambda x: x * 1.1)

# FAST  -  use vectorized operations instead
df['salary'] * 1.1

# apply is needed when:
# 1. Your logic is too complex for vectorized operations
# 2. You need to use multiple columns per row (axis=1)
# 3. You're calling an external function

17. Plotting with Pandas

Pandas has built-in plotting that wraps Matplotlib. Always import Matplotlib too.

import pandas as pd
import matplotlib.pyplot as plt

df = pd.DataFrame({
    'month':    ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
    'revenue':  [12000, 15000, 11000, 18000, 21000, 17000],
    'expenses': [8000, 9000, 10000, 11000, 13000, 12000]
})

# Line plot
df.plot(x='month', y='revenue', figsize=(10, 5))
df.plot(x='month', y=['revenue', 'expenses'])  # multiple lines
plt.title('Monthly Revenue')
plt.show()

# Bar chart
df.plot.bar(x='month', y='revenue')
df.plot.barh(x='month', y='revenue')     # horizontal
df.plot.bar(x='month', stacked=True)     # stacked bars

# Scatter
df.plot.scatter(x='expenses', y='revenue')

# Histogram
df['revenue'].plot.hist(bins=20)

# Box plot
df[['revenue', 'expenses']].plot.box()

# Pie chart
df['revenue'].plot.pie()

# Area chart
df.plot.area(x='month', y=['revenue', 'expenses'])

# Plot directly on a Series
s = pd.Series([1, 4, 9, 16, 25])
s.plot()
s.plot.bar()

# Correlation heatmap (with seaborn)
import seaborn as sns
numeric_df = df[['revenue', 'expenses']]
sns.heatmap(numeric_df.corr(), annot=True, cmap='coolwarm')
plt.show()

18. Performance Tips

Use vectorized operations

# SLOW
for i, row in df.iterrows():
    df.at[i, 'bonus'] = row['salary'] * 0.1

# FAST
df['bonus'] = df['salary'] * 0.1

Use efficient types

# Check memory usage
df.memory_usage(deep=True)

# Convert string columns with few unique values to category
df['dept'] = df['dept'].astype('category')    # can save up to 90% memory

# Use smaller integer/float types when possible
df['age'] = df['age'].astype('int8')          # age fits in int8 (-128 to 127)
df['salary'] = df['salary'].astype('float32') # less precision, less memory

Prefer query/loc over chained indexing

# SLOW and may cause SettingWithCopyWarning
df[df['dept'] == 'Eng']['salary'] = 90000  # BAD

# CORRECT
df.loc[df['dept'] == 'Eng', 'salary'] = 90000  # GOOD

Avoid iterrows - use vectorized or apply

# SLOW (Python-speed loop)
for i, row in df.iterrows():
    ...

# MUCH FASTER
df.apply(func, axis=1)       # still Python, but optimized
df['col'] * 2                # fully vectorized (fastest)

Read large files efficiently

# Read in chunks for huge files
chunk_size = 100_000
chunks = []
for chunk in pd.read_csv('huge_file.csv', chunksize=chunk_size):
    # process each chunk
    chunk = chunk[chunk['salary'] > 50000]
    chunks.append(chunk)
df = pd.concat(chunks, ignore_index=True)

# Use parquet for fast I/O on large datasets
df.to_parquet('data.parquet')            # save
df = pd.read_parquet('data.parquet')     # load (10–100x faster than CSV)

Quick Reference Card

# ── CREATING ─────────────────────────────────────────
pd.Series([1,2,3], index=['a','b','c'])
pd.DataFrame({'col1': [...], 'col2': [...]})
pd.read_csv('f.csv')  pd.read_excel('f.xlsx')

# ── EXPLORING ────────────────────────────────────────
df.head()  df.tail()  df.sample(n)
df.shape   df.info()  df.describe()
df.dtypes  df.isnull().sum()  df['col'].value_counts()

# ── SELECTING ────────────────────────────────────────
df['col']           df[['a','b']]
df.loc[0, 'col']    df.loc[0:3, 'a':'c']
df.iloc[0, 1]       df.iloc[0:3, 1:4]
df[df['col'] > 5]   df.query('col > 5')
df[df['col'].isin(['x','y'])]

# ── ADDING/MODIFYING ─────────────────────────────────
df['new'] = df['a'] * 2
df['level'] = np.where(df['sal']>60000, 'Senior', 'Junior')
df.rename(columns={'old': 'new'})
df.drop(columns=['col'])

# ── CLEANING ─────────────────────────────────────────
df.isnull().sum()         df.dropna()
df.fillna(0)              df.fillna(df['col'].mean())
df.drop_duplicates()      df['col'].astype(int)
df.replace('?', np.nan)

# ── STRINGS ──────────────────────────────────────────
df['col'].str.lower()   .str.strip()   .str.contains('x')
df['col'].str.split(' ', expand=True)  .str.extract(r'(\w+)')

# ── GROUPBY ──────────────────────────────────────────
df.groupby('col')['val'].mean()
df.groupby('col').agg({'val': ['mean','max']})
df.groupby('col')['val'].transform('mean')

# ── MERGING ──────────────────────────────────────────
pd.merge(df1, df2, on='key', how='left')
pd.concat([df1, df2], ignore_index=True)

# ── RESHAPING ────────────────────────────────────────
pd.pivot_table(df, values='v', index='r', columns='c')
pd.melt(df, id_vars=['name'], value_vars=['Jan','Feb'])

# ── SAVING ───────────────────────────────────────────
df.to_csv('f.csv', index=False)
df.to_excel('f.xlsx', index=False)

End of Pandas Masterclass