Grouped Box Plot — Bokeh

A grouped box plot displays multiple box plots side-by-side within each category, enabling comparison of distributions across subgroups. Each group contains boxes representing different subcategories or conditions, making it ideal for multi-factor comparisons and A/B testing scenarios with multiple metrics.

Grouped Box Plot rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
box-grouped: Grouped Box Plot
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 80/100 | Updated: 2026-05-08
"""

import numpy as np
from bokeh.io import export_png, save
from bokeh.models import ColumnDataSource, Legend, LegendItem
from bokeh.plotting import figure
from bokeh.resources import CDN


# Data - Employee performance scores across departments by experience level
np.random.seed(42)

categories = ["Sales", "Engineering", "Marketing", "Support"]
subcategories = ["Junior", "Senior", "Lead"]
colors = ["#306998", "#FFD43B", "#4ECDC4"]  # Python Blue, Python Yellow, Teal

# Generate performance data with different distributions per group
data = {}
for cat in categories:
    data[cat] = {}
    for i, sub in enumerate(subcategories):
        # Different base means for departments
        base = {"Sales": 70, "Engineering": 75, "Marketing": 68, "Support": 72}[cat]
        # Experience adds to mean
        exp_bonus = i * 8
        # Generate realistic performance scores (50-100 range)
        n_points = 50
        scores = np.random.normal(base + exp_bonus, 10, n_points)
        scores = np.clip(scores, 40, 100)
        # Add some outliers for visual interest
        if cat == "Engineering" and sub == "Lead":
            scores = np.append(scores, [38, 100, 100])  # Add outliers
        if cat == "Sales" and sub == "Junior":
            scores = np.append(scores, [35, 105])  # Add outliers
        data[cat][sub] = scores


# Calculate box plot statistics
def calc_boxplot_stats(values):
    q1 = np.percentile(values, 25)
    q2 = np.percentile(values, 50)  # median
    q3 = np.percentile(values, 75)
    iqr = q3 - q1
    upper_whisker = min(max(values), q3 + 1.5 * iqr)
    lower_whisker = max(min(values), q1 - 1.5 * iqr)
    outliers = values[(values < lower_whisker) | (values > upper_whisker)]
    return {"q1": q1, "q2": q2, "q3": q3, "lower": lower_whisker, "upper": upper_whisker, "outliers": outliers}


# Create figure
p = figure(
    width=4800,
    height=2700,
    x_range=categories,
    y_range=(30, 110),
    title="box-grouped · bokeh · pyplots.ai",
    x_axis_label="Department",
    y_axis_label="Performance Score",
    tools="",
    toolbar_location=None,
)

# Styling
p.title.text_font_size = "36pt"
p.title.align = "center"
p.xaxis.axis_label_text_font_size = "28pt"
p.yaxis.axis_label_text_font_size = "28pt"
p.xaxis.major_label_text_font_size = "22pt"
p.yaxis.major_label_text_font_size = "22pt"
p.xgrid.grid_line_alpha = 0.3
p.ygrid.grid_line_alpha = 0.3
p.xgrid.grid_line_dash = "dashed"
p.ygrid.grid_line_dash = "dashed"

# Box dimensions
box_width = 0.22
offsets = [-0.28, 0, 0.28]  # Position offsets for subcategories

# Store renderers for legend
legend_items = []

# Draw grouped box plots
for sub_idx, sub in enumerate(subcategories):
    color = colors[sub_idx]
    offset = offsets[sub_idx]

    # Collect data for this subcategory across all categories
    boxes_lower = []
    boxes_upper = []
    boxes_q1 = []
    boxes_q2 = []
    boxes_q3 = []
    x_positions = []
    all_outliers_x = []
    all_outliers_y = []

    for cat_idx, cat in enumerate(categories):
        stats = calc_boxplot_stats(data[cat][sub])
        x_pos = cat_idx + offset
        x_positions.append(x_pos)

        boxes_lower.append(stats["lower"])
        boxes_upper.append(stats["upper"])
        boxes_q1.append(stats["q1"])
        boxes_q2.append(stats["q2"])
        boxes_q3.append(stats["q3"])

        # Collect outliers
        for outlier in stats["outliers"]:
            all_outliers_x.append(x_pos)
            all_outliers_y.append(outlier)

    # Draw whisker stems (vertical lines from lower to upper)
    for i, _cat in enumerate(categories):
        x_pos = x_positions[i]
        # Lower whisker
        p.segment(x0=[x_pos], y0=[boxes_lower[i]], x1=[x_pos], y1=[boxes_q1[i]], line_color="#333333", line_width=3)
        # Upper whisker
        p.segment(x0=[x_pos], y0=[boxes_q3[i]], x1=[x_pos], y1=[boxes_upper[i]], line_color="#333333", line_width=3)
        # Whisker caps
        cap_width = box_width * 0.6
        p.segment(
            x0=[x_pos - cap_width / 2],
            y0=[boxes_lower[i]],
            x1=[x_pos + cap_width / 2],
            y1=[boxes_lower[i]],
            line_color="#333333",
            line_width=3,
        )
        p.segment(
            x0=[x_pos - cap_width / 2],
            y0=[boxes_upper[i]],
            x1=[x_pos + cap_width / 2],
            y1=[boxes_upper[i]],
            line_color="#333333",
            line_width=3,
        )

    # Draw boxes (q1 to q3)
    box_source = ColumnDataSource(data={"x": x_positions, "bottom": boxes_q1, "top": boxes_q3})

    box_renderer = p.vbar(
        x="x",
        width=box_width,
        bottom="bottom",
        top="top",
        source=box_source,
        fill_color=color,
        fill_alpha=0.8,
        line_color="#333333",
        line_width=2,
    )

    # Draw median lines
    for i in range(len(categories)):
        p.segment(
            x0=[x_positions[i] - box_width / 2],
            y0=[boxes_q2[i]],
            x1=[x_positions[i] + box_width / 2],
            y1=[boxes_q2[i]],
            line_color="#333333",
            line_width=4,
        )

    # Draw outliers
    if all_outliers_x:
        p.scatter(
            x=all_outliers_x,
            y=all_outliers_y,
            size=18,
            color=color,
            alpha=0.9,
            line_color="#333333",
            line_width=2,
            marker="circle",
        )

    # Store for legend
    legend_items.append(LegendItem(label=sub, renderers=[box_renderer]))

# Add legend
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="22pt",
    glyph_width=40,
    glyph_height=40,
    spacing=15,
    padding=20,
    background_fill_alpha=0.8,
    border_line_color="#cccccc",
    border_line_width=2,
)
p.add_layout(legend, "right")

# Adjust x-axis to show category names at correct positions
p.xaxis.major_label_overrides = {cat: cat for cat in categories}

# Save
export_png(p, filename="plot.png")

# Also save HTML for interactive version
save(p, filename="plot.html", resources=CDN, title="box-grouped · bokeh · pyplots.ai")

Part of Grouped Box Plot on anyplot.ai.

Other implementations