This guide covers the charts you'll actually use day to day, not every visualization that exists, just the ones that answer most real business questions. For each one, you'll see what it looks like (as a text diagram, not code), what it's for, and when to use it or leave it alone. Two libraries do the work here. Matplotlib is the underlying engine. Seaborn sits on top of it and gives you good-looking, statistically aware charts with a lot less code.
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
The Relationship Between Matplotlib and Seaborn
Before the charts, here's the one thing worth understanding: these two libraries aren't alternatives. One sits on top of the other.
┌───────────────────────────────────────────────┐
│ SEABORN │
│ "Give me a heatmap" → writes the Matplotlib │
│ code for you, with better defaults │
├───────────────────────────────────────────────┤
│ MATPLOTLIB │
│ The engine: every line, label, colour, │
│ and tick mark ultimately lives here │
└───────────────────────────────────────────────┘
Matplotlib is baking from scratch: you control every ingredient. Seaborn is a baking mix: it handles the tedious parts (colour palettes, grid styles, error bars) and adds statistical features automatically, though the oven underneath is still Matplotlib. You can mix the two freely, and the best code usually does.
The Anatomy of a Matplotlib Figure
Every chart, regardless of type, is built from the same two core objects:
- A Figure is the entire canvas, the outer box.
- An Axes is the actual plot area inside that canvas, where the chart itself lives.
FIGURE (the whole canvas)
┌──────────────────────────────────────────────┐
│ ┌────────────────────────────────────────┐ │
│ │ AXES Title legend │ │
│ │ │ ● ● │ │
│ │ ylabel │ ● ● │ │
│ │ │ ● │ │
│ │ └──────────────────── x-axis │ │
│ │ xlabel │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
The cleanest way to create both, and the one that gives you full control:
fig, ax = plt.subplots(figsize=(10, 6)) # canvas 10 inches wide, 6 tall
Everything you call after this (ax.set_title(...), ax.set_xlabel(...), ax.plot(...)) draws onto that axes object. The title, axis labels, ticks and legend all belong to the Axes, which is why they're set through ax.
Chart 1: Line Plot, Tracking Change Over Time
What it looks like
Revenue ($M)
50 │ ●
40 │ ●
30 │ ●
20 │ ● ●
10 │ ● ●
└─────────────────────────────────────
Jan Feb Mar Apr May Jun Jul
The Code
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
revenue = [10, 14, 23, 20, 30, 42, 50]
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(months, revenue, marker='o', linewidth=2, color='steelblue')
ax.set_title("Monthly Revenue | Netflix India")
ax.set_xlabel("Month")
ax.set_ylabel("Revenue ($M)")
plt.tight_layout()
plt.show()
When to Use It
A line plot assumes the x-axis is ordered and continuous, usually time. It's the chart for any "how did this change over time" question.
- Netflix tracking daily active users over a quarter
- Spotify charting weekly stream counts for a new release
- Swiggy monitoring hourly order volumes during a festival
- Tesla plotting quarterly production output over two years
When Not to Use It
Skip the line plot if your x-axis is categories with no natural order, like city names or product categories. Connecting them with a line implies a trend that isn't there. Use a bar chart instead.
Seaborn Version
Seaborn's lineplot adds confidence intervals automatically. It shows the average and the uncertainty around it, which is genuinely useful once you have repeated measurements:
sns.lineplot(data=df, x="month", y="revenue", hue="region")
The hue parameter splits one line into multiple lines, one per category, and generates a legend automatically.
Chart 2: Bar Chart, Comparing Categories Side by Side
What it looks like
Orders
500 │ ████
400 │ ████ ████ ████
300 │ ████ ████ ████ ████ ████
200 │ ████ ████ ████ ████ ████
100 │ ████ ████ ████ ████ ████
└──────────────────────────────
Mon Tue Wed Thu Fri
The Code
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
sales = [300, 450, 380, 420, 500]
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(days, sales, color='coral', edgecolor='black')
ax.set_title("Daily Orders | Swiggy")
ax.set_xlabel("Day")
ax.set_ylabel("Orders")
plt.tight_layout()
plt.show()
When to Use It
A bar chart is what you reach for when comparing a measurement across discrete, unordered categories. The height of each bar is the one thing your eye picks up immediately.
- Amazon comparing total revenue by product category (Electronics, Clothing, Books)
- YouTube showing watch time by content genre
- Uber displaying average ride duration by city
- Instagram comparing engagement rates across different content types
Horizontal Bar Chart: When Labels Are Long
If your category names run long (city names, full product titles, campaign names), rotate the chart horizontally instead. It keeps the labels from getting cramped or angled:
ax.barh(days, sales, color='teal') # categories run down the y-axis; bar length = value
The bar lengths scale with the data (sales = [300, 450, 380, 420, 500]): Mon is shortest and Fri is longest. Matplotlib draws the first category at the bottom, so Mon sits at the bottom and Fri at the top.
Fri ████████████████████████████████████████ (500)
Thu ██████████████████████████████████ (420)
Wed ██████████████████████████████ (380)
Tue ████████████████████████████████████ (450)
Mon ████████████████████████ (300)
0 100 200 300 400 500
When Not to Use It
Avoid bar charts for continuous data with many categories, say 100 countries. The bars collapse into an unreadable wall. Use a line plot if there's order to the data, or aggregate and show only the top N.
Seaborn: countplot and barplot
Seaborn has two bar chart variants, and it's worth knowing the difference:
sns.countplottakes a categorical column on the x-axis and counts the occurrences for you automatically. Good for "how many of each?"sns.barplottakes both an x (category) and a y (value) and draws bars with a 95% confidence interval by default. Good for "what's the average of Y, per category?"
sns.countplot(data=df, x="product_category", order=df["product_category"].value_counts().index)
sns.barplot(data=df, x="city", y="fare", errorbar=('ci', 95),
hue="city", palette="viridis", legend=False)
Version note: older tutorials often show
ci=95as the parameter here. That was deprecated in Seaborn 0.12 and now only triggers a deprecation warning.errorbaris the current parameter, and it defaults to a 95% confidence interval anyway, so in most cases you can just leave it out.
Chart 3: Scatter Plot, Finding Relationships Between Two Variables
What it looks like
Price ($)
300 │ ●
250 │ ● ●
200 │ ● ●
150 │ ● ●
100 │● ●
└────────────────────────────
0 20 40 60 80 100
Rating (out of 100)
The Code
fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(x=df["rating"], y=df["price"],
alpha=0.6, s=50, color='steelblue', edgecolors='white')
ax.set_title("Price vs Rating | Airbnb Listings, Mumbai")
ax.set_xlabel("Rating Score")
ax.set_ylabel("Price per Night ($)")
plt.tight_layout()
plt.show()
When to Use It
A scatter plot shows the relationship, the correlation, between two continuous variables. Each point is one observation, one row in your data.
- Airbnb exploring whether higher-rated listings charge more
- Uber studying whether longer trips earn more (distance vs fare)
- Amazon investigating if more reviews lead to higher prices
- A streaming platform checking whether longer movies have lower completion rates
The patterns you look for:
Positive Negative No Correlation Curved
Correlation Correlation
● ● ● ● ● ●
● ●● ● ● ● ● ●
●● ●● ● ● ● ● ● ●
●● ●● ● ● ● ●
● ●● ● ● ● ●
"as X ↑, Y ↑" "as X ↑, Y ↓" "no pattern" "U-shaped"
When Not to Use It
Scatter plots break down with very large datasets (millions of points), because the dots overlap and pile up into what's called overplotting. Fix it by making the dots transparent (alpha=0.1), switching to a hexbin plot, or adding jitter.
Seaborn: scatterplot and regplot
sns.regplot goes a step further: it draws the scatter plot and fits a regression line through the data, with a shaded confidence band, all in one call.
sns.scatterplot(data=df, x="distance", y="fare", hue="city", style="vehicle_type")
sns.regplot(data=df, x="rating", y="price", scatter_kws={"alpha": 0.4})
The hue and style parameters add two more dimensions of information without changing the chart type: different colours per city, different marker shapes per vehicle type.
Chart 4: Histogram, Seeing the Shape of Your Data
What it looks like
Frequency
80 │ ████
60 │ ████ ████ ████
40 │ ████ ████ ████ ████
20 │ ████ ████ ████ ████ ████
0 │ ████ ████ ████ ████ ████ ████
└────────────────────────────────
10 20 30 40 50 60
Age (years)
The Code
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(df["age"], bins=20, color='mediumseagreen', edgecolor='white')
ax.set_title("Age Distribution of Uber Riders")
ax.set_xlabel("Age")
ax.set_ylabel("Number of Riders")
plt.tight_layout()
plt.show()
What bins Means
A histogram groups continuous numbers into buckets (called bins) and counts how many values fall into each bucket. The bins parameter controls how many buckets to use:
bins=5 (wide buckets) bins=20 (narrow buckets)
████████████ ████
████████████ ████ ████ ████
████████████ ████ ████ ████ ████ ████
───────────────────── ────────────────────
"rough shape" "fine detail"
Too few bins hide structure; too many create noise. A good starting point is 15–30 bins and adjusting from there.
The Shapes You'll See
Understanding histogram shapes is a core data analysis skill:
Normal / Bell Right-Skewed Left-Skewed Bimodal
"most people are "a few very high "a few very low "two clusters"
average" values pull the tail" values pull the tail"
████ ████ ████████
██████ ██████ ████████████
████████ █████████ ████████████
██████████ ████████████ ██████████
adult heights app revenue exam scores orders by hour
(one population) (a few whales) (easy exam) (lunch + dinner)
Here's a quick way to check the logic: on an easy exam, most students score high, near the top, with only a small tail of low scores from the ones who struggled. That tail sits on the left, at low values, which is what "left-skewed" means. A hard exam is the opposite: scores bunch low, with a small tail of high scorers stretching right. That's the right-skewed case, the same shape as the app revenue example above it.
When to Use It
Use a histogram when you want to understand the distribution of a single continuous variable: how spread out it is, where most values cluster, whether it's symmetric, whether there are outliers.
- PhonePe checking the distribution of transaction amounts (are most small, with rare large ones?)
- Spotify analyzing song length distribution across a genre
- Swiggy understanding delivery time distribution (where's the peak? how wide is the spread?)
- Netflix examining hours watched per user per month
Seaborn: histplot and kdeplot
Seaborn's histplot can overlay a smooth density curve, the KDE (Kernel Density Estimate), that shows the shape without the jaggedness of bins:
sns.histplot(data=df, x="fare", kde=True, bins=25, color="steelblue")
sns.kdeplot(data=df, x="fare", hue="city", fill=True, alpha=0.3)
The KDE (kde=True) is that smooth curve drawn over the bars. Think of it as a smoothed-out histogram, and it makes comparing multiple distributions on the same chart a lot cleaner.
Chart 5: Box Plot for Distribution, Spread, and Outliers
What it looks like
○ ← outlier (beyond 1.5 × IQR from the box)
│
┴ ← upper whisker (max value that ISN'T an outlier)
┌────────┐
│ │ ← Q3, 75th percentile, top of the box
│────────│ ← Median, 50th percentile, line inside the box
│ │ ← Q1, 25th percentile, bottom of the box
└────────┘
┬ ← lower whisker (min value that ISN'T an outlier)
│
IQR (Interquartile Range) = height of the box = Q3 − Q1
More concretely, with real numbers:
Ride Fare ($)
50 │ ○ ← outlier (unusually expensive ride)
45 │ │
40 │ ┴ ← upper whisker (highest normal fare)
│ ┌─────────┐
30 │ │ ┼ │ ← median fare
│ └─────────┘
20 │ ┬ ← lower whisker (lowest normal fare)
│ │
10 │
└──────────────────────
All Rides
The Code
fig, ax = plt.subplots(figsize=(10, 6))
ax.boxplot(df["fare"], patch_artist=True, boxprops=dict(facecolor='lightblue'))
ax.set_title("Distribution of Ride Fares | Uber")
ax.set_ylabel("Fare ($)")
plt.tight_layout()
plt.show()
Decoding the Box Plot
The box plot packs five statistics into one picture:
| Feature | What it shows |
|---|---|
| Bottom of box (Q1) | The 25th percentile, 25% of values are below this |
| Middle line | The median, 50% of values are below this |
| Top of box (Q3) | The 75th percentile, 75% of values are below this |
| Height of box (IQR) | Interquartile range = Q3 − Q1. The middle 50% of the data. Wide means spread out, narrow means clustered. |
| Whiskers | Lines extending from the box to the furthest value that is not an outlier (at most 1.5 × IQR beyond the box, by default) |
| Dots beyond whiskers | Outliers, unusually extreme values worth investigating |
When to Use It
Box plots are for comparing distributions across categories. You get the full spread and any outliers, not just the average, across multiple groups at a glance.
City: Mumbai Delhi Bangalore
┌──┐ ┌──┐ ┌──┐
│ │ ○ │ │ │ │
├──┤ ├──┤ │ │
│ │ │ │ ├──┤
└──┘ └──┘ └──┘
Tight Outliers Wide spread
distribution present (varied fares)
- Uber comparing fare distributions across cities (which city has more variability?)
- Amazon comparing delivery times across warehouses
- Spotify analyzing song popularity scores by genre (is Hip-Hop more consistently popular than Classical?)
- A pharma company comparing test results across treatment groups
Seaborn: boxplot and violinplot
Seaborn makes comparing multiple groups easy with the x and hue parameters:
sns.boxplot(data=df, x="city", y="fare", hue="city", palette="Set2", legend=False)
Passing the same column to hue is how current Seaborn applies a palette per category; legend=False hides the legend, which would only repeat the x-axis labels.
The violin plot is a box plot's more expressive sibling. It shows the full distribution shape (like a KDE turned sideways) rather than just the five-number summary.
sns.violinplot(data=df, x="city", y="fare", hue="city", palette="muted",
legend=False, inner="quartile")
Box Plot Violin Plot
┌──┐ ┃░░░░░░░░┃
│ │ ┃░░░░░░░░░░░┃
├──┤ ┃░░░░░░░░░░░░░░┃ ← widest = most common values
│ │ ┃░░░░░░░░░░░┃
└──┘ ┃░░░░░░░░┃
shows 5 numbers shows the full shape
Use a violin plot when the shape of the distribution matters: whether it's bimodal (two humps), skewed, or has a heavy tail. Use a box plot when the outliers or the IQR itself is what you want to communicate.
Chart 6: Heatmap, Visualizing a Matrix of Numbers
What it looks like
Correlation Matrix
Price Rating Distance Duration
Price [1.00] [0.72] [0.85] [0.78] ← darker = stronger relationship
Rating [0.72] [1.00] [0.40] [0.33]
Dist [0.85] [0.40] [1.00] [0.91]
Dur [0.78] [0.33] [0.91] [1.00]
░ = weak ▒ = moderate ▓ = strong
The Code
corr = df[["price", "rating", "distance", "duration"]].corr() # compute correlations
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", center=0,
square=True, linewidths=0.5, ax=ax)
ax.set_title("Feature Correlation Matrix")
plt.tight_layout()
plt.show()
Heatmap Parameters That Matter
| Parameter | What it does |
|---|---|
annot=True |
Write the number inside each cell |
fmt=".2f" |
Format numbers to 2 decimal places |
cmap="coolwarm" |
Blue for negative, red for positive. "viridis" for sequential data. |
center=0 |
Anchors the colour scale at zero, so a diverging cmap like coolwarm stays symmetric around "no correlation" |
square=True |
Forces the cells to be square |
vmin=-1, vmax=1 |
Fix the colour scale (useful for correlations) |
What Correlation Values Mean
-1.0 -0.5 0.0 +0.5 +1.0
│──────────────│─────────────│─────────────│──────────────│
Perfect Moderate No Moderate Perfect
negative negative relationship positive positive
(as X↑, Y↓) (as X↑, Y↑)
A value near ±1 means the two variables move closely together in a straight-line way. Near 0 means there's no linear relationship, though the variables can still be related in a curved way (a scatter plot will show it). In a correlation heatmap, the dark red and dark blue cells are the strongest relationships, and the ones worth investigating further.
When to Use It
Heatmaps work for any data that lives in a grid, where both rows and columns are meaningful categories. The correlation matrix is the most common use case, but they're also good for:
- User engagement by hour and day (rows are hours, columns are days, values are average orders), which is how Swiggy's operations team spots which hour of which day is the peak
- Product cross-sell patterns, which items get bought together, the kind of thing Amazon's recommendation team looks at
- Sensor data, like temperatures across factory zones over time
When Not to Use It
Heatmaps lose their value once one dimension has too many categories, say 60 cities or 200 features. The cells get too small to read. Reduce the dimensions first: pick the top 10 most important features, or cluster before plotting.
Chart 7: Pairplot, Exploring All Relationships at Once
What it looks like
price distance rating
│ │ │
price ──│── kde │ scatter │ scatter
│ │ │
│ │ │
dist ───│── scatter ───│── kde │ scatter
│ │ │
│ │ │
rating──│── scatter ───│── scatter ───│── kde
│ │ │
The Code
sns.pairplot(df[["price", "distance", "duration", "rating", "city"]],
hue="city", diag_kind="kde", corner=True)
plt.suptitle("Pairwise Relationships | Ride Data", y=1.02)
plt.show()
Note: if you're subsetting columns before plotting, make sure the column you pass to
hueis included in that subset. It's a common mistake: slice a DataFrame down to just the numeric columns, then reference a category column inhuethat got left behind, and you'll get aKeyError.
When to Use It
A pairplot is a quick exploration tool. It draws every scatter plot of every variable pair in one grid, with distribution plots on the diagonal, and answers "which variables are related?" without you creating a dozen scatter plots by hand.
- Early in any analysis, before you know which variables matter
- When you have 4–8 features and want a bird's-eye view of relationships
- When colouring by category (
hue) might reveal patterns, like whether city clusters separate cleanly
When Not to Use It
A pairplot with more than 8–10 variables becomes a wall of tiny unreadable charts. For high-dimensional data, use a correlation heatmap instead.
Chart 8: Subplots, Multiple Charts in One Figure
Often the best answer to "how do I show X and Y together?" is to put them side by side in one figure rather than combining them into one chart. Subplots do exactly that.
The Code
# months, revenue, days and sales come from the earlier examples;
# ratings stands in for whatever column you're working with
fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(15, 5))
# Chart 1: Line plot on the first axes
axes[0].plot(months, revenue, marker='o', color='steelblue')
axes[0].set_title("Revenue Trend")
# Chart 2: Bar chart on the second axes
axes[1].bar(days, sales, color='coral')
axes[1].set_title("Orders by Day")
# Chart 3: Histogram on the third axes
axes[2].hist(ratings, bins=20, color='mediumseagreen')
axes[2].set_title("Rating Distribution")
plt.suptitle("Q3 Dashboard | Uber India", fontsize=14)
plt.tight_layout()
plt.show()
Subplot Grid Layouts
1 row, 3 columns 2 rows, 2 columns
plt.subplots(1, 3) plt.subplots(2, 2)
┌──────┬──────┬──────┐ ┌──────┬──────┐
│ [0] │ [1] │ [2] │ │ [0,0]│ [0,1]│
└──────┴──────┴──────┘ ├──────┼──────┤
│ [1,0]│ [1,1]│
└──────┴──────┘
axes[0], axes[1], ... axes[0,0], axes[0,1], ...
When to Use It
Subplots are good for dashboards and reports, where you want several related charts together so they can be compared at a glance. Instead of making a viewer click through four separate charts, you put them on one page: trend over time on the left, breakdown by category in the middle, distribution on the right.
Chart 9: Strip Plot and Swarm Plot, Showing Every Individual Data Point
The Problem with Box Plots and Bar Charts
Bar charts and box plots summarise data: they show you an average, a median, or a spread. But summary statistics can hide things. Two datasets can share nearly identical summary numbers and still look completely different once you plot every point.
The classic demonstration of this is Anscombe's Quartet: four small datasets that share nearly identical means, variances, and correlations, yet look completely different once you plot them point by point.
Dataset A Dataset B Dataset C
● ● ● ● ●●●● ●
● ● ● ● ●●●●● ●●
● ● ●●●● ●●●●
● ●●●
●
Mean ≈ 7.5 Mean ≈ 7.5 Mean ≈ 7.5
Looks like a spread Looks like a curve An outlier changes everything
Going only by the summary numbers, the same mean, the same rough spread, you'd assume these were three similar datasets. Strip and swarm plots apply that same lesson to categorical comparisons. They show every individual data point instead of a summary, so nothing is hidden behind an average.
Strip Plot
Fare ($)
50 │ ●
│ ●●
40 │ ●●●
│ ●●●●●
30 │●●●●●●
│ ●●●●
20 │ ●●
│ ●
─────────────
UberX
fig, ax = plt.subplots(figsize=(10, 6))
sns.stripplot(data=df, x="vehicle_type", y="fare", hue="vehicle_type",
jitter=True, alpha=0.6, palette="Set2", legend=False, ax=ax)
ax.set_title("Fare Distribution by Vehicle Type | Uber")
ax.set_xlabel("Vehicle Type")
ax.set_ylabel("Fare ($)")
plt.tight_layout()
plt.show()
The jitter=True parameter nudges each point slightly left or right, so they don't all stack on the same vertical line and disappear into each other. Without jitter, every point at the same value sits exactly on top of the last one and becomes invisible.
Swarm Plot: No Overlap, Ever
A swarm plot takes this further. Instead of random jitter, it arranges points so they never overlap, spreading them into a clean "bee swarm" shape. The width of the swarm at any height tells you how many data points sit at that value.
Fare ($)
50 │ ●
40 │ ●●●●
30 │ ●●●●●●●●
20 │ ●●●●●
10 │ ●●
─────────
UberX
Width of swarm ≈ how many rides had that fare
sns.swarmplot(data=df, x="vehicle_type", y="fare", hue="vehicle_type",
palette="muted", legend=False)
The Power Move: Overlay on a Box Plot
The most information-rich combination is a box plot (showing the statistics) with a strip plot on top of it (showing the raw data). You get both the summary and the individual points in one view:
fig, ax = plt.subplots(figsize=(10, 6))
sns.boxplot(data=df, x="city", y="fare", hue="city", palette="pastel",
legend=False, width=0.4, ax=ax)
sns.stripplot(data=df, x="city", y="fare", color="black", alpha=0.4,
jitter=True, size=3, ax=ax)
ax.set_title("Fare Distribution by City | Box + Raw Points")
plt.tight_layout()
plt.show()
Box plot + Strip plot overlay
●● ┌──┐ ●●
● │ │ ●
● ● ├──┤ ●●●
● │ │ ●
● └──┘ ●
────────────────
Mumbai Delhi
Box = summary Dots = individual rides
When to Use It
Use strip or swarm plots when your sample size is small to moderate, up to a few hundred points, and you want the audience to see the actual data rather than a summary that could be misleading.
- A clinical trial comparing treatment outcomes across three groups, where seeing every patient matters
- Comparing employee salaries across departments, where individual points reveal clusters an average would hide
- Airbnb showing price spread across property types: the box plot might look the same, but the strip plot reveals that "Entire Apartment" is actually bimodal, a budget cluster and a luxury cluster
When Not to Use It
With very large datasets, 10,000+ rows, every point overlaps and the swarm turns into a solid mass. At that scale, use a violin plot or a KDE instead.
Chart 10: Joint Plot, Scatter and Distribution in One
The joint plot is one of Seaborn's more elegant charts. It combines:
- a scatter plot in the centre: the relationship between two variables
- a histogram or KDE along the top: the distribution of the x variable
- a histogram or KDE along the right side: the distribution of the y variable
You get the relationship and the shape of each variable in one glance.
What it looks like
── Distribution of Duration ──
▄▄ ██ ▄▄
▄▄ ██ ██ ██ ▄▄
┌──────────────────────────┐ ┌─────┐
F │ ● ●● │ │██ │
a │ ●●● ●●● │ │████ │ Distribution
r │ ● ●●● ●● │ │███ │ of Fare
e │ ●●●● ● ● ● │ │█ │
└──────────────────────────┘ └─────┘
Duration
← scatter in the middle →
g = sns.jointplot(data=df, x="duration", y="fare",
kind="scatter", height=7,
marginal_kws=dict(bins=20, fill=True))
g.set_axis_labels("Duration (min)", "Fare ($)")
g.figure.suptitle("Duration vs. Fare with Marginal Distributions", y=1.02)
plt.show()
The kind Parameter: Four Views of the Same Relationship
The kind parameter changes what's drawn in the centre while keeping the marginals:
kind= |
Centre shows | Best for |
|---|---|---|
"scatter" |
Individual dots | Small to medium datasets |
"kde" |
Density contours | Large datasets, seeing clusters |
"hex" |
Hexagonal bins (colour = count) | Very large datasets |
"reg" |
Scatter + regression line | Measuring the trend |
sns.jointplot(data=df, x="duration", y="fare", kind="kde") # smooth density
sns.jointplot(data=df, x="duration", y="fare", kind="hex") # hexagonal bins
sns.jointplot(data=df, x="duration", y="fare", kind="reg") # with trend line
When to Use It
A joint plot works well when you want to report on one specific relationship and also want the reader to see both variables' distributions, without needing two separate charts. It's a self-contained exploratory report in a single figure.
- Uber asking: "Does longer duration always mean higher fare, and what are the typical duration and fare distributions?"
- Spotify: "Is there a relationship between audio energy and danceability, and are either of these normally distributed?"
- An e-commerce team: "Do products with higher ratings actually sell more units?"
Chart 11: FacetGrid, the Same Chart for Every Category
A FacetGrid takes one chart and repeats it for every category in your data, arranged in a grid. Instead of cramming five city comparisons onto one scatter plot with five colours, you get five separate scatter plots side by side, each one clean and readable on its own.
What it looks like
┌─────────────┬─────────────┬─────────────┐
│ Mumbai │ Delhi │ Bangalore │
│ ● ● │ ● │ ●● │
│ ●● ● │ ●● │ ● ●● │
│ ● ● │ ●●●● │ ● ● │
└─────────────┴─────────────┴─────────────┘
Same chart type, same axes, different city
g = sns.FacetGrid(df, col="city", col_wrap=3, height=4, sharey=True)
g.map(sns.scatterplot, "distance", "fare", alpha=0.5)
g.set_axis_labels("Distance (miles)", "Fare ($)")
g.set_titles(col_template="{col_name}")
g.figure.suptitle("Distance vs. Fare by City", y=1.02)
plt.show()
Key FacetGrid Parameters
| Parameter | What it controls |
|---|---|
col="city" |
Which column creates the panels (one per unique value) |
row="time_of_day" |
Adds a second dimension of rows |
col_wrap=3 |
Wrap into a new row after 3 columns (prevents one giant row) |
sharey=True |
All panels share the same y-axis scale (enables fair comparison) |
height=4 |
How tall each individual panel is |
col vs row vs Both
col="city" only col="city", row="time"
┌──┬──┬──┐ ┌──┬──┬──┐ ← Morning
│M │D │B │ ├──┼──┼──┤
└──┴──┴──┘ │M │D │B │ ← Evening
└──┴──┴──┘
3 panels in 1 row 6 panels in a 2×3 grid
When to Use It
A FacetGrid is worth reaching for whenever you want to compare the same pattern across groups without the visual noise of overlapping, colour-coded series.
- Netflix: "How does watch-time-vs-episode-count look differently across genres?"
- Amazon: "Is the rating-vs-price relationship the same for Electronics, Books, and Clothing?"
- Swiggy: "Does order volume peak at the same hour in Mumbai, Delhi, and Bangalore?"
The advantage over cramming everything onto one chart is that side-by-side panels on shared axes let the eye compare instantly, with no legend to decode.
Choosing the Right Chart: A Decision Guide
The most common mistake beginners make is picking a chart type they like the look of, rather than one that fits the data. Here's the decision framework:
What do you want to show?
│
├── CHANGE OVER TIME?
│ └── LINE PLOT
│
├── COMPARISON ACROSS CATEGORIES?
│ ├── A few categories (≤ 10) → BAR CHART
│ └── Long category names → HORIZONTAL BAR CHART
│
├── RELATIONSHIP BETWEEN TWO VARIABLES?
│ ├── Both continuous → SCATTER PLOT
│ ├── With a regression line → sns.regplot
│ └── Plus both distributions → JOINT PLOT
│
├── DISTRIBUTION OF ONE VARIABLE?
│ ├── Overall shape → HISTOGRAM
│ ├── Compare groups/shape → BOX PLOT or VIOLIN PLOT
│ └── Every individual point → STRIP PLOT or SWARM PLOT
│
├── RELATIONSHIPS IN A GRID?
│ ├── All variable pairs → PAIRPLOT
│ └── Correlation matrix → HEATMAP
│
├── SAME QUESTION, SPLIT BY CATEGORY?
│ └── FACETGRID
│
└── MULTIPLE DIFFERENT QUESTIONS AT ONCE?
└── SUBPLOTS
The Chart No One Talks You Out Of: Pie Charts
Pie charts are the most widely used chart type outside of data science, and the most widely criticised inside it. The human eye is good at comparing bar heights side by side, but bad at comparing slice angles and areas. Can you tell, just by looking, whether a 24% slice is bigger than a 26% slice? Not reliably. A bar chart makes that comparison instant.
Use a pie chart only if you have 3-5 categories and the proportions are clearly different, something like 60/25/15. In almost any other case, a bar chart says the same thing more clearly. And skip 3D pie charts entirely: the perspective distortion makes slices look larger or smaller than they really are.
fig, ax = plt.subplots(figsize=(6, 6))
ax.pie(sizes, labels=labels, autopct='%1.0f%%', colors=sns.color_palette("Set2"))
ax.set_title("Market Share | Top 3 Ride-Sharing Apps")
Styling Your Charts: Making Them Look Professional
A technically correct chart that looks ugly loses its audience. Both Matplotlib and Seaborn offer styling tools that require almost no extra code.
Seaborn Themes
sns.set_theme() applies a clean style globally, and every chart after it will use it:
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.2)
The four most useful styles:
"whitegrid" "darkgrid" "white" "ticks"
┌────────┐ ┌════════┐ ┌────────┐ ┌────────┐
│░░│░░│░░│ │▒▒│▒▒│▒▒│ │ │ │ │
│──┼──┼──│ │▒▒│▒▒│▒▒│ │ │ │ ticks │
│░░│░░│░░│ │▒▒│▒▒│▒▒│ │ │ │ only │
└────────┘ └════════┘ └────────┘ └────────┘
White with Dark with Clean white Minimal
grid lines grid (default) no grid ticks
| Style | Best for |
|---|---|
"whitegrid" |
Most general use, clean and readable |
"white" |
Presentations, posters, minimalist |
"darkgrid" |
Dense charts where gridlines help navigation |
"ticks" |
Academic papers, very clean |
Colour Palettes
The palette parameter controls the colours used for different categories. Seaborn has many built-in palettes grouped by purpose:
QUALITATIVE (for categories: each colour = different group)
"Set1" ● ● ● ● ● ● ● ● ● (bold, distinct)
"Set2" ● ● ● ● ● ● ● ● ● (softer, muted)
"tab10" ● ● ● ● ● ● ● ● ● ● (Matplotlib default)
"muted" ● ● ● ● ● ● ● ● ● (professional, subdued)
SEQUENTIAL (for continuous data: light to dark)
"Blues" ░ ▒ ▓ ███ (white → dark blue)
"viridis" ░ ▒ ▓ ███ (perceptually uniform)
"YlOrRd" ░ ▒ ▓ ███ (yellow → red)
DIVERGING (for data that has a meaningful centre, like correlations)
"coolwarm" ── ░ │ ▓ ── (blue → white → red)
"RdBu" ── ░ │ ▓ ── (red → white → blue)
The rule of thumb: use a qualitative palette for categorical data, a sequential palette for ordered or continuous data (like heatmaps with positive values only), and a diverging palette when your data has a meaningful midpoint (like correlations from -1 to +1).
Essential Chart Labels: Never Skip These
A chart without proper labels forces the reader to guess what they're looking at. These four elements are non-negotiable:
ax.set_title("Clear, descriptive title that states the finding",
fontsize=14, fontweight='bold')
ax.set_xlabel("What the x-axis represents (unit)", fontsize=12)
ax.set_ylabel("What the y-axis represents (unit)", fontsize=12)
ax.legend(title="What the colours/markers mean", fontsize=10)
A good title states the finding, not just the topic. "Revenue by Month" is a topic. "Revenue grew 40% in Q3, driven by festive sales" is a finding. A chart that gives you the conclusion in its own title saves the reader some thinking.
Putting It All Together: A Real Data Science Workflow
Here's how a data scientist at a company like Swiggy would move through a full visualization workflow for a single business question: "Why are delivery times increasing in Mumbai on weekends?"
Step 1: First look at the distributions (Histogram)
mumbai = df[df["city"] == "Mumbai"]
fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
sns.histplot(mumbai[mumbai["day_type"]=="Weekday"]["delivery_mins"], bins=25,
kde=True, color="steelblue", ax=axes[0])
sns.histplot(mumbai[mumbai["day_type"]=="Weekend"]["delivery_mins"], bins=25,
kde=True, color="coral", ax=axes[1])
axes[0].set_title("Weekday Delivery Times")
axes[1].set_title("Weekend Delivery Times")
for ax in axes:
ax.set_xlabel("Delivery Time (minutes)")
plt.suptitle("Mumbai: Weekend vs Weekday Delivery Distribution")
plt.tight_layout()
Finding: the weekend distribution is right-skewed. Most deliveries are fine, but a long tail of very slow ones is pulling the average up.
Step 2: Compare the spread and outliers by hour (Box Plot)
fig, ax = plt.subplots(figsize=(14, 6))
sns.boxplot(data=mumbai, x="hour", y="delivery_mins",
hue="day_type", palette="Set2", ax=ax)
ax.set_title("Delivery Time by Hour | Mumbai")
ax.set_xlabel("Hour of Day")
ax.set_ylabel("Delivery Time (minutes)")
Finding: the 7pm–10pm window on weekends has far more outliers (dots above the whisker). That points to a surge in demand the fleet can't handle.
Step 3: See where delays correlate with order volume (Scatter + Trend)
# weekend_df: Mumbai weekend data summarised to one row per zone and hour,
# with orders_per_hour and avg_delivery_mins columns
fig, ax = plt.subplots(figsize=(10, 6))
sns.regplot(data=weekend_df, x="orders_per_hour", y="avg_delivery_mins",
scatter_kws={"alpha": 0.5}, line_kws={"color": "red"}, ax=ax)
ax.set_title("More Orders → Slower Delivery (Weekend, Mumbai)")
ax.set_xlabel("Orders per Hour")
ax.set_ylabel("Avg Delivery Time (minutes)")
Finding: there's a strong positive correlation. As orders increase, so do delivery times, and the regression line puts a number on it.
Step 4: Check if the pattern is the same across zones (FacetGrid)
g = sns.FacetGrid(weekend_df, col="zone", col_wrap=3, height=4)
g.map(sns.lineplot, "hour", "avg_delivery_mins")
g.set_axis_labels("Hour", "Avg Delivery (min)")
g.set_titles("{col_name}")
g.figure.suptitle("Weekend Delivery by Hour | Mumbai Zones", y=1.02)
Finding: South Mumbai and Bandra spike hardest at 8pm; Thane and Andheri stay much flatter. The problem is concentrated in specific zones, not citywide.
Conclusion delivered to operations team:
"Weekend delivery slowdowns are concentrated in South Mumbai and Bandra between 7pm–10pm, driven by order volumes that exceed fleet capacity in those zones. Redistributing 20% of rider allocation from Andheri to those zones during peak weekend hours should reduce P90 delivery time by an estimated 8 minutes."
This is the actual point of visualization: turning raw data into a precise, actionable statement for someone who has to act on it.
Quick Reference Summary
| Chart | Matplotlib Code | Seaborn Code | Best For | Avoid When |
|---|---|---|---|---|
| Line Plot | ax.plot(x, y) |
sns.lineplot(data, x, y, hue) |
Trends over time | No natural order on x-axis |
| Bar Chart | ax.bar(x, y) |
sns.barplot(data, x, y) |
Comparing category averages | Too many categories (>15) |
| H. Bar Chart | ax.barh(x, y) |
sns.barplot(orient='h') |
Long category labels | N/A |
| Count Plot | (manual) | sns.countplot(data, x) |
Counting occurrences | Comparing averages |
| Scatter Plot | ax.scatter(x, y) |
sns.scatterplot(data, x, y, hue) |
Relationship between 2 continuous vars | Millions of points |
| Regression Plot | (manual) | sns.regplot(data, x, y) |
Scatter + trend line | Categorical x-axis |
| Histogram | ax.hist(x, bins) |
sns.histplot(data, x, kde=True) |
Distribution of 1 variable | Comparing many groups |
| KDE Plot | (manual) | sns.kdeplot(data, x, hue, fill) |
Smooth shape of distribution | Small datasets |
| Box Plot | ax.boxplot(x) |
sns.boxplot(data, x, y) |
Spread, outliers, group comparison | Shape of distribution matters |
| Violin Plot | (complex) | sns.violinplot(data, x, y) |
Distribution shape across groups | Unfamiliar audiences |
| Strip Plot | (manual) | sns.stripplot(data, x, y, jitter) |
All individual points per category | Very large datasets |
| Swarm Plot | (manual) | sns.swarmplot(data, x, y) |
Points with no overlap | Datasets > ~500 rows |
| Joint Plot | (manual) | sns.jointplot(data, x, y, kind) |
Scatter + both marginal distributions | Multiple comparisons |
| Heatmap | (manual) | sns.heatmap(matrix, annot, cmap) |
Correlation matrix, grid data | Too many categories |
| Pairplot | (manual) | sns.pairplot(df, hue, diag_kind) |
Quick EDA across all variable pairs | More than 8–10 variables |
| FacetGrid | (manual) | sns.FacetGrid(df, col, row) |
Same chart replicated per category | Few categories (use hue instead) |
| Pie Chart | ax.pie(sizes, labels) |
(rarely used) | 3–5 categories, big differences | More than 5 categories |
| Subplots | plt.subplots(r, c) |
Combined with any Seaborn chart | Dashboards, multiple questions | Single focused story |
| Styling | plt.style.use(...) |
sns.set_theme(style, palette) |
Professional appearance | N/A, always use it |
Data visualization is part technical, part editorial. The technical part, knowing which function produces which chart, is the easy half. The editorial part is harder: deciding what to show, to whom, and in what form, and that's what separates a chart that confuses from one that convinces. Every element on the chart should earn its place; if a colour, label, or line isn't helping the viewer understand something, cut it. The best charts feel effortless to read because someone put in the work to make them that way.