Grouped Box Plot — Pygal

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 Pygal

Renders

Python source (Pygal)

""" anyplot.ai
box-grouped: Grouped Box Plot
Library: pygal 3.1.3 | Python 3.13.15
Quality: 90/100 | Updated: 2026-08-18
"""

import os

import numpy as np
import pygal
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

# Data - Clinical trial symptom-improvement scores by treatment arm and patient age group
np.random.seed(42)

treatment_arms = ["Placebo", "Low Dose", "High Dose"]
age_groups = ["Under 40", "40-59", "60+"]

# Generate improvement-score distributions with realistic dose/age effects
data = {}
# Placebo: modest, noisy improvement regardless of age
data[("Placebo", "Under 40")] = np.random.normal(18, 9, 60)
data[("Placebo", "40-59")] = np.random.normal(15, 9, 60)
data[("Placebo", "60+")] = np.random.normal(12, 8, 60)

# Low Dose: moderate improvement, slightly weaker response in older patients
data[("Low Dose", "Under 40")] = np.random.normal(42, 11, 60)
data[("Low Dose", "40-59")] = np.random.normal(37, 11, 60)
data[("Low Dose", "60+")] = np.random.normal(30, 10, 60)

# High Dose: strongest improvement, response still tapers with age
data[("High Dose", "Under 40")] = np.random.normal(68, 10, 60)
data[("High Dose", "40-59")] = np.random.normal(60, 10, 60)
data[("High Dose", "60+")] = np.random.normal(50, 12, 60)

# Add outliers to show box plot features (non-responders and exceptional responders)
data[("Placebo", "Under 40")] = np.append(data[("Placebo", "Under 40")], [-8, 45])
data[("Low Dose", "60+")] = np.append(data[("Low Dose", "60+")], [-2, 62])
data[("High Dose", "40-59")] = np.append(data[("High Dose", "40-59")], [25, 92])

# Subcategory colors: Imprint palette starting with brand green, canonical order
subcategory_colors = IMPRINT[:3]

# Build full color tuple: repeat pattern for each treatment-arm group
all_colors = subcategory_colors * len(treatment_arms)

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=all_colors,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    opacity=0.97,
    opacity_hover=1.0,
    stroke_width=2.5,
)

# Create box chart with legend showing only 3 age groups
chart = pygal.Box(
    width=3200,
    height=1800,
    style=custom_style,
    title="box-grouped · python · pygal · anyplot.ai",
    x_title="Treatment Arm",
    y_title="Symptom Improvement Score",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=3,
    legend_box_size=28,
    truncate_legend=-1,
    truncate_label=-1,
    show_y_guides=True,
    show_x_guides=False,
    margin=60,
    box_mode="tukey",
    x_label_rotation=0,
    yrange=(-15, 100),
    range=(-15, 100),
    y_labels=[-10, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
    dots_size=9,
)

# Track which age groups have been labeled in legend
labeled_groups = set()

# Add the 9 boxes grouped by treatment arm
for arm in treatment_arms:
    for group in age_groups:
        values = data[(arm, group)].tolist()
        # Only first occurrence of each age group gets a legend entry
        if group not in labeled_groups:
            chart.add(group, values)
            labeled_groups.add(group)
        else:
            # Suppress legend entry with None label
            chart.add(None, values)

# X-axis labels: show treatment arm name in center of each group
x_labels = ["", "Placebo", "", "", "Low Dose", "", "", "High Dose", ""]
chart.x_labels = x_labels

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/box-grouped/pygal/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": "box-grouped",
  "language": "python",
  "library": "pygal",
  "page": "https://anyplot.ai/box-grouped/python/pygal",
  "hub": "https://anyplot.ai/box-grouped",
  "code_json": "https://api.anyplot.ai/specs/box-grouped/pygal/code",
  "spec_json": "https://api.anyplot.ai/specs/box-grouped",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/pygal/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/pygal/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/pygal/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/pygal/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Grouped Box Plot on anyplot.ai.

Other implementations