Mathew K Analytics

Lesson 1 · Python for Data Analysts

Pandas Zero to Hero: The Complete Beginner-to-Advanced Course

One notebook, start to finish: from your first DataFrame to groupby, merging, pivot tables, and rolling windows. No prior pandas experience needed. Let's…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Pandas Zero to Hero: The Complete Beginner-to-Advanced Course#

  • One notebook, start to finish: from your first DataFrame to groupby, merging, pivot tables, and rolling windows.
  • No prior pandas experience needed. Let's get straight into it.

Before You Start#

  • Open a new Jupyter Notebook in VS Code and select your Python interpreter as the kernel.
  • If pandas isn't installed yet, open a terminal in VS Code and run: pip install pandas
  • Place sales.csv and products.csv in the same folder as your notebook so pandas can find them with just their file names.

Part 1: Fundamentals#

import pandas as pd
print(pd.__version__)
2.3.0

Series: A Single Labeled Column of Data#

prices = pd.Series([19.99, 59.99, 24.99], index=['Mouse', 'Keyboard', 'Hub'])
print(prices)
print(prices['Keyboard'])
Mouse       19.99
Keyboard    59.99
Hub         24.99
dtype: float64
59.99

DataFrames: Rows and Columns Together#

data = {
    'product': ['Mouse', 'Keyboard', 'Hub'],
    'price': [19.99, 59.99, 24.99],
    'in_stock': [True, False, True]
}
df = pd.DataFrame(data)
print(df)
    product  price  in_stock
0     Mouse  19.99      True
1  Keyboard  59.99     False
2       Hub  24.99      True

Loading Real Data From CSV#

sales = pd.read_csv('sales.csv')
sales.head()
order_id order_date region product_id quantity unit_price discount_pct
0 1 2025-06-25 West P5 2 27.95 10
1 2 2025-05-23 East P8 1 20.89 0
2 3 2025-05-17 West P8 7 22.25 10
3 4 2025-01-01 North P4 3 212.78 10
4 5 2025-03-11 North P1 1 NaN 5
print(sales.shape)
print(sales.columns)
print(sales.dtypes)
(252, 7)
Index(['order_id', 'order_date', 'region', 'product_id', 'quantity',
       'unit_price', 'discount_pct'],
      dtype='object')
order_id          int64
order_date       object
region           object
product_id       object
quantity          int64
unit_price      float64
discount_pct      int64
dtype: object
sales.info()
sales.describe()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 252 entries, 0 to 251
Data columns (total 7 columns):
 #   Column        Non-Null Count  Dtype  
---  ------        --------------  -----  
 0   order_id      252 non-null    int64  
 1   order_date    252 non-null    object 
 2   region        249 non-null    object 
 3   product_id    252 non-null    object 
 4   quantity      252 non-null    int64  
 5   unit_price    247 non-null    float64
 6   discount_pct  252 non-null    int64  
dtypes: float64(1), int64(3), object(3)
memory usage: 13.9+ KB
order_id quantity unit_price discount_pct
count 252.000000 252.000000 247.000000 252.000000
mean 124.888889 3.904762 56.714858 5.178571
std 72.390566 2.098865 56.777575 5.926833
min 1.000000 1.000000 18.180000 0.000000
25% 62.750000 2.000000 22.830000 0.000000
50% 124.500000 4.000000 29.810000 5.000000
75% 187.250000 6.000000 59.440000 10.000000
max 250.000000 7.000000 218.320000 15.000000

Selecting Columns and Rows#

print(sales['region'].head())
print(sales[['region', 'quantity']].head())
0     West
1     East
2     West
3    North
4    North
Name: region, dtype: object
  region  quantity
0   West         2
1   East         1
2   West         7
3  North         3
4  North         1
print(sales.loc[0])
print(sales.loc[0:2, ['product_id', 'quantity']])
print(sales.iloc[0])
print(sales.iloc[0:2, 0:3])
order_id                 1
order_date      2025-06-25
region                West
product_id              P5
quantity                 2
unit_price           27.95
discount_pct            10
Name: 0, dtype: object
  product_id  quantity
0         P5         2
1         P8         1
2         P8         7
order_id                 1
order_date      2025-06-25
region                West
product_id              P5
quantity                 2
unit_price           27.95
discount_pct            10
Name: 0, dtype: object
   order_id  order_date region
0         1  2025-06-25   West
1         2  2025-05-23   East

Filtering With Boolean Masks#

big_orders = sales[sales['quantity'] >= 5]
print(big_orders.shape)
big_orders.head()
(108, 7)
order_id order_date region product_id quantity unit_price discount_pct
2 3 2025-05-17 West P8 7 22.25 10
7 8 2025-05-25 North P7 6 86.51 0
8 9 2025-02-05 North P1 6 19.91 0
10 11 2025-01-20 NaN P4 5 207.52 0
12 13 2025-06-28 West P3 6 24.13 0
west_big_orders = sales[(sales['quantity'] >= 5) & (sales['region'] == 'West')]
print(west_big_orders.shape)
(20, 7)

Sorting#

print(sales.sort_values('quantity', ascending=False).head(3))
print(sales.sort_values(['region', 'quantity']).head(3))
    order_id  order_date region product_id  quantity  unit_price  discount_pct
2          3  2025-05-17   West         P8         7       22.25            10
14        15  2025-03-18  North         P4         7      214.20            15
36        37  2025-02-14  North         P2         7       58.17             5
    order_id  order_date region product_id  quantity  unit_price  discount_pct
1          2  2025-05-23   East         P8         1       20.89             0
20        21  2025-01-13   East         P7         1       88.18            10
37        38  2025-01-06   East         P6         1         NaN             0

Part 2: Cleaning and Transforming Data#

Finding and Handling Missing Data#

print(sales.isna().sum())
order_id        0
order_date      0
region          3
product_id      0
quantity        0
unit_price      5
discount_pct    0
dtype: int64
missing_price_rows = sales[sales['unit_price'].isna()]
print(missing_price_rows[['order_id', 'product_id', 'unit_price']])
     order_id product_id  unit_price
4           5         P1         NaN
37         38         P6         NaN
88         89         P7         NaN
150       151         P1         NaN
201       202         P7         NaN
sales['region'] = sales['region'].fillna('Unknown')
print(sales['region'].isna().sum())
0
sales_clean = sales.dropna(subset=['unit_price'])
print(sales.shape, sales_clean.shape)
(252, 7) (247, 7)

Finding and Removing Duplicates#

print(sales_clean.duplicated().sum())
sales_clean = sales_clean.drop_duplicates()
print(sales_clean.duplicated().sum())
2
0

Changing Data Types#

sales_clean['order_date'] = pd.to_datetime(sales_clean['order_date'])
print(sales_clean['order_date'].dtype)
print(sales_clean['order_date'].head())
datetime64[ns]
0   2025-06-25
1   2025-05-23
2   2025-05-17
3   2025-01-01
5   2025-02-04
Name: order_date, dtype: datetime64[ns]

Creating New Columns#

sales_clean['line_total'] = sales_clean['quantity'] * sales_clean['unit_price']
sales_clean['discounted_total'] = sales_clean['line_total'] * (1 - sales_clean['discount_pct'] / 100)
sales_clean[['quantity', 'unit_price', 'line_total', 'discount_pct', 'discounted_total']].head()
quantity unit_price line_total discount_pct discounted_total
0 2 27.95 55.90 10 50.310
1 1 20.89 20.89 0 20.890
2 7 22.25 155.75 10 140.175
3 3 212.78 638.34 10 574.506
5 2 19.09 38.18 0 38.180

apply() and Lambda Functions#

def size_label(qty):
    if qty >= 5:
        return 'Bulk'
    elif qty >= 2:
        return 'Multi'
    return 'Single'

sales_clean['order_size'] = sales_clean['quantity'].apply(size_label)
sales_clean['order_size'].value_counts()
order_size
Bulk      106
Multi      99
Single     40
Name: count, dtype: int64
sales_clean['is_discounted'] = sales_clean['discount_pct'].apply(lambda pct: pct > 0)
print(sales_clean['is_discounted'].sum())
122

String Methods With .str#

print(sales_clean['region'].str.upper().head())
print(sales_clean['region'].str.contains('est').head())
0     WEST
1     EAST
2     WEST
3    NORTH
5    NORTH
Name: region, dtype: object
0     True
1    False
2     True
3    False
5    False
Name: region, dtype: bool

Working With Dates Using .dt#

sales_clean['order_month'] = sales_clean['order_date'].dt.month
sales_clean['order_weekday'] = sales_clean['order_date'].dt.day_name()
sales_clean[['order_date', 'order_month', 'order_weekday']].head()
order_date order_month order_weekday
0 2025-06-25 6 Wednesday
1 2025-05-23 5 Friday
2 2025-05-17 5 Saturday
3 2025-01-01 1 Wednesday
5 2025-02-04 2 Tuesday

Part 3: Aggregating and Reshaping#

groupby: Splitting, Applying, Combining#

region_totals = sales_clean.groupby('region')['discounted_total'].sum()
print(region_totals)
region
East        9791.8545
North      14262.1980
South      13177.7550
Unknown     1534.1840
West       10726.0355
Name: discounted_total, dtype: float64
region_summary = sales_clean.groupby('region').agg(
    total_revenue=('discounted_total', 'sum'),
    avg_order_size=('quantity', 'mean'),
    order_count=('order_id', 'count')
)
print(region_summary)
         total_revenue  avg_order_size  order_count
region                                             
East         9791.8545        3.731343           67
North       14262.1980        4.343750           64
South       13177.7550        4.070175           57
Unknown      1534.1840        2.666667            3
West        10726.0355        3.611111           54
multi_group = sales_clean.groupby(['region', 'order_size'])['discounted_total'].sum()
print(multi_group.head(8))
region  order_size
East    Bulk           4810.0660
        Multi          4407.0055
        Single          574.7830
North   Bulk          10215.2205
        Multi          3777.4980
        Single          269.4795
South   Bulk           8056.3180
        Multi          4457.7270
Name: discounted_total, dtype: float64

Pivot Tables#

pivot = sales_clean.pivot_table(
    values='discounted_total',
    index='region',
    columns='order_size',
    aggfunc='sum',
    fill_value=0
)
print(pivot)
order_size        Bulk      Multi    Single
region                                     
East         4810.0660  4407.0055  574.7830
North       10215.2205  3777.4980  269.4795
South        8056.3180  4457.7270  663.7100
Unknown      1037.6000   414.9600   81.6240
West         5818.3795  4446.0770  461.5790

Merging DataFrames#

products = pd.read_csv('products.csv')
products.head()
product_id product_name category list_price
0 P1 Wireless Mouse Accessories 19.99
1 P2 Mechanical Keyboard Accessories 59.99
2 P3 USB-C Hub Accessories 24.99
3 P4 27in Monitor Displays 219.99
4 P5 Laptop Stand Accessories 29.99
sales_with_products = sales_clean.merge(products, on='product_id', how='left')
sales_with_products[['order_id', 'product_id', 'product_name', 'category']].head()
order_id product_id product_name category
0 1 P5 Laptop Stand Accessories
1 2 P8 Desk Lamp Office
2 3 P8 Desk Lamp Office
3 4 P4 27in Monitor Displays
4 6 P1 Wireless Mouse Accessories

Concatenating DataFrames#

north = sales_with_products[sales_with_products['region'] == 'North']
south = sales_with_products[sales_with_products['region'] == 'South']
combined = pd.concat([north, south])
print(north.shape, south.shape, combined.shape)
(64, 16) (57, 16) (121, 16)

Reshaping With melt#

wide = pivot.reset_index()
print(wide.head())
long = wide.melt(id_vars='region', var_name='order_size', value_name='revenue')
print(long.head(6))
order_size   region        Bulk      Multi    Single
0              East   4810.0660  4407.0055  574.7830
1             North  10215.2205  3777.4980  269.4795
2             South   8056.3180  4457.7270  663.7100
3           Unknown   1037.6000   414.9600   81.6240
4              West   5818.3795  4446.0770  461.5790
    region order_size     revenue
0     East       Bulk   4810.0660
1    North       Bulk  10215.2205
2    South       Bulk   8056.3180
3  Unknown       Bulk   1037.6000
4     West       Bulk   5818.3795
5     East      Multi   4407.0055

Part 4: Advanced Pandas#

MultiIndex: Hierarchical Row Labels#

multi = sales_with_products.groupby(['category', 'region'])['discounted_total'].sum()
print(multi)
print(multi.loc['Accessories'])
print(multi.loc[('Accessories', 'North')])
category     region 
Accessories  East       6184.9505
             North      5523.6635
             South      5740.4645
             West       2973.7545
Audio        East        804.0140
             North      2822.7550
             South      2025.6360
             Unknown      81.6240
             West       3182.8690
Displays     East       2092.8360
             North      5388.9280
             South      5354.6620
             Unknown    1452.5600
             West       3787.1780
Office       East        710.0540
             North       526.8515
             South        56.9925
             West        782.2340
Name: discounted_total, dtype: float64
region
East     6184.9505
North    5523.6635
South    5740.4645
West     2973.7545
Name: discounted_total, dtype: float64
5523.6635

Method Chaining#

top_categories = (
    sales_with_products
    .groupby('category')['discounted_total']
    .sum()
    .sort_values(ascending=False)
    .head(3)
)
print(top_categories)
category
Accessories    20422.833
Displays       18076.164
Audio           8916.898
Name: discounted_total, dtype: float64

Rolling and Expanding Windows#

daily_revenue = sales_with_products.groupby('order_date')['discounted_total'].sum().sort_index()
daily_revenue = daily_revenue.asfreq('D', fill_value=0)
rolling_7d = daily_revenue.rolling(window=7).mean()
print(rolling_7d.tail(5))
order_date
2025-06-24    263.562286
2025-06-25    334.220857
2025-06-26    477.376571
2025-06-27    480.769429
2025-06-28    462.296286
Freq: D, Name: discounted_total, dtype: float64
cumulative_revenue = daily_revenue.expanding().sum()
print(cumulative_revenue.tail(3))
order_date
2025-06-26    49250.327
2025-06-27    49274.077
2025-06-28    49492.027
Freq: D, Name: discounted_total, dtype: float64

Categorical Data for Speed and Memory#

before_memory = sales_with_products['category'].memory_usage(deep=True)
sales_with_products['category'] = sales_with_products['category'].astype('category')
after_memory = sales_with_products['category'].memory_usage(deep=True)
print(f'Before: {before_memory} bytes, After: {after_memory} bytes')
Before: 14438 bytes, After: 775 bytes

Vectorization vs. apply: Why Speed Matters#

import time

start = time.time()
_ = sales_with_products['quantity'].apply(lambda q: q * 2)
apply_time = time.time() - start

start = time.time()
_ = sales_with_products['quantity'] * 2
vectorized_time = time.time() - start

print(f'apply: {apply_time:.6f}s, vectorized: {vectorized_time:.6f}s')
apply: 0.000000s, vectorized: 0.000000s

Exporting Data#

region_summary.to_csv('region_summary.csv')
sales_with_products.to_excel('sales_report.xlsx', index=False, sheet_name='Sales')
print('Files saved.')
Files saved.

Part 5: More Advanced Pandas#

Cross-Tabulation With pd.crosstab#

cross = pd.crosstab(sales_with_products['region'], sales_with_products['category'])
print(cross)
category  Accessories  Audio  Displays  Office
region                                        
East               49      5         4       9
North              44      7         6       7
South              39      8         9       1
Unknown             0      1         2       0
West               30     10         5       9
cross_with_totals = pd.crosstab(
    sales_with_products['region'],
    sales_with_products['category'],
    margins=True,
    margins_name='Total'
)
print(cross_with_totals)
category  Accessories  Audio  Displays  Office  Total
region                                               
East               49      5         4       9     67
North              44      7         6       7     64
South              39      8         9       1     57
Unknown             0      1         2       0      3
West               30     10         5       9     54
Total             162     31        26      26    245

Binning Continuous Data With cut and qcut#

sales_with_products['price_tier'] = pd.cut(
    sales_with_products['unit_price'],
    bins=[0, 25, 75, 250],
    labels=['Budget', 'Mid-range', 'Premium']
)
print(sales_with_products['price_tier'].value_counts())
price_tier
Mid-range    97
Budget       91
Premium      57
Name: count, dtype: int64
sales_with_products['quantity_quartile'] = pd.qcut(sales_with_products['quantity'], q=4, labels=['Q1', 'Q2', 'Q3', 'Q4'])
print(sales_with_products['quantity_quartile'].value_counts())
quantity_quartile
Q1    78
Q3    72
Q2    61
Q4    34
Name: count, dtype: int64

One-Hot Encoding With pd.get_dummies#

region_dummies = pd.get_dummies(sales_with_products['region'], prefix='region')
print(region_dummies.head())
   region_East  region_North  region_South  region_Unknown  region_West
0        False         False         False           False         True
1         True         False         False           False        False
2        False         False         False           False         True
3        False          True         False           False        False
4        False          True         False           False        False
encoded = pd.concat([sales_with_products[['order_id', 'region']], region_dummies], axis=1)
encoded.head()
order_id region region_East region_North region_South region_Unknown region_West
0 1 West False False False False True
1 2 East True False False False False
2 3 West False False False False True
3 4 North False True False False False
4 6 North False True False False False

groupby().transform(): Broadcasting a Group Result Back#

sales_with_products['region_avg_total'] = sales_with_products.groupby('region')['discounted_total'].transform('mean')
sales_with_products[['region', 'discounted_total', 'region_avg_total']].head()
region discounted_total region_avg_total
0 West 50.310 198.630287
1 East 20.890 146.147082
2 West 140.175 198.630287
3 North 574.506 222.846844
4 North 38.180 222.846844
sales_with_products['above_region_avg'] = sales_with_products['discounted_total'] > sales_with_products['region_avg_total']
print(sales_with_products['above_region_avg'].sum())
72

groupby().filter(): Keeping or Dropping Entire Groups#

print(sales_with_products['category'].value_counts())
large_categories = sales_with_products.groupby('category', observed=True).filter(lambda g: len(g) >= 100)
print(large_categories['category'].value_counts())
category
Accessories    162
Audio           31
Displays        26
Office          26
Name: count, dtype: int64
category
Accessories    162
Audio            0
Displays         0
Office           0
Name: count, dtype: int64

set_index and reindex#

products_indexed = products.set_index('product_id')
print(products_indexed.head())
print(products_indexed.loc['P3'])
                   product_name     category  list_price
product_id                                              
P1               Wireless Mouse  Accessories       19.99
P2          Mechanical Keyboard  Accessories       59.99
P3                    USB-C Hub  Accessories       24.99
P4                 27in Monitor     Displays      219.99
P5                 Laptop Stand  Accessories       29.99
product_name      USB-C Hub
category        Accessories
list_price            24.99
Name: P3, dtype: object
wishlist_ids = ['P1', 'P2', 'P9', 'P10']
wishlist = products_indexed.reindex(wishlist_ids)
print(wishlist)
                   product_name     category  list_price
product_id                                              
P1               Wireless Mouse  Accessories       19.99
P2          Mechanical Keyboard  Accessories       59.99
P9                          NaN          NaN         NaN
P10                         NaN          NaN         NaN

Index Alignment in Arithmetic#

q1_revenue = pd.Series({'North': 4200, 'South': 3100, 'East': 2600})
q2_revenue = pd.Series({'North': 4600, 'South': 3300, 'West': 2900})
print(q1_revenue + q2_revenue)
East        NaN
North    8800.0
South    6400.0
West        NaN
dtype: float64
combined_revenue = (q1_revenue + q2_revenue).fillna(0)
print(combined_revenue)
East        0.0
North    8800.0
South    6400.0
West        0.0
dtype: float64

Capstone Project: A Reusable Sales Report Pipeline#

  • Let's wrap nearly everything from this lesson into one function: loading, cleaning, merging, aggregating, and exporting, all in a single reusable call.
  • A real analyst rarely runs these steps by hand more than once; they build a pipeline like this and reuse it every time fresh data arrives.
def build_sales_report(sales_path, products_path, output_path):
    sales = pd.read_csv(sales_path)
    products = pd.read_csv(products_path)

    sales['region'] = sales['region'].fillna('Unknown')
    sales = sales.dropna(subset=['unit_price'])
    sales = sales.drop_duplicates()
    sales['order_date'] = pd.to_datetime(sales['order_date'])

    sales['line_total'] = sales['quantity'] * sales['unit_price']
    sales['discounted_total'] = sales['line_total'] * (1 - sales['discount_pct'] / 100)

    merged = sales.merge(products, on='product_id', how='left')

    summary = merged.groupby(['category', 'region']).agg(
        total_revenue=('discounted_total', 'sum'),
        order_count=('order_id', 'count')
    ).reset_index()

    summary.to_excel(output_path, index=False, sheet_name='Summary')
    return summary
report = build_sales_report('sales.csv', 'products.csv', 'quarterly_report.xlsx')
report.sort_values('total_revenue', ascending=False).head(10)
category region total_revenue order_count
0 Accessories East 6184.9505 49
2 Accessories South 5740.4645 39
1 Accessories North 5523.6635 44
10 Displays North 5388.9280 6
11 Displays South 5354.6620 9
13 Displays West 3787.1780 5
8 Audio West 3182.8690 10
3 Accessories West 2973.7545 30
5 Audio North 2822.7550 7
9 Displays East 2092.8360 4

Wrap-Up: What You Learned#

  • Fundamentals: Series, DataFrames, reading CSV, inspecting data, selecting with loc/iloc, filtering with boolean masks, and sorting.
  • Cleaning: missing data, duplicates, dtype conversion, new columns, apply/lambda, string methods, and datetime handling.
  • Aggregating and reshaping: groupby, agg, pivot tables, merge, concat, and melt.
  • Advanced: MultiIndex, method chaining, rolling and expanding windows, categorical dtype, vectorization versus apply, and exporting to CSV and Excel.
  • More advanced: crosstab, cut/qcut binning, get_dummies one-hot encoding, groupby transform and filter, set_index/reindex, and index alignment.
  • A capstone pipeline combining nearly all of it into one reusable, real-world function.
  • You went from your first Series to a working, reusable data pipeline in one sitting. If you want the next build to land in your feed automatically, subscribing is the move see you in the next one.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.