Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Python

Visualizing Data with matplotlib and seaborn: Charts That Explain Your Analysis

Learn to build bar charts, line charts, scatter plots, heatmaps, and multi-panel dashboards with matplotlib and seaborn. Covers the Figure/Axes model, seaborn's statistical charts, professional styling, and annotation — all with realistic sales data you can run immediately.

⚡ Practitioner17 min readSep 22, 2026Updated Sep 22, 2026
Visualizing Data with matplotlib and seaborn: Charts That Explain Your Analysis
On this page
  • Prerequisites
  • Understanding matplotlib's Figure/Axes Architecture
  • The Dataset We'll Use
  • Bar Charts: Comparing Categories
  • Line Charts: Showing Change Over Time
  • Distributions: Histograms and Box Plots
  • Histograms
  • Box Plots
  • Scatter Plots: Exploring Relationships
  • Heatmaps: Seeing Patterns in Matrices
  • Correlation Matrix
  • Pivot Heatmap
  • Building Multi-Panel Dashboard Figures
  • seaborn's FacetGrid: Small Multiples
  • Styling: Making Charts That Look Professional
  • Setting a Global Theme
  • Color Conventions That Work
  • Annotation: Pointing to What Matters
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Visualizing Data with matplotlib and seaborn: Charts That Explain Your Analysis

    You've spent hours cleaning messy data, joining tables, and running groupby aggregations. You've got a DataFrame with real answers in it. Now someone asks you to explain what you found — and a table of numbers isn't going to cut it. This is where visualization earns its place in your workflow: not as decoration, but as the final step that makes your analysis actually land.

    matplotlib and seaborn are the two workhorses of Python data visualization. matplotlib gives you low-level control over every pixel of a chart. seaborn sits on top of it and gives you statistically-aware, publication-quality charts with far less code. In practice, you'll use both — seaborn for fast, expressive charts and matplotlib to fine-tune the result.

    By the end of this lesson, you'll know how to build the charts that actually come up in real analysis: distributions, comparisons, time series, correlations, and multi-panel dashboards. More importantly, you'll understand why you'd choose each chart type and how to make them readable for someone who hasn't seen your data before.

    What you'll learn:

    • How matplotlib's Figure/Axes model works and why it matters
    • Building the core chart types: bar, line, scatter, histogram, box, and heatmap
    • When to use seaborn vs raw matplotlib
    • How to control titles, labels, colors, and layouts like a professional
    • Combining multiple charts into dashboard-style figures

    Prerequisites

    You should be comfortable loading and transforming data with pandas — specifically grouping and aggregating with groupby and selecting and filtering data with loc and boolean masks. You should have a working Python environment with Jupyter or VS Code — if you're not set up yet, see Setting Up Python for Data Analysis.

    Install the libraries if needed:

    pip install matplotlib seaborn pandas
    

    Understanding matplotlib's Figure/Axes Architecture

    Before writing a single chart, you need to understand how matplotlib thinks. This trips up almost every new user, and if you skip it, your charts will fight you constantly.

    matplotlib has two core objects:

    • A Figure is the entire window or canvas. Think of it as the sheet of paper.
    • An Axes (not "axis" — that's something else) is one individual chart living inside the Figure.

    A Figure can contain one or many Axes objects. This is what allows you to create multi-panel dashboards.

    import matplotlib.pyplot as plt
    
    # The simplest approach: one figure, one axes
    fig, ax = plt.subplots()
    ax.plot([1, 2, 3], [10, 25, 18])
    ax.set_title("My Chart")
    plt.show()
    

    The fig, ax = plt.subplots() pattern is the professional way to create charts. You'll see older code that just calls plt.plot() directly — that works for quick exploration, but it uses an implicit "current axes" under the hood that causes confusion once you start building multi-panel layouts.

    Key insight

    Always use fig, ax = plt.subplots() explicitly. When you have multiple subplots, ax becomes an array: fig, axes = plt.subplots(2, 2) gives you a 2x2 grid where axes[0, 0] is the top-left chart.

    For a multi-panel layout:

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))
    
    axes[0].plot([1, 2, 3], [10, 20, 15])
    axes[0].set_title("Left Chart")
    
    axes[1].bar(["A", "B", "C"], [30, 45, 22])
    axes[1].set_title("Right Chart")
    
    plt.tight_layout()
    plt.show()
    

    plt.tight_layout() adjusts spacing so titles and labels don't overlap. Get in the habit of calling it before plt.show().


    The Dataset We'll Use

    Throughout this lesson, we'll work with a realistic sales dataset — the kind you'd have after joining order data with product and customer tables. Here's how to generate it so you can follow along:

    import pandas as pd
    import numpy as np
    import matplotlib.pyplot as plt
    import seaborn as sns
    
    np.random.seed(42)
    
    n = 500
    regions = ["Northeast", "Southeast", "Midwest", "West"]
    categories = ["Software", "Hardware", "Services", "Consulting"]
    months = pd.date_range("2023-01-01", periods=12, freq="MS")
    
    df = pd.DataFrame({
        "order_date": np.random.choice(months, n),
        "region": np.random.choice(regions, n),
        "category": np.random.choice(categories, n),
        "sales_rep": np.random.choice([f"Rep_{i}" for i in range(1, 16)], n),
        "revenue": np.random.lognormal(mean=8, sigma=1.2, size=n).round(2),
        "units": np.random.randint(1, 50, n),
        "customer_satisfaction": np.random.uniform(2.5, 5.0, n).round(1),
    })
    
    # Add some realistic correlations
    df["revenue"] = df["revenue"] * (df["category"] == "Software").astype(int) * 1.4 + \
                    df["revenue"] * (df["category"] != "Software").astype(int)
    df["order_date"] = pd.to_datetime(df["order_date"])
    
    print(df.head())
    print(df.dtypes)
    

    This gives you 500 rows with a date column, categorical dimensions, and numeric measures — exactly the structure you'll encounter in real analysis.


    Bar Charts: Comparing Categories

    Bar charts are the most misused chart in business. People apply them to everything. But they're genuinely the right tool when you're comparing a single numeric value across discrete categories — "revenue by region," "tickets by department," "headcount by role."

    # Aggregate first, then plot
    revenue_by_region = (
        df.groupby("region")["revenue"]
        .sum()
        .sort_values(ascending=False)
        .reset_index()
    )
    
    fig, ax = plt.subplots(figsize=(8, 5))
    
    ax.bar(
        revenue_by_region["region"],
        revenue_by_region["revenue"],
        color="#2563EB",
        edgecolor="white",
        linewidth=0.5
    )
    
    ax.set_title("Total Revenue by Region", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Region", fontsize=11)
    ax.set_ylabel("Total Revenue ($)", fontsize=11)
    
    # Format y-axis as dollars
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    
    # Remove top and right spines for a cleaner look
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    plt.tight_layout()
    plt.show()
    

    Sorting the bars (sort_values(ascending=False)) before plotting is almost always the right call. A reader should immediately see the ranking — that's the point of the chart.

    Now let's make a grouped bar chart to show revenue by region and category at the same time. This is where seaborn saves you significant work:

    revenue_by_region_cat = (
        df.groupby(["region", "category"])["revenue"]
        .sum()
        .reset_index()
    )
    
    fig, ax = plt.subplots(figsize=(11, 6))
    
    sns.barplot(
        data=revenue_by_region_cat,
        x="region",
        y="revenue",
        hue="category",
        palette="Set2",
        ax=ax
    )
    
    ax.set_title("Revenue by Region and Category", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Region", fontsize=11)
    ax.set_ylabel("Total Revenue ($)", fontsize=11)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.legend(title="Category", bbox_to_anchor=(1.01, 1), loc="upper left")
    
    plt.tight_layout()
    plt.show()
    

    Tip

    Notice we pass ax=ax to seaborn functions. This is critical. Without it, seaborn creates its own figure, which breaks multi-panel layouts and makes it impossible to combine with matplotlib customizations. Always pass the ax parameter.


    Line Charts: Showing Change Over Time

    Line charts are for time series — that's essentially their only job, and they're excellent at it. If your x-axis isn't time (or some other naturally ordered sequence), a bar chart is almost certainly better.

    Let's plot monthly revenue over time. This is a natural follow-on from working with dates and time series in pandas:

    monthly_revenue = (
        df.groupby("order_date")["revenue"]
        .sum()
        .reset_index()
        .sort_values("order_date")
    )
    
    fig, ax = plt.subplots(figsize=(11, 5))
    
    ax.plot(
        monthly_revenue["order_date"],
        monthly_revenue["revenue"],
        color="#2563EB",
        linewidth=2.5,
        marker="o",
        markersize=6,
        label="Monthly Revenue"
    )
    
    # Add a rolling average for trend
    monthly_revenue["rolling_avg"] = monthly_revenue["revenue"].rolling(3, center=True).mean()
    ax.plot(
        monthly_revenue["order_date"],
        monthly_revenue["rolling_avg"],
        color="#DC2626",
        linewidth=2,
        linestyle="--",
        label="3-Month Rolling Avg"
    )
    
    ax.set_title("Monthly Revenue Trend — 2023", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Month", fontsize=11)
    ax.set_ylabel("Revenue ($)", fontsize=11)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.legend(fontsize=10)
    
    # Rotate x-axis labels so they don't overlap
    fig.autofmt_xdate(rotation=45)
    
    plt.tight_layout()
    plt.show()
    

    The rolling average line is doing real analytical work here — it smooths out noise so the viewer can see whether the trend is moving up or down. This is the difference between a chart that decorates your analysis and one that explains it.

    For multiple lines (one per category):

    monthly_by_cat = (
        df.groupby(["order_date", "category"])["revenue"]
        .sum()
        .reset_index()
        .sort_values("order_date")
    )
    
    fig, ax = plt.subplots(figsize=(12, 6))
    
    palette = sns.color_palette("Set1", n_colors=4)
    
    for i, cat in enumerate(monthly_by_cat["category"].unique()):
        subset = monthly_by_cat[monthly_by_cat["category"] == cat]
        ax.plot(
            subset["order_date"],
            subset["revenue"],
            label=cat,
            color=palette[i],
            linewidth=2,
            marker="o",
            markersize=4
        )
    
    ax.set_title("Monthly Revenue by Category", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Month", fontsize=11)
    ax.set_ylabel("Revenue ($)", fontsize=11)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.legend(title="Category", fontsize=10)
    fig.autofmt_xdate(rotation=45)
    
    plt.tight_layout()
    plt.show()
    

    Warning

    Don't put more than 5-6 lines on a single chart. Above that, the chart becomes a spaghetti mess that nobody can read. If you have more categories, consider using facets (small multiples) instead — we'll cover that shortly.


    Distributions: Histograms and Box Plots

    When you need to understand how a variable spreads — not just its average — you need a distribution chart. These are the charts that analysts use heavily internally, even if they show cleaner summaries to executives.

    Histograms

    fig, ax = plt.subplots(figsize=(9, 5))
    
    ax.hist(
        df["revenue"],
        bins=40,
        color="#2563EB",
        edgecolor="white",
        linewidth=0.5
    )
    
    ax.set_title("Distribution of Order Revenue", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Revenue ($)", fontsize=11)
    ax.set_ylabel("Number of Orders", fontsize=11)
    ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    # Add a vertical line for the median
    median_rev = df["revenue"].median()
    ax.axvline(median_rev, color="#DC2626", linestyle="--", linewidth=2, label=f"Median: ${median_rev:,.0f}")
    ax.legend(fontsize=10)
    
    plt.tight_layout()
    plt.show()
    

    Notice the revenue follows a log-normal distribution (we generated it that way to simulate realistic sales data, where a few big deals skew the mean heavily). The vertical median line makes this immediately visible to any reader.

    seaborn's histplot adds even more options including a KDE (kernel density estimate) overlay:

    fig, ax = plt.subplots(figsize=(9, 5))
    
    sns.histplot(
        data=df,
        x="revenue",
        hue="category",
        bins=30,
        kde=True,
        palette="Set2",
        alpha=0.6,
        ax=ax
    )
    
    ax.set_title("Revenue Distribution by Category", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Revenue ($)", fontsize=11)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    plt.tight_layout()
    plt.show()
    

    Box Plots

    Box plots are underutilized outside of data science circles, which is a shame — they convey median, quartiles, and outliers in a single compact view.

    fig, ax = plt.subplots(figsize=(9, 5))
    
    sns.boxplot(
        data=df,
        x="category",
        y="revenue",
        palette="Set2",
        linewidth=1.5,
        flierprops={"marker": "o", "markersize": 3, "alpha": 0.5},
        ax=ax
    )
    
    ax.set_title("Revenue Distribution by Category", fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Category", fontsize=11)
    ax.set_ylabel("Revenue ($)", fontsize=11)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    plt.tight_layout()
    plt.show()
    

    Key insight

    The box shows the interquartile range (Q1 to Q3), the line inside is the median, the whiskers extend to 1.5× IQR, and the dots beyond that are outliers. If you're presenting to an audience unfamiliar with box plots, add a small annotation explaining this — or switch to a violin plot (sns.violinplot) which is more intuitive for non-technical audiences.


    Scatter Plots: Exploring Relationships

    When you want to understand the relationship between two continuous variables — does higher satisfaction correlate with larger deals? does more units sold mean more revenue? — scatter plots are your tool.

    fig, ax = plt.subplots(figsize=(9, 6))
    
    scatter = ax.scatter(
        df["units"],
        df["revenue"],
        c=df["customer_satisfaction"],
        cmap="RdYlGn",
        alpha=0.6,
        s=40,
        edgecolors="none"
    )
    
    # Add a colorbar to explain the color encoding
    cbar = plt.colorbar(scatter, ax=ax)
    cbar.set_label("Customer Satisfaction", fontsize=10)
    
    ax.set_title("Revenue vs Units Sold\n(color = customer satisfaction)", fontsize=13, fontweight="bold", pad=15)
    ax.set_xlabel("Units Sold", fontsize=11)
    ax.set_ylabel("Revenue ($)", fontsize=11)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    plt.tight_layout()
    plt.show()
    

    This scatter plot encodes three variables: units (x-axis), revenue (y-axis), and satisfaction (color). That's the maximum you should pack into one chart — four variables becomes unreadable.

    seaborn's scatterplot and regplot add regression lines with confidence intervals in one line:

    fig, ax = plt.subplots(figsize=(8, 5))
    
    sns.regplot(
        data=df,
        x="units",
        y="revenue",
        scatter_kws={"alpha": 0.4, "s": 30},
        line_kws={"color": "#DC2626", "linewidth": 2},
        ax=ax
    )
    
    ax.set_title("Revenue vs Units Sold (with trend line)", fontsize=13, fontweight="bold", pad=15)
    ax.set_xlabel("Units Sold", fontsize=11)
    ax.set_ylabel("Revenue ($)", fontsize=11)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    plt.tight_layout()
    plt.show()
    

    Heatmaps: Seeing Patterns in Matrices

    Heatmaps are perfect for two scenarios: correlation matrices and pivot table data where you want to spot patterns across two categorical dimensions.

    Correlation Matrix

    numeric_cols = df[["revenue", "units", "customer_satisfaction"]]
    correlation_matrix = numeric_cols.corr()
    
    fig, ax = plt.subplots(figsize=(7, 5))
    
    sns.heatmap(
        correlation_matrix,
        annot=True,           # Show the numbers inside each cell
        fmt=".2f",            # Format to 2 decimal places
        cmap="coolwarm",      # Red = positive, Blue = negative
        center=0,             # Center the colormap at zero
        square=True,
        linewidths=0.5,
        cbar_kws={"shrink": 0.8},
        ax=ax
    )
    
    ax.set_title("Correlation Matrix", fontsize=13, fontweight="bold", pad=15)
    plt.tight_layout()
    plt.show()
    

    Pivot Heatmap

    This is the visual equivalent of a pivot table — you can spot which region/category combinations are strongest at a glance:

    pivot_data = df.pivot_table(
        index="region",
        columns="category",
        values="revenue",
        aggfunc="sum"
    )
    
    fig, ax = plt.subplots(figsize=(9, 5))
    
    sns.heatmap(
        pivot_data,
        annot=True,
        fmt=",.0f",
        cmap="YlOrRd",
        linewidths=0.5,
        ax=ax
    )
    
    ax.set_title("Total Revenue by Region and Category ($)", fontsize=13, fontweight="bold", pad=15)
    ax.set_xlabel("Category", fontsize=11)
    ax.set_ylabel("Region", fontsize=11)
    
    plt.tight_layout()
    plt.show()
    

    Building Multi-Panel Dashboard Figures

    The real power of matplotlib's Figure/Axes model shows up when you combine charts. Here's a four-panel summary dashboard — the kind of thing you'd put at the top of a report or a Jupyter notebook deliverable.

    fig = plt.figure(figsize=(14, 10))
    fig.suptitle("Sales Analysis Dashboard — 2023", fontsize=16, fontweight="bold", y=1.01)
    
    # --- Panel 1: Revenue by Region (top-left) ---
    ax1 = fig.add_subplot(2, 2, 1)
    
    rev_region = df.groupby("region")["revenue"].sum().sort_values(ascending=False)
    ax1.bar(rev_region.index, rev_region.values, color="#2563EB", edgecolor="white")
    ax1.set_title("Revenue by Region", fontsize=12, fontweight="bold")
    ax1.set_ylabel("Revenue ($)")
    ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e6:.1f}M"))
    ax1.spines["top"].set_visible(False)
    ax1.spines["right"].set_visible(False)
    
    # --- Panel 2: Monthly Revenue Trend (top-right) ---
    ax2 = fig.add_subplot(2, 2, 2)
    
    monthly = df.groupby("order_date")["revenue"].sum().sort_index()
    ax2.plot(monthly.index, monthly.values, color="#2563EB", linewidth=2.5, marker="o", markersize=5)
    ax2.set_title("Monthly Revenue Trend", fontsize=12, fontweight="bold")
    ax2.set_ylabel("Revenue ($)")
    ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e3:.0f}K"))
    ax2.spines["top"].set_visible(False)
    ax2.spines["right"].set_visible(False)
    fig.autofmt_xdate()
    
    # --- Panel 3: Revenue Distribution (bottom-left) ---
    ax3 = fig.add_subplot(2, 2, 3)
    
    ax3.hist(df["revenue"], bins=35, color="#2563EB", edgecolor="white", alpha=0.8)
    ax3.axvline(df["revenue"].median(), color="#DC2626", linestyle="--", linewidth=2,
                label=f"Median: ${df['revenue'].median():,.0f}")
    ax3.set_title("Revenue Distribution", fontsize=12, fontweight="bold")
    ax3.set_xlabel("Revenue ($)")
    ax3.set_ylabel("Order Count")
    ax3.legend(fontsize=9)
    ax3.spines["top"].set_visible(False)
    ax3.spines["right"].set_visible(False)
    
    # --- Panel 4: Category Box Plots (bottom-right) ---
    ax4 = fig.add_subplot(2, 2, 4)
    
    sns.boxplot(data=df, x="category", y="revenue", palette="Set2", linewidth=1.2,
                flierprops={"marker": "o", "markersize": 3, "alpha": 0.4}, ax=ax4)
    ax4.set_title("Revenue by Category", fontsize=12, fontweight="bold")
    ax4.set_xlabel("Category")
    ax4.set_ylabel("Revenue ($)")
    ax4.spines["top"].set_visible(False)
    ax4.spines["right"].set_visible(False)
    ax4.tick_params(axis="x", rotation=15)
    
    plt.tight_layout()
    plt.savefig("sales_dashboard.png", dpi=150, bbox_inches="tight")
    plt.show()
    

    The plt.savefig() call at the end is how you export to a file — dpi=150 gives you a sharp image, and bbox_inches="tight" ensures nothing gets cropped.

    Tip

    Use fig.add_subplot(rows, cols, index) for precise control over subplot positions, or plt.subplots(2, 2) when you want a regular grid. For irregular layouts (one wide chart spanning two columns), look into fig.add_gridspec() — it gives you CSS grid-level control over subplot sizing.


    seaborn's FacetGrid: Small Multiples

    When you have more categories than a single chart can handle cleanly, small multiples — one panel per category — are often cleaner than overlapping lines or a cluttered grouped bar chart.

    g = sns.FacetGrid(df, col="category", col_wrap=2, height=4, aspect=1.4, sharey=False)
    g.map_dataframe(sns.histplot, x="revenue", bins=25, color="#2563EB", alpha=0.8)
    g.set_titles(col_template="{col_name}", fontsize=12, fontweight="bold")
    g.set_axis_labels("Revenue ($)", "Order Count")
    
    for ax in g.axes.flat:
        ax.spines["top"].set_visible(False)
        ax.spines["right"].set_visible(False)
        ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e3:.0f}K"))
    
    g.figure.suptitle("Revenue Distribution by Category", fontsize=14, fontweight="bold", y=1.02)
    plt.tight_layout()
    plt.show()
    

    sharey=False lets each panel scale independently — important when categories have very different volumes and you want to see each distribution's shape rather than compare absolute counts.

    Note

    seaborn FacetGrid works best when your data is in long format (one row per observation), which is how well-structured pandas DataFrames are organized. If your data is wide (one column per category), use melt to reshape it before passing it to seaborn.


    Styling: Making Charts That Look Professional

    A chart that communicates well looks intentional, not accidental. A few consistent habits will raise your charts from "default matplotlib" to something you're comfortable putting in a report.

    Setting a Global Theme

    # Put this near the top of your notebook, before any charts
    sns.set_theme(style="whitegrid", palette="Set2", font="sans-serif")
    
    # Or use a matplotlib style
    plt.style.use("seaborn-v0_8-whitegrid")
    

    seaborn's built-in styles: whitegrid, darkgrid, white, dark, ticks. For reports, white or ticks with clean spines tends to look the most professional.

    Color Conventions That Work

    • Use a single blue (#2563EB or similar) for neutral charts
    • Use red (#DC2626) sparingly to highlight something important
    • For sequential data (low to high), use YlOrRd or Blues
    • For diverging data (negative to positive), use RdBu or coolwarm
    • For categorical data, use Set2 (soft) or Set1 (bold)

    Annotation: Pointing to What Matters

    fig, ax = plt.subplots(figsize=(9, 5))
    
    monthly = df.groupby("order_date")["revenue"].sum().sort_index()
    ax.plot(monthly.index, monthly.values, color="#2563EB", linewidth=2.5, marker="o", markersize=5)
    
    # Find and annotate the peak month
    peak_month = monthly.idxmax()
    peak_value = monthly.max()
    
    ax.annotate(
        f"Peak: ${peak_value:,.0f}",
        xy=(peak_month, peak_value),
        xytext=(peak_month, peak_value * 1.08),
        fontsize=10,
        fontweight="bold",
        color="#DC2626",
        arrowprops={"arrowstyle": "->", "color": "#DC2626"},
        ha="center"
    )
    
    ax.set_title("Monthly Revenue with Peak Annotated", fontsize=13, fontweight="bold", pad=15)
    ax.set_xlabel("Month")
    ax.set_ylabel("Revenue ($)")
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    
    fig.autofmt_xdate(rotation=45)
    plt.tight_layout()
    plt.show()
    

    Annotation is the bridge between a chart and a conclusion. When you annotate the peak month, you're telling the reader what to notice. That's analysis, not just visualization.


    Hands-On Exercise

    Build a complete visual analysis of the sales dataset with the following requirements. All four charts should appear in a single saved figure.

    Your task:

    1. Horizontal bar chart — Top 10 sales reps by total revenue. Use a horizontal bar chart (ax.barh()) so the rep names are readable. Sort bars so the highest revenue rep is at the top.

    2. Stacked line chart — Monthly revenue split by region. Plot four lines (one per region) on the same axes with a clear legend.

    3. Scatter plot — Customer satisfaction (x) vs. average revenue per order (y), aggregated at the sales rep level. Each point represents one sales rep. Add rep labels for the top 5 by revenue.

    4. Heatmap — Average customer satisfaction by region and category (use pivot_table with aggfunc="mean").

    Stretch goal: Add a color-coding to the scatter plot where each point's color represents which region the rep's most common orders came from.

    This exercise covers groupby aggregation, reshaping data for the heatmap, and all the visualization techniques from this lesson. Work through it without copying the lesson code directly — the struggle is where the learning happens.


    Common Mistakes & Troubleshooting

    Problem: My seaborn chart appears in a separate figure and I can't customize it Always pass ax=ax to seaborn functions. Without it, seaborn creates its own Figure and ignores your layout.

    Problem: Subplot labels are overlapping Call plt.tight_layout() before plt.show(). For suptitles that still overlap, add plt.tight_layout(rect=[0, 0, 1, 0.96]) to leave space at the top.

    Problem: x-axis date labels are crammed together Call fig.autofmt_xdate(rotation=45) or ax.tick_params(axis="x", rotation=45). Also consider formatting dates more compactly with ax.xaxis.set_major_formatter(mdates.DateFormatter("%b")).

    Problem: My saved figure is blurry Increase dpi in plt.savefig(). Use dpi=150 for web, dpi=300 for print. Also ensure bbox_inches="tight" to prevent cropping.

    Problem: seaborn barplot is showing error bars I don't want In newer seaborn versions (0.12+), barplot shows confidence intervals by default. Add errorbar=None to disable: sns.barplot(..., errorbar=None).

    Problem: My colors look wrong or inconsistent across charts Set your palette once with sns.set_palette("Set2") at the top of your notebook. For matplotlib charts, define a list: colors = sns.color_palette("Set2", n_colors=4) and index into it.

    Problem: Heatmap annotations are showing scientific notation Use fmt=",.0f" for integers or fmt=".2f" for decimals. If numbers are very large, pre-divide the matrix values before plotting: pivot_data / 1000 and adjust your title to note the unit.

    Warning

    Don't use pie charts for more than 4-5 categories, and even then, ask yourself if a sorted bar chart would be clearer. The human eye is very good at comparing lengths and very bad at comparing angles and areas. Pie charts are almost always the wrong choice for data analysis — they're better suited to presentations where you want to make a single proportional point (e.g., "Software accounts for more than half of revenue").


    Summary & Next Steps

    You've now got a complete visualization toolkit:

    • matplotlib's Figure/Axes model — the foundation that makes everything else work, including multi-panel dashboards
    • Bar charts for categorical comparisons, grouped and sorted
    • Line charts for time series with multiple series and rolling averages
    • Histograms and box plots for distribution analysis
    • Scatter plots for relationship exploration with multi-variable encoding
    • Heatmaps for correlation matrices and pivot table visualization
    • FacetGrid for small multiple layouts when you have many categories
    • seaborn theming and matplotlib annotation for publication-quality output

    The pattern across every chart is the same: aggregate your data with pandas, then hand off a clean, properly shaped DataFrame to the chart function. The better your data preparation — cleaning it thoroughly, loading it correctly — the less friction you'll hit at the visualization stage.

    The natural next step from here is automating these charts — generating a report that runs weekly, saves figures to files, and emails them or drops them into a shared folder. That's where Python's advantage over Excel really compounds: your visualization code runs on fresh data with zero manual effort.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Python for Data Analysis

    Previous

    Text Cleanup at Scale with pandas String Methods and Regular Expressions

    Next

    Reading from SQL Databases into pandas with SQLAlchemy

    Related Insights

    PythonExpert

    Segmenting Customers with RFM Analysis in pandas: Calculating Recency, Frequency, and Monetary Scores from Transaction Data

    28 min
    PythonExpert

    Building a Self-Updating Sales Dashboard in pandas and openpyxl: Automated Charts, Conditional Formatting, and Named Ranges from a Live Data Source

    24 min
    PythonExpert

    Automating pandas Data Cleaning with Custom Validation Rules, Error Logs, and a Corrected Output File

    26 min

    On this page

    • Prerequisites
    • Understanding matplotlib's Figure/Axes Architecture
    • The Dataset We'll Use
    • Bar Charts: Comparing Categories
    • Line Charts: Showing Change Over Time
    • Distributions: Histograms and Box Plots
    • Histograms
    • Box Plots
    • Scatter Plots: Exploring Relationships
    • Heatmaps: Seeing Patterns in Matrices
    • Correlation Matrix
    • Pivot Heatmap
    • Building Multi-Panel Dashboard Figures
    • seaborn's FacetGrid: Small Multiples
    • Styling: Making Charts That Look Professional
    • Setting a Global Theme
    • Color Conventions That Work
    • Annotation: Pointing to What Matters
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps