Python Data Analysis with Pandas: Complete Tutorial (2026)

A hands-on Pandas tutorial covering everything from reading CSV files to building pivot tables and time series analysis. Each concept includes copy-paste code with realistic data so you can follow along in a Jupyter notebook.

In This Tutorial
  1. Setup and Installation
  2. Creating DataFrames
  3. Reading Data: CSV, Excel, JSON
  4. Selecting and Filtering Data
  5. GroupBy: Split-Apply-Combine
  6. Merging, Joining, and Concatenating
  7. Pivot Tables and Reshaping
  8. Handling Missing Data
  9. Time Series Analysis
  10. Visualization with Matplotlib
  11. Performance Tips
  12. Related Developer Tools
  13. Frequently Asked Questions

Pandas is the foundation of data analysis in Python. Whether you are cleaning messy CSV exports, building reports from database queries, or preparing features for a machine learning model, Pandas is almost certainly part of the workflow. It handles tabular data the way NumPy handles numerical arrays: fast, flexible, and with an API designed for real-world messiness.

This tutorial walks through every core Pandas operation with working code. Each example uses realistic data so you can paste it directly into a Jupyter notebook or Python script and see results immediately.

Working with CSV data? The CSV Editor lets you open, edit, sort, and filter CSV files directly in your browser. The CSV to JSON Converter transforms tabular data into JSON format for API consumption.

Setup and Installation

# Install Pandas (and optional dependencies)
pip install pandas matplotlib openpyxl

# Import convention
import pandas as pd
import numpy as np

The standard import alias is pd. Every tutorial, documentation page, and Stack Overflow answer uses it. Do not deviate from this convention.

Creating DataFrames

A DataFrame is a two-dimensional labeled data structure. Think of it as a spreadsheet with named columns and numbered (or labeled) rows.

From a Dictionary

import pandas as pd

df = pd.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
    'department': ['Engineering', 'Marketing', 'Engineering', 'Sales', 'Marketing'],
    'salary': [95000, 72000, 88000, 78000, 71000],
    'years': [5, 3, 7, 2, 4]
})

print(df)
#       name   department  salary  years
# 0    Alice  Engineering   95000      5
# 1      Bob    Marketing   72000      3
# 2  Charlie  Engineering   88000      7
# 3    Diana        Sales   78000      2
# 4      Eve    Marketing   71000      4

From a List of Dictionaries

# Each dict is a row - common when parsing API responses
records = [
    {'product': 'Widget A', 'price': 29.99, 'qty': 150},
    {'product': 'Widget B', 'price': 49.99, 'qty': 85},
    {'product': 'Widget C', 'price': 19.99, 'qty': 300},
]
df = pd.DataFrame(records)

Quick Inspection

df.shape        # (5, 4) - rows, columns
df.dtypes       # data type of each column
df.info()       # column names, types, non-null counts
df.describe()   # statistical summary (count, mean, std, min, max)
df.head(3)      # first 3 rows
df.tail(2)      # last 2 rows
df.columns      # column names as an Index
df.index        # row labels

Reading Data: CSV, Excel, JSON

CSV Files

# Basic read
df = pd.read_csv('sales_data.csv')

# Specify column types (reduces memory, prevents type guessing errors)
df = pd.read_csv('sales_data.csv', dtype={
    'order_id': 'int32',
    'customer_id': 'int32',
    'product': 'category',
    'amount': 'float32'
})

# Parse dates automatically
df = pd.read_csv('sales_data.csv', parse_dates=['order_date'])

# Read specific columns
df = pd.read_csv('large_file.csv', usecols=['name', 'email', 'total'])

# Handle different delimiters
df = pd.read_csv('european_data.csv', sep=';', decimal=',')

# Read in chunks (for files too large for memory)
chunks = pd.read_csv('huge_file.csv', chunksize=10000)
for chunk in chunks:
    process(chunk)

Excel Files

# Requires: pip install openpyxl
df = pd.read_excel('report.xlsx')

# Read a specific sheet
df = pd.read_excel('report.xlsx', sheet_name='Q4 Data')

# Read specific columns and skip header rows
df = pd.read_excel('report.xlsx', usecols='A:D', skiprows=2)

# Read all sheets into a dict of DataFrames
all_sheets = pd.read_excel('report.xlsx', sheet_name=None)
# all_sheets['Sheet1'], all_sheets['Q4 Data'], etc.

JSON Files

# Standard JSON array of objects
df = pd.read_json('data.json')

# Nested JSON (needs normalization)
import json
with open('nested.json') as f:
    raw = json.load(f)

df = pd.json_normalize(raw['results'], record_path='orders',
                        meta=['customer_id', 'customer_name'])

If you need to convert between CSV and JSON formats for data pipeline work, the JSON to CSV Converter and CSV to JSON Converter handle the transformation in your browser without uploading data to any server.

Writing Data Back Out

# CSV
df.to_csv('output.csv', index=False)

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

# JSON
df.to_json('output.json', orient='records', indent=2)

# Clipboard (paste into spreadsheet)
df.to_clipboard(index=False)

Selecting and Filtering Data

Column Selection

# Single column (returns Series)
df['name']

# Multiple columns (returns DataFrame)
df[['name', 'salary']]

# Dot notation (only for simple column names without spaces)
df.name

Row Selection with loc and iloc

# loc: label-based selection
df.loc[0]                    # row with index label 0
df.loc[0:2, 'name':'salary'] # rows 0-2, columns name through salary
df.loc[df['salary'] > 80000] # boolean filter

# iloc: integer position-based selection
df.iloc[0]        # first row
df.iloc[0:3]      # first 3 rows
df.iloc[:, 0:2]   # all rows, first 2 columns
df.iloc[-1]       # last row

Filtering (Boolean Indexing)

# Single condition
high_earners = df[df['salary'] > 80000]

# Multiple conditions (use & for AND, | for OR, ~ for NOT)
eng_senior = df[(df['department'] == 'Engineering') & (df['years'] >= 5)]

# Filter with isin()
target_depts = df[df['department'].isin(['Engineering', 'Sales'])]

# Filter with string methods
df[df['name'].str.startswith('A')]
df[df['name'].str.contains('li', case=False)]

# query() method (cleaner for complex conditions)
df.query('salary > 80000 and department == "Engineering"')
df.query('years >= @min_years')  # use @ for Python variables

Adding and Modifying Columns

# New column from calculation
df['annual_bonus'] = df['salary'] * 0.10

# Conditional column with np.where
df['seniority'] = np.where(df['years'] >= 5, 'Senior', 'Junior')

# Multiple conditions with np.select
conditions = [
    df['years'] >= 7,
    df['years'] >= 3,
    df['years'] >= 0
]
labels = ['Senior', 'Mid', 'Junior']
df['level'] = np.select(conditions, labels)

# Apply a function to a column
df['name_upper'] = df['name'].apply(str.upper)

# Drop columns
df = df.drop(columns=['name_upper'])

GroupBy: Split-Apply-Combine

GroupBy splits data into groups based on column values, applies a function to each group, and combines the results. It is the Pandas equivalent of SQL's GROUP BY.

# Average salary per department
df.groupby('department')['salary'].mean()
# department
# Engineering    91500.0
# Marketing      71500.0
# Sales          78000.0

# Multiple aggregations
summary = df.groupby('department').agg(
    avg_salary=('salary', 'mean'),
    max_salary=('salary', 'max'),
    headcount=('name', 'count'),
    avg_years=('years', 'mean')
)

# Group by multiple columns
df.groupby(['department', 'seniority'])['salary'].mean()

# Custom aggregation with a lambda
df.groupby('department')['salary'].agg(
    lambda x: x.max() - x.min()
).rename('salary_range')

Transform and Filter

# transform: broadcast group result to original DataFrame shape
df['dept_avg'] = df.groupby('department')['salary'].transform('mean')
df['salary_vs_avg'] = df['salary'] - df['dept_avg']

# filter: keep only groups meeting a condition
large_depts = df.groupby('department').filter(lambda x: len(x) >= 2)

Merging, Joining, and Concatenating

pd.merge() -- SQL-Style Joins

employees = pd.DataFrame({
    'emp_id': [1, 2, 3, 4],
    'name': ['Alice', 'Bob', 'Charlie', 'Diana'],
    'dept_id': [10, 20, 10, 30]
})

departments = pd.DataFrame({
    'dept_id': [10, 20, 40],
    'dept_name': ['Engineering', 'Marketing', 'Legal']
})

# Inner join (only matching rows)
pd.merge(employees, departments, on='dept_id', how='inner')

# Left join (keep all employees, NaN for unmatched departments)
pd.merge(employees, departments, on='dept_id', how='left')

# Right join (keep all departments)
pd.merge(employees, departments, on='dept_id', how='right')

# Outer join (keep everything)
pd.merge(employees, departments, on='dept_id', how='outer')

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

pd.concat() -- Stacking DataFrames

# Vertical stack (add rows)
q1 = pd.DataFrame({'month': ['Jan', 'Feb', 'Mar'], 'revenue': [100, 120, 110]})
q2 = pd.DataFrame({'month': ['Apr', 'May', 'Jun'], 'revenue': [130, 140, 125]})

full_year = pd.concat([q1, q2], ignore_index=True)

# Horizontal stack (add columns)
pd.concat([df1, df2], axis=1)

For a quick visual comparison of your data before and after merging, the JSON Diff tool highlights structural differences between two datasets.

Pivot Tables and Reshaping

sales = pd.DataFrame({
    'date': ['2026-01-01', '2026-01-01', '2026-01-02', '2026-01-02'],
    'product': ['Widget', 'Gadget', 'Widget', 'Gadget'],
    'region': ['North', 'South', 'North', 'South'],
    'revenue': [500, 300, 450, 320]
})

# Pivot table: average revenue by product and region
pd.pivot_table(sales, values='revenue', index='product',
               columns='region', aggfunc='mean')

# With totals
pd.pivot_table(sales, values='revenue', index='product',
               columns='region', aggfunc='sum', margins=True)

# Melt: wide to long format
wide = pd.DataFrame({
    'name': ['Alice', 'Bob'],
    'math': [90, 85],
    'science': [88, 92],
    'english': [95, 78]
})

long = wide.melt(id_vars='name', var_name='subject', value_name='score')
# name    subject  score
# Alice   math     90
# Alice   science  88
# Alice   english  95
# Bob     math     85
# ...

Handling Missing Data

df = pd.DataFrame({
    'name': ['Alice', 'Bob', None, 'Diana'],
    'salary': [95000, np.nan, 88000, 78000],
    'bonus': [5000, 3000, np.nan, np.nan]
})

# Detect missing values
df.isna()          # Boolean DataFrame
df.isna().sum()    # Count NaN per column

# Drop rows with any NaN
df.dropna()

# Drop rows where specific columns are NaN
df.dropna(subset=['salary'])

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

# Fill with a constant
df['bonus'].fillna(0)

# Fill with column mean
df['salary'].fillna(df['salary'].mean())

# Forward-fill (use previous value)
df['salary'].fillna(method='ffill')

# Back-fill (use next value)
df['salary'].fillna(method='bfill')

# Interpolate (linear by default)
df['salary'].interpolate()

# Replace specific values
df.replace({'salary': {0: np.nan}})

Strategy matters. Dropping rows with missing data is safe for exploratory analysis but can introduce bias in statistical models. Mean imputation preserves the average but reduces variance. Forward-fill works well for time series. Always understand why data is missing before choosing a strategy.

Time Series Analysis

# Create a DatetimeIndex
dates = pd.date_range('2026-01-01', periods=365, freq='D')
ts = pd.DataFrame({
    'date': dates,
    'sales': np.random.randint(100, 500, size=365)
})
ts.set_index('date', inplace=True)

# Slice by date range
jan = ts['2026-01']
q1 = ts['2026-01':'2026-03']

# Resample: daily to monthly
monthly = ts.resample('M').sum()

# Resample: daily to weekly with multiple aggregations
weekly = ts.resample('W').agg({'sales': ['sum', 'mean', 'max']})

# Rolling average (7-day window)
ts['rolling_7d'] = ts['sales'].rolling(window=7).mean()

# Expanding (cumulative) sum
ts['cumulative'] = ts['sales'].expanding().sum()

# Shift (lag/lead)
ts['prev_day'] = ts['sales'].shift(1)    # yesterday's sales
ts['next_day'] = ts['sales'].shift(-1)   # tomorrow's sales

# Percentage change
ts['pct_change'] = ts['sales'].pct_change()

# Day of week, month extraction
ts['dow'] = ts.index.day_name()
ts['month'] = ts.index.month

Visualization with Matplotlib

Pandas has built-in plotting that wraps Matplotlib. For quick exploration, it is faster than writing raw Matplotlib code.

import matplotlib.pyplot as plt

# Line plot (great for time series)
ts['sales'].plot(figsize=(12, 4), title='Daily Sales')
plt.ylabel('Revenue ($)')
plt.tight_layout()
plt.savefig('daily_sales.png', dpi=150)
plt.show()

# Bar chart
df.groupby('department')['salary'].mean().plot.bar(
    color='#00d4ff', title='Average Salary by Department'
)
plt.ylabel('Salary ($)')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

# Histogram
df['salary'].plot.hist(bins=20, edgecolor='black',
                       title='Salary Distribution')
plt.xlabel('Salary ($)')
plt.show()

# Scatter plot
df.plot.scatter(x='years', y='salary', alpha=0.6,
                title='Experience vs Salary')
plt.show()

# Multiple subplots
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
df.groupby('department')['salary'].mean().plot.bar(ax=axes[0], title='Avg Salary')
df['years'].plot.hist(ax=axes[1], bins=10, title='Years Distribution')
plt.tight_layout()
plt.show()

# Box plot (detect outliers)
df.boxplot(column='salary', by='department', figsize=(8, 5))
plt.suptitle('')  # Remove automatic title
plt.title('Salary Distribution by Department')
plt.show()

When you need to fine-tune the colors in your charts, the Color Picker generates hex, RGB, and HSL values that you can drop directly into your Matplotlib style parameters.

Performance Tips

1. Use Vectorized Operations, Not Loops

# Slow: iterating with iterrows
for idx, row in df.iterrows():
    df.at[idx, 'tax'] = row['salary'] * 0.3

# Fast: vectorized operation (100x+ faster on large datasets)
df['tax'] = df['salary'] * 0.3

2. Optimize Data Types

# Before: 304 bytes for a category column stored as object
df['department'].memory_usage(deep=True)

# After: convert to category (fewer unique values = big savings)
df['department'] = df['department'].astype('category')
df['department'].memory_usage(deep=True)  # significantly less

# Downcast numeric columns
df['salary'] = pd.to_numeric(df['salary'], downcast='integer')
df['years'] = pd.to_numeric(df['years'], downcast='integer')

3. Filter Early

# Slow: load everything, then filter
df = pd.read_csv('huge.csv')
filtered = df[df['region'] == 'North']

# Faster: only read what you need
df = pd.read_csv('huge.csv', usecols=['region', 'sales', 'date'])
filtered = df[df['region'] == 'North']

4. Use query() for Complex Filters

# Boolean indexing (creates temporary boolean arrays)
result = df[(df['salary'] > 80000) & (df['department'] == 'Engineering')]

# query() can be faster on large DataFrames
result = df.query('salary > 80000 and department == "Engineering"')

5. Consider Alternatives for Big Data

For formatting the JSON output from your Pandas data pipelines, the JSON Formatter validates structure and formats output with consistent indentation.

Related Developer Tools

Free browser-based tools for working with the data formats you encounter in Pandas workflows.


Frequently Asked Questions

A Series is a one-dimensional labeled array that can hold any data type (integers, strings, floats, objects). Think of it as a single column. A DataFrame is a two-dimensional labeled data structure with rows and columns, like a spreadsheet or SQL table. Each column in a DataFrame is a Series. When you select a single column from a DataFrame with df['column_name'], you get a Series. When you select multiple columns with df[['col1', 'col2']], you get a DataFrame.

Pandas represents missing data as NaN for numeric data and None or NaT for datetime data. Use df.isna() to detect missing values and df.isna().sum() to count them per column. To remove rows with missing data, use df.dropna(). To fill missing values, use df.fillna(value) with a constant, df.fillna(method='ffill') for forward-fill, or df.fillna(df.mean()) to fill with column means. For more control, use df.interpolate() to estimate missing values based on surrounding data. Always explore why data is missing before choosing a strategy.

pd.concat() stacks DataFrames vertically (adding rows) or horizontally (adding columns). It does not match on keys. pd.merge() combines DataFrames by matching values in one or more columns, similar to SQL JOIN. It supports inner, left, right, and outer joins with the how parameter. df.join() is a convenience method that merges on the index by default. In practice, use concat when you want to stack datasets with the same structure, and merge when you want to combine datasets based on a shared column like a customer ID or date.

Several strategies improve performance. First, use vectorized operations instead of loops: df['total'] = df['price'] * df['qty'] is far faster than iterating with iterrows(). Second, specify dtypes when reading data to reduce memory usage. Third, use categorical dtype for columns with few unique values. Fourth, filter early to reduce the size of data you process. Fifth, use query() for complex filtering as it can be faster than boolean indexing on large DataFrames. For truly large datasets, consider Polars, DuckDB, or Dask, which handle out-of-memory data and parallelize operations.

Use the groupby() method to split data into groups, apply a function, and combine results. Basic syntax: df.groupby('column').agg_function(). For example, df.groupby('department')['salary'].mean() calculates average salary per department. Use .agg() for multiple aggregations: df.groupby('dept').agg({'salary': ['mean', 'max'], 'bonus': 'sum'}). Group by multiple columns with df.groupby(['year', 'dept']). Use transform() to broadcast group results back to the original DataFrame size, and filter() to keep only groups meeting a condition.

Yes. Use pd.read_excel('file.xlsx') to read Excel files and df.to_excel('output.xlsx') to write them. Pandas requires an engine library: openpyxl for .xlsx files (pip install openpyxl) or xlrd for legacy .xls files. You can read specific sheets with sheet_name='Sheet2' or sheet_name=0, read specific columns with usecols='A:D', and skip rows with skiprows=2. To read all sheets at once, use sheet_name=None, which returns a dictionary of DataFrames keyed by sheet name.

NT

Christian Bucher

We build free developer tools including CSV editors, JSON formatters, data converters, and many more. All browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Tools

CSS Box Shadow Generator · Emoji Picker & Search · Free JSON to YAML Converter

Related Tools

Free JSON to YAML Converter · YAML to JSON Converter · Free API Mock Server

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →