Basic Bubble Chart in Matplotlib (Python)

The same plot in 14 other libraries — Python: Altair, Bokeh, lets-plot, Plotly, plotnine, Pygal, Seaborn; R: ggplot2; Julia: Makie.jl; JavaScript: Chart.js, D3.js, Apache ECharts, Highcharts, MUI X Charts. Compare all 15 side by side: Basic Bubble Chart in Python, R, Julia and JavaScript.

A bubble chart extending scatter plots by adding a third dimension through bubble size. Each point's position shows two variables (x, y) while the bubble size represents a third quantitative variable. This visualization is excellent for understanding relationships between three numerical variables simultaneously, revealing patterns that would be hidden in traditional 2D scatter plots.

Basic Bubble Chart rendered with Matplotlib

Renders

Python source (Matplotlib)

""" anyplot.ai
bubble-basic: Basic Bubble Chart
Library: matplotlib 3.11.2 | Python 3.13.15
Quality: 86/100 | Updated: 2026-09-30
"""

import os

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.ticker import FuncFormatter, NullLocator
from mpl_toolkits.axes_grid1.inset_locator import inset_axes


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — 8 hues, canonical order
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — retail product portfolio: price vs quality rating, sales volume as bubble size
np.random.seed(42)

category_specs = [
    {
        "name": "Electronics",
        "price_mean": 320,
        "price_std": 170,
        "quality_mean": 7.2,
        "quality_std": 1.0,
        "sales_mean": 45,
        "sales_std": 22,
        "n": 24,
    },
    {
        "name": "Furniture",
        "price_mean": 680,
        "price_std": 240,
        "quality_mean": 6.7,
        "quality_std": 1.2,
        "sales_mean": 18,
        "sales_std": 9,
        "n": 20,
    },
    {
        "name": "Kitchenware",
        "price_mean": 55,
        "price_std": 30,
        "quality_mean": 6.1,
        "quality_std": 1.4,
        "sales_mean": 68,
        "sales_std": 32,
        "n": 26,
    },
    {
        "name": "Outdoor Gear",
        "price_mean": 190,
        "price_std": 85,
        "quality_mean": 7.6,
        "quality_std": 0.9,
        "sales_mean": 30,
        "sales_std": 14,
        "n": 20,
    },
]

price_parts, quality_parts, sales_parts, category_parts = [], [], [], []
for spec in category_specs:
    n = spec["n"]
    prices = np.random.normal(spec["price_mean"], spec["price_std"], n)
    qualities = np.random.normal(spec["quality_mean"], spec["quality_std"], n)
    sales = np.random.normal(spec["sales_mean"], spec["sales_std"], n)

    # Explicit outlier pair per category — a bargain-bin low and a flagship
    # high, derived from that category's own mean/std — so every category
    # visibly spans its full realistic range instead of relying on luck.
    bargain_price = max(spec["price_mean"] - 2.2 * spec["price_std"], 5)
    flagship_price = spec["price_mean"] + 2.6 * spec["price_std"]
    prices = np.append(prices, [bargain_price, flagship_price])
    qualities = np.append(
        qualities, [spec["quality_mean"] - 1.6 * spec["quality_std"], spec["quality_mean"] + 1.7 * spec["quality_std"]]
    )
    sales = np.append(
        sales, [spec["sales_mean"] + 1.4 * spec["sales_std"], spec["sales_mean"] - 0.9 * spec["sales_std"]]
    )

    price_parts.append(prices)
    quality_parts.append(qualities)
    sales_parts.append(sales)
    category_parts.extend([spec["name"]] * (n + 2))

price = np.clip(np.concatenate(price_parts), 8, 1200)
quality_rating = np.clip(np.concatenate(quality_parts), 1, 10)
sales_volume = np.clip(np.concatenate(sales_parts), 3, 130)
category = np.array(category_parts)

category_names = [spec["name"] for spec in category_specs]
category_colors = IMPRINT_PALETTE[:4]

# Scale bubble sizes by area for accurate visual perception (tuned for 3200×1800 canvas).
size_scaled = (sales_volume / sales_volume.max()) * 380 + 25

# Plot
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

for name, color in zip(category_names, category_colors, strict=True):
    mask = category == name
    ax.scatter(
        price[mask],
        quality_rating[mask],
        s=size_scaled[mask],
        alpha=0.5,
        color=color,
        edgecolors=PAGE_BG,
        linewidths=0.8,
        label=name,
        zorder=3,
    )

# Size legend — a real inset panel (matplotlib-native technique), placed in the
# one clear gap in the data (high price, low quality) so it never covers bubbles
legend_caps = [15, 60, 120]
legend_ax = inset_axes(
    ax,
    width="20%",
    height="30%",
    loc="lower left",
    bbox_to_anchor=(0.62, 0.04, 1, 1),
    bbox_transform=ax.transAxes,
    borderpad=0,
)
legend_ax.set_facecolor(ELEVATED_BG)
legend_ax.set_xlim(0, 1)
legend_ax.set_ylim(0, 1)
legend_ax.set_xticks([])
legend_ax.set_yticks([])
for spine in legend_ax.spines.values():
    spine.set_color(INK_SOFT)
    spine.set_linewidth(0.8)
legend_ax.set_title("Sales Volume", fontsize=10, fontweight="bold", color=INK, loc="left", pad=4)
for cap, y in zip(legend_caps, (0.18, 0.5, 0.84), strict=True):
    legend_ax.scatter(
        0.28,
        y,
        s=(cap / sales_volume.max()) * 380 + 25,
        color=INK_MUTED,
        alpha=0.5,
        edgecolors=PAGE_BG,
        linewidths=0.8,
        transform=legend_ax.transAxes,
        clip_on=False,
    )
    legend_ax.text(0.55, y, f"{cap}K units", fontsize=9, color=INK_SOFT, va="center", transform=legend_ax.transAxes)

# Category color legend — upper right, bold title leads the eye ahead of the body text
category_legend = ax.legend(
    fontsize=9,
    loc="upper right",
    framealpha=1.0,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    title="Product Category",
    title_fontsize=10,
    markerscale=0.7,
    handletextpad=0.5,
    borderpad=0.7,
)
plt.setp(category_legend.get_title(), color=INK, fontweight="bold")
plt.setp(category_legend.get_texts(), color=INK_SOFT)

# Style — descriptive prefix clarifies the retail domain; title fontsize scales
# down since the prefix pushes past the 67-char baseline the style-guide default
# is tuned for.
title = "Retail Product Portfolio · bubble-basic · python · matplotlib · anyplot.ai"
title_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12
ax.set_xlabel("Retail Price ($, log scale)", fontsize=10, color=INK, labelpad=8)
ax.set_ylabel("Customer Quality Rating (1–10)", fontsize=10, color=INK, labelpad=8)
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=12)
ax.tick_params(axis="both", labelsize=8, labelcolor=INK_SOFT, length=0)

for spine in ax.spines.values():
    spine.set_visible(False)

ax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK, zorder=0)
ax.xaxis.grid(False)

# Log x-axis: retail price is heavily right-skewed (Kitchenware ~$55 mean vs
# Furniture ~$680 mean), so a linear axis crushes the low-price cluster into an
# unreadable clump. Log spacing gives that region room to breathe while keeping
# the high-price tail proportionate.
ax.set_xscale("log")
ax.set_xlim(7, 1400)
ax.set_xticks([10, 30, 100, 300, 1000])
ax.xaxis.set_minor_locator(NullLocator())
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"${v:,.0f}"))
ax.set_ylim(1, 10.8)

fig.subplots_adjust(left=0.09, right=0.97, top=0.92, bottom=0.12)

# Save
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bubble-basic/matplotlib/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "bubble-basic",
  "language": "python",
  "library": "matplotlib",
  "page": "https://anyplot.ai/bubble-basic/python/matplotlib",
  "hub": "https://anyplot.ai/bubble-basic",
  "code_json": "https://api.anyplot.ai/specs/bubble-basic/matplotlib/code",
  "spec_json": "https://api.anyplot.ai/specs/bubble-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/matplotlib/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/matplotlib/plot-dark.png",
  "quality_score": 86.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Bubble Chart on anyplot.ai.

Other implementations