Declarative, native-Qt plotting for data-intensive desktop apps.
Describe a plot once as immutable data, then render it through whichever engine
fits the moment — pyqtgraph (fast, interactive), matplotlib
(publication-quality), or webengine (interactive Plotly in an embedded
browser view). The same plot draws identically on all three, swaps backends at
runtime, and drops into any PySide6 application as a plain QWidget.
Docs: jawjay.github.io/qtviz ·
Install: pip install qtviz (or uv add qtviz)
import numpy as np
import qtviz as qv
x = np.linspace(0, 10, 500)
qv.show(qv.Scatter({"x": x, "y": np.sin(x)}, x="x", y="y"), title="hello")That's a complete program: a real Qt window, a hardware-accelerated scatter,
pan and zoom out of the box. Change one keyword — backend="matplotlib" — and
the same line renders through a different engine.
Most Python plotting either targets the browser or bolts a single rendering engine into Qt. qtviz takes a different position:
- One immutable API, many backends — an
Elementsays what to plot, never how; render it native, publication-grade, or web, and swap at runtime. - Native Qt, not a web app in disguise — real
QWidgets, Qt signals, strict GUI-thread discipline. - Runs 100% offline — no network at render time, ever; the webengine backend bundles its JavaScript from your installed packages, never a CDN.
- Built for large data — dict / NumPy / pandas / Arrow eagerly, Dask / xarray / zarr out-of-core, and Datashader turns 10M+ points into a density raster that re-aggregates as you zoom. All off the GUI thread.
- No dead ends — anything qtviz doesn't model natively (a 3-D surface, a
Sankey) rides in through
RawFigurein the sameView, with the same events.
Every screenshot here is captured from a runnable script — the gallery shows all 37.
pip install qtviz # or: uv add qtviz
pip install "qtviz[matplotlib]" # + the matplotlib backend
pip install "qtviz[webengine]" # + Plotly/Bokeh/HoloViews hosting
pip install "qtviz[all]" # everythingHard dependencies: PySide6, pyqtgraph, numpy. Extras: matplotlib,
webengine, datashader, dask, xarray, hvplot, all.
Twenty-eight immutable plot types share one channel vocabulary — data first, then keyword accessors (a column name, a lazy expression, a callable, or an array):
qv.Scatter(df, x="time", y="temp", color_by="sensor") # auto legend
qv.Curve(df, x="time", y=qv.col("raw") - qv.col("base")) # derived channel
qv.Bars(df, x="region", y="sales", by="year", orient="horizontal")
qv.Heatmap(df, x="day", y="hour", z="load", annotate="auto")
qv.Histogram(df, value="latency", bins="fd")
qv.HLine(4.5, label="alarm") * qv.Span(2, 4) * qv.Text(5, 2, "peak")The everyday figures, declaratively — one grid, one vocabulary
(examples/35_everyday_figures.py).
* overlays on shared axes; + lays out panels. Configure any node with
.opts() — no wrapper construction:
(price * bollinger_band).opts(title="AAPL", y="USD") # layered, labeled
price + volume # side-by-side panels
qv.Layout.mosaic("AAB\nCCB", A=a, B=b, C=c).opts(link_x=True)Spanning panes from an ASCII plan, track ratios, and a figure suptitle.
Choose an engine, let qtviz pick, or mix engines in one window — pan/zoom and subscriptions survive a runtime swap:
view = qv.View(scatter * curve, backend="auto") # "pyqtgraph" | "matplotlib" | "webengine"
view.set_backend("matplotlib") # swap live — keeps zoom + events
layout.addWidget(view) # it's just a QWidgetOne Layout, two engines — pyqtgraph beside matplotlib, one event stream.
Interactions arrive as plain dataclasses, identical on every backend:
view.on(qv.SelectEvent, lambda e: print(e.indices)) # brush → row indices
view.on(qv.HoverEvent, lambda e: print(e.value)) # aggregated value on rasters
view.on(qv.RangeEvent, on_zoom, throttle_ms=50)qv.View(plot, theme=qv.Theme.dark())
qv.View(plot, theme=qv.Theme.from_qt_app()) # match the host app's modeA qv.stream is a thread-safe, append-able source — views update in place.
Past the point where a scatter overplots, raster="datashader" aggregates to
a screen-resolution density image that re-aggregates to the viewport as you
zoom, out-of-core when the data is lazy:
feed = qv.stream({"t": float, "v": float}, window=100_000)
qv.View(qv.Curve(feed, x="t", y="v")) # that's all the wiring
feed.append(t=ts, v=vs) # from any thread
qv.Scatter(big, x="x", y="y", raster="datashader") # 10M points → density rasterA live rolling feed, its datashaded 400k-point history, and a brush-driven
detail panel — examples/34_streaming_telemetry.py.
| Backend | Best for | Notes |
|---|---|---|
| pyqtgraph | real-time, interactive, large data | default; no extra deps |
| matplotlib | figures for print / papers | extra; PNG / SVG / PDF export |
| webengine | rich web charts, escape hatch | extra; Plotly in a Qt WebEngine view |
Backends are registered, never imported by the core — a third-party
backend plugs in through an entry point with zero qtviz edits
(writing a backend). The webengine
backend renders the same elements as interactive Plotly, and RawFigure hosts
any existing Plotly / Bokeh / HoloViews figure with events bridged back:
fig = go.Figure(go.Surface(z=heights)) # beyond the 2-D vocabulary
view = qv.View(qv.RawFigure(fig)) # auto-routes to webengine
view.on(qv.PickEvent, on_pick) # still typed qtviz eventsElement (pure data)
│ negotiation picks a Backend from hints + capabilities + data size
▼
resolve pipeline turns channel accessors into arrays
│ (off the GUI thread for lazy / datashaded data)
▼
Backend renderers build native primitives — pyqtgraph items,
│ matplotlib artists, Plotly traces
▼
RenderHandle owns the QWidget + a typed EventBus;
survives backend swaps
Backends and data adapters are registered, never imported by the core, so
every new engine or container type is purely additive. The full design record
— specification, architecture, decision log — lives in
design/.
37 runnable scripts in
examples/, from
hello-world to linked dashboards, reactive crossfilters, HoloViews/hvplot
adapters, and out-of-core xarray cubes — every one screenshotted in the
gallery:
uv run python examples/01_hello.pyThe linked three-panel dashboard, under sixty lines —
examples/dashboard_native.py.
2.0 is the current stable release. The 71-name public surface is frozen
under a documented policy — semver, a deprecation window, and a test suite
that pins the API, the honor-or-warn contract (an option is honored or warns,
never silently dropped), and the performance ceilings
(stability policy). Fully typed
(py.typed), 980+ tests, mypy-clean.
Issues and PRs welcome — see CONTRIBUTING.md for the uv-based setup, the CI gates, and the project's conventions.










