Basic Count Plot — Altair

A count plot displays the frequency of observations in each category of a categorical variable using vertical bars. Unlike a basic bar chart that requires pre-computed values, a count plot automatically counts occurrences from raw data. This makes it ideal for quick exploratory analysis of categorical distributions without manual aggregation.

Basic Count Plot rendered with Altair

Renders

Python source (Altair)

""" anyplot.ai
count-basic: Basic Count Plot
Library: altair 6.2.2 | Python 3.13.14
Quality: 91/100 | Updated: 2026-08-11
"""

import os
import sys


sys.path = [p for p in sys.path if not p.endswith("implementations/python")]

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

BRAND = "#009E73"  # Imprint palette position 1

# Data: Survey responses with varying frequencies
np.random.seed(42)
responses = np.random.choice(
    ["Excellent", "Good", "Average", "Poor", "Very Poor"], size=200, p=[0.25, 0.35, 0.20, 0.12, 0.08]
)
df = pd.DataFrame({"Response": responses})

TITLE = "count-basic · python · altair · anyplot.ai"

# Aggregate counts and each category's share of the total via Altair's
# declarative transform pipeline, so the percentage annotation is computed
# inside the chart spec rather than pre-calculated in pandas. `isLeading`
# flags the top category so it can carry a deliberate focal-point treatment
# (stroke + full opacity + bold label) instead of a single flat green fill.
base = (
    alt.Chart(df)
    .transform_aggregate(count="count()", groupby=["Response"])
    .transform_joinaggregate(total="sum(count)", max="max(count)")
    .transform_calculate(pct="datum.count / datum.total * 100")
    .transform_calculate(label="format(datum.count, 'd') + ' (' + format(datum.pct, '.0f') + '%)'")
    .transform_calculate(isLeading="datum.count == datum.max")
)

# Hover highlight: a real Altair selection, not a decorative effect — fully
# functional in the interactive plot-{THEME}.html export.
hover = alt.selection_point(on="pointerover", fields=["Response"], empty=False)

bars = (
    base.mark_bar(color=BRAND, cornerRadiusTopLeft=4, cornerRadiusTopRight=4, stroke=INK)
    .encode(
        x=alt.X("Response:N", sort="-y", title="Survey Response", axis=alt.Axis(labelAngle=0)),
        y=alt.Y("count:Q", title="Number of Responses"),
        opacity=alt.when(hover)
        .then(alt.value(1.0))
        .when("datum.isLeading")
        .then(alt.value(1.0))
        .otherwise(alt.value(0.8)),
        strokeWidth=alt.condition("datum.isLeading", alt.value(2.5), alt.value(0)),
        tooltip=[
            alt.Tooltip("Response:N", title="Response"),
            alt.Tooltip("count:Q", title="Count"),
            alt.Tooltip("pct:Q", title="Share", format=".1f"),
        ],
    )
    .add_params(hover)
)

# fontWeight isn't a data-driven Vega-Lite encoding channel, so the
# bold-vs-muted label hierarchy is split into two filtered layers instead of
# a single conditional encoding.
label_encode = {"x": alt.X("Response:N", sort="-y"), "y": "count:Q", "text": "label:N"}
label_leading = (
    base.transform_filter("datum.isLeading")
    .mark_text(align="center", baseline="bottom", dy=-6, fontSize=14, fontWeight="bold", color=INK)
    .encode(**label_encode)
)
label_rest = (
    base.transform_filter("!datum.isLeading")
    .mark_text(align="center", baseline="bottom", dy=-6, fontSize=12, fontWeight="normal", color=INK_SOFT)
    .encode(**label_encode)
)
labels = label_rest + label_leading

chart = (
    (bars + labels)
    .properties(
        width=620,  # inner-view landscape target — see prompts/library/altair.md "Canvas"
        height=320,
        background=PAGE_BG,
        title=alt.Title(
            TITLE,
            subtitle=f"n = {len(df)} survey responses",
            fontSize=18,
            color=INK,
            subtitleFontSize=13,
            subtitleColor=INK_SOFT,
        ),
    )
    .configure_view(fill=PAGE_BG, strokeWidth=0)  # no boxed frame — L-shaped spines via axis domain lines only
    .configure_axis(
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        gridColor=INK,
        gridOpacity=0.12,
        labelColor=INK_SOFT,
        titleColor=INK,
        labelFontSize=11,
        titleFontSize=13,
    )
)

# Save
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
chart.save(f"plot-{THEME}.html")

# Canvas contract: pad the rendered PNG up to the exact target — never crop,
# since cropping would clip the title/axis labels (see prompts/library/altair.md).
TARGET_W, TARGET_H = 3200, 1800
img = Image.open(f"plot-{THEME}.png").convert("RGB")
w, h = img.size
if w > TARGET_W or h > TARGET_H:
    raise SystemExit(
        f"altair vl-convert produced {w}x{h}, exceeds target {TARGET_W}x{TARGET_H}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if w < TARGET_W or h < TARGET_H:
    canvas = Image.new("RGB", (TARGET_W, TARGET_H), PAGE_BG)
    canvas.paste(img, ((TARGET_W - w) // 2, (TARGET_H - h) // 2))
    canvas.save(f"plot-{THEME}.png")

Retrieve this implementation

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

Part of Basic Count Plot on anyplot.ai.

Other implementations