mrkeyoor.com_
Thu 06 Aug 13:51 UTC
PyPIDataupdated 06 Aug 2026

altair

Vega-Altair is a Python charting library that does not draw anything. You describe a chart as data plus encodings, and Altair emits a Vega-Lite JSON specification that a browser renders. So alt.Chart(df).mark_point().encode(x='Horsepower', y='Miles_per_Gallon', color='Origin') is not a drawing instruction, it is a statement that horsepower maps to the x position, mileage to y, and origin to color. Vega-Lite decides the scales, axes, legend, and colors from the data types you declare. The payoff is that composition is grammar rather than API surface: chart_a | chart_b puts two charts side by side, chart_a & chart_b stacks them, chart_a + chart_b layers them, and .facet() splits one chart into a grid. Interactivity works the same way. A selection is a named parameter you add to a chart and then reference from an encoding or a transform_filter, and the resulting pan, zoom, brush, and cross-filter behavior runs entirely in the browser with no callback server. Version 6 compiles against Vega-Lite 6 and takes data through Narwhals, so pandas, Polars, and PyArrow tables all work without pandas being a required dependency.

Verdict

The clearest way to express a statistical chart in Python, and by far the cheapest way to get linked interactive views into a notebook. Budget for the row limit and the vl-convert install before you promise anyone a PNG.

API stability4/5The grammar has been stable since version 3 and old chart code mostly still runs, but the recent majors moved real ground: 5.5 replaced alt.condition with alt.when().then() and alt.themes with alt.theme, and 6.0 bumped to Vega-Lite 6 and dropped Python 3.9. Deprecations get warnings with the replacement spelled out, which is more than most projects do
Docs5/5altair-viz.github.io has a gallery of several hundred runnable examples, a user guide that teaches the grammar rather than listing methods, and a page per encoding channel and transform; the reference is generated from the same schema the library validates against, so it cannot drift from the code
Maintenance4/5Pushed 2026-08-01 under the vega organization with weekly dev builds published to PyPI and a stated move to smaller, more frequent releases starting at 6.2.0; 124 open issues out of 150 open issues and PRs. The ceiling is that most feature requests are really Vega-Lite requests and have to be fixed upstream first
Ecosystem4/5About 12.7M downloads a week, first-class rendering in JupyterLab, VS Code, Colab, Streamlit, and Marimo, and a real extension layer in vl-convert-python, VegaFusion, altair-tiles, and anywidget; it is still a smaller community than matplotlib or plotly, so niche questions land on the Vega-Lite issue tracker rather than Stack Overflow

Use it if

  • You work in Jupyter, VS Code notebooks, or Marimo and want charts that pan, zoom, and cross-filter in the output cell with no callback server and no running Python process behind them
  • You are building linked views: one selection_interval brush on a scatter plot filtering a histogram next to it is about six extra lines, and the same idea in matplotlib is an event-handler project
  • Your data is in Polars or PyArrow rather than pandas: since the Narwhals rewrite, pandas is not a required dependency and Altair reads the frame you already have
  • You want the chart to be data rather than pixels: chart.to_dict() gives you a JSON spec you can store, diff in a pull request, hand to a JavaScript front end, or paste into the online Vega-Lite editor
  • You want good defaults without arguing about them: axis titles, legends, color scales for nominal versus quantitative fields, and binning all follow from the type you declare on each encoding
Skip it if

Setup reality

pip install altair pulls jinja2, jsonschema, narwhals, and packaging, and needs Python 3.10 or newer as of 6.2.2. That base install renders in notebooks and nothing else. The extras are the actual decision. altair[save] adds vl-convert-python for chart.save('x.png'), which is a large wheel because it bundles a rendering engine. altair[all] additionally pulls pandas, numpy, pyarrow, vegafusion, anywidget, and altair-tiles, and is the fastest way to a working environment if you do not care about size. Two things surprise people after install. First, nothing renders outside a notebook or an HTML page, so a script that ends in a bare chart object appears to do nothing; use chart.save('out.html') or chart.show(). Second, the 5000 row cap fires on the frame you pass in, not the number of marks drawn, so a groupby that reduces to twelve bars still fails if you hand Altair the raw table and aggregate inside the chart. Aggregate in the dataframe, or enable VegaFusion so the transforms run in Python before the spec is written. If you are coming from Altair 5, alt.condition is replaced by alt.when().then().otherwise(), alt.themes is now alt.theme, and version 6 requires Vega-Lite 6 in any custom JavaScript embed you maintain.

Patterns

A chart is data, a mark, and encodingsfirst-chart

import altair as alt
from altair.datasets import data

cars = data.cars()          # pandas by default

chart = (
    alt.Chart(cars)
    .mark_point()
    .encode(
        x="Horsepower:Q",
        y="Miles_per_Gallon:Q",
        color="Origin:N",
        tooltip=["Name:N", "Year:T"],
    )
    .properties(width=500, height=300, title="Power vs mileage")
)

chart.save("cars.html")     # in a notebook, just let it be the last expression

The :Q, :N, :O, :T suffixes are the encoding type and they change the output, not just the label. :Q gives a continuous axis, :N an unordered color palette, :O an ordered discrete scale, :T time parsing. Get one wrong and you get a categorical color ramp over 400 distinct horsepower values. In a plain script nothing renders: the object only becomes a picture via save(), show(), or a notebook renderer. altair.datasets is new in version 6 and fetches over the network the first time.

Aggregate with encoding shorthand instead of groupbyaggregate-in-the-chart

import altair as alt

# shorthand form
alt.Chart(cars).mark_bar().encode(
    x="mean(Miles_per_Gallon):Q",
    y="Origin:N",
)

# explicit form, when you need an alias or a filter afterwards
alt.Chart(cars).mark_bar().encode(
    x=alt.X("avg_mpg:Q", title="Average MPG"),
    y="Origin:N",
).transform_aggregate(
    avg_mpg="mean(Miles_per_Gallon)",
    groupby=["Origin"],
)

# count() needs no field
alt.Chart(cars).mark_bar().encode(x="count():Q", y="Cylinders:O")

Both forms run in the browser, which means the whole unaggregated table is still serialized into the spec and still counts against the 5000 row limit. That is the single most common surprise: three bars on screen, MaxRowsError on the way there. If the input is large, do the groupby in pandas or Polars first, or enable VegaFusion so the aggregation happens in Python.

MaxRowsError and the three real fixesrow-limit

import altair as alt

# 1. best: reduce before charting
small = df.groupby("category", as_index=False)["amount"].sum()
alt.Chart(small).mark_bar().encode(x="category:N", y="amount:Q")

# 2. point at a URL, so rows never enter the notebook
alt.Chart("https://cdn.example.com/sales.csv").mark_line().encode(
    x="date:T", y="amount:Q",
)

# 3. push transforms back into Python (pip install "vegafusion>=2")
alt.data_transformers.enable("vegafusion")

# last resort, and it is a foot-gun
alt.data_transformers.disable_max_rows()

The default limit is 5000 rows and it exists because every row is inlined as JSON in the notebook output. Turning it off does not make the chart faster, it makes your .ipynb hundreds of megabytes and often crashes the browser tab. When you use a URL the file has to be reachable from the reader's browser, not from your kernel, so a local path will not work outside a live server.

Layer, concatenate, and facet with operatorscompose-charts

import altair as alt

base = alt.Chart(stocks).encode(x="date:T", y="price:Q", color="symbol:N")

line = base.mark_line()
points = base.mark_circle(size=40)

line + points                      # layer, same axes
line | points                       # side by side (hconcat)
line & points                       # stacked (vconcat)
alt.layer(line, points)             # explicit forms
alt.hconcat(line, points)

# one small chart per category
base.mark_line().facet(column="symbol:N", columns=3)

# repeat the same chart over several fields
alt.Chart(cars).mark_point().encode(
    x=alt.X(alt.repeat("column"), type="quantitative"),
    y="Miles_per_Gallon:Q",
).repeat(column=["Horsepower", "Weight_in_lbs", "Acceleration"])

Layered charts share scales by default, which is what you want for line plus points and wrong for two series with different units; use .resolve_scale(y='independent') to split them. Faceting a chart that already has its own .properties(width=...) works, but faceting one that has been layered and then given a title can raise a schema error, so build composition from the inside out.

Brush on one chart, filter anotherinteractive-selection

import altair as alt

brush = alt.selection_interval()

points = (
    alt.Chart(cars)
    .mark_point()
    .encode(
        x="Horsepower:Q",
        y="Miles_per_Gallon:Q",
        color=alt.when(brush).then("Origin:N").otherwise(alt.value("lightgray")),
    )
    .add_params(brush)
)

bars = (
    alt.Chart(cars)
    .mark_bar()
    .encode(x="count():Q", y="Origin:N", color="Origin:N")
    .transform_filter(brush)
)

points & bars

alt.when(brush).then(...).otherwise(...) is the version 5.5 and later spelling; alt.condition(brush, a, b) still works but emits a deprecation warning. add_params goes on the chart the user interacts with, transform_filter on the chart that reacts. Forget add_params and you get a chart with no brush and no error message. All of this runs in the browser, so the Python kernel can be dead and the interaction still works.

Click a legend, or bind a sliderlegend-and-widget-params

import altair as alt

# click the legend to filter
legend_sel = alt.selection_point(fields=["Origin"], bind="legend")

alt.Chart(cars).mark_point().encode(
    x="Horsepower:Q",
    y="Miles_per_Gallon:Q",
    color="Origin:N",
    opacity=alt.when(legend_sel).then(alt.value(1)).otherwise(alt.value(0.05)),
).add_params(legend_sel)

# an HTML slider bound to a filter
year = alt.param(
    value=1970,
    bind=alt.binding_range(min=1970, max=1982, step=1, name="Year: "),
)

alt.Chart(cars).mark_point().encode(
    x="Horsepower:Q", y="Miles_per_Gallon:Q",
).add_params(year).transform_filter("year(datum.Year) == " + "toString(year_param)")

Shift-click on a legend entry selects several categories. Filtering on a param uses a Vega expression string where datum is the current row, and that string is evaluated by Vega, not Python, so f-strings that inject Python values silently freeze them into the spec. Give params an explicit name= when you reference them in expressions, otherwise Altair generates one like param_7 and your expression breaks on the next re-run.

Pass Polars or PyArrow, not just pandaspolars-and-arrow

import altair as alt
import polars as pl

df = pl.read_parquet("events.parquet").group_by("day").len()

alt.Chart(df).mark_bar().encode(x="day:T", y="len:Q")

# datasets module honours the same choice
from altair.datasets import data
cars_pl = data.cars(engine="polars")
data.set_default_engine("polars")

Since the Narwhals rewrite, Altair does not import pandas unless your data is a pandas object, so a Polars-only environment stays Polars-only. The catch is dtype handling: Polars temporal columns with timezones serialize correctly now, but categorical and duration types can still land as strings in the spec, so declare the encoding type explicitly rather than trusting inference on exotic dtypes.

Export PNG, SVG, or PDFsave-static-image

# pip install "altair[save]"      -> pulls vl-convert-python

chart.save("chart.png", scale_factor=2.0)
chart.save("chart.svg")
chart.save("chart.pdf")
chart.save("chart.html")            # no extra dependency
chart.save("chart.html", inline=True)   # bundle the JS, works offline

import vl_convert as vlc
png_bytes = vlc.vegalite_to_png(chart.to_json(), scale=2)

HTML is the only format the base install can write. PNG, SVG, and PDF all go through vl-convert-python, whose wheel is large because it embeds a rendering engine, and which is the usual reason an Altair image export works locally and fails in a slim CI container. scale_factor is the only way to get a high-resolution raster; there is no dpi argument. inline=True matters for HTML you email to someone, since the default output fetches Vega from a CDN at open time.

Set defaults once instead of per charttheme-and-config

import altair as alt

alt.theme.enable("dark")            # 'default', 'dark', 'ggplot2', 'quartz', ...

@alt.theme.register("house", enable=True)
def house() -> alt.theme.ThemeConfig:
    return {
        "config": {
            "view": {"continuousWidth": 480, "continuousHeight": 300},
            "axis": {"labelFontSize": 12, "titleFontSize": 13, "grid": False},
            "range": {"category": ["#264653", "#2a9d8f", "#e9c46a", "#e76f51"]},
        }
    }

# per chart, same keys
chart.configure_axis(labelAngle=-45).configure_legend(orient="bottom")

alt.themes was renamed to alt.theme in 5.5 and the old name warns. Config keys are Vega-Lite names in camelCase, not Python snake_case, and an unknown key is silently ignored rather than rejected, which is why a styling change can appear to do nothing. configure_* methods only work on a top-level chart: calling them on a subchart before layering raises an error.

Get the brushed rows back into the kernelread-selection-in-python

# pip install anywidget
import altair as alt

brush = alt.selection_interval(name="brush")
chart = (
    alt.Chart(cars).mark_point()
    .encode(x="Horsepower:Q", y="Miles_per_Gallon:Q")
    .add_params(brush)
)

jchart = alt.JupyterChart(chart)
jchart                       # display, then drag a box

# in a later cell
jchart.selections.brush.value      # {'Horsepower': [90, 150], ...}

def on_change(change):
    print(change.new)
jchart.selections.observe(on_change, ["brush"])

A plain rendered chart is one-way: the browser never talks back. JupyterChart is the escape hatch and it needs anywidget plus a live kernel, so it does nothing in a static HTML export or on nbviewer. Name the selection explicitly, because the attribute on .selections is that name. Interval selections give you ranges per field, point selections give you the selected values, and you still do the dataframe filtering yourself.

The chart is JSON you can store and shipspec-json-roundtrip

import altair as alt

spec = chart.to_dict()              # validated against the Vega-Lite schema
chart.to_json(indent=2)
chart.to_url()                      # opens in the online Vega-Lite editor

# hand a spec to a front end, or rebuild it later
alt.Chart.from_dict(spec)

# skip validation while debugging a spec that will not build
chart.to_dict(validate=False)

to_dict() is where SchemaValidationError comes from, so a chart that builds fine and then fails on display is failing here. validate=False gets you the spec so you can read it, which is usually faster than the error text at finding a misplaced key. The output includes your data inline unless you used a URL or a data transformer, so treat a saved spec as a copy of the dataset for privacy and size purposes.

Choropleth from a TopoJSON sourcegeographic-maps

import altair as alt
from altair.datasets import data

counties = alt.topo_feature(data.us_10m.url, "counties")
unemp = data.unemployment.url

alt.Chart(counties).mark_geoshape().encode(
    color=alt.Color("rate:Q", scale=alt.Scale(scheme="blues")),
).transform_lookup(
    lookup="id",
    from_=alt.LookupData(unemp, "id", ["rate"]),
).project(type="albersUsa").properties(width=600, height=400)

mark_geoshape needs TopoJSON or GeoJSON, not a dataframe of latitudes, and alt.topo_feature names the object inside the topology; get that name wrong and you render an empty canvas with no error. GeoPandas frames work directly but serialize every geometry into the spec, so simplify before charting or the 5000 row limit becomes the least of your problems. Tile basemaps are a separate package, altair-tiles.

Alternatives

PackageRegistryPick it when
plotlyPyPIYou want interactive browser charts plus 3D, maps, and a Dash app story, and you do not mind a much larger JavaScript payload per figure
seabornPyPIYou want statistical plots as static images for a paper or a PDF report and you are already comfortable dropping into matplotlib for the details
bokehPyPIYou need interactivity that calls back into Python, such as widgets that recompute a dataframe and push new data to a running server
matplotlibPyPIYou need exact control over every element, an unusual plot type, or output that must work with no browser anywhere in the pipeline