Reactive data apps that actually ship.
A high-performance reactive Directed Acyclic Graph (DAG) framework for Python. Golit maps your data dependencies once, then on every interaction recomputes only the nodes that changed — not your whole script.
Documentation · Changelog · Examples · Benchmarks · Architecture · Deployment
Golit pairs the authoring speed of a notebook-style data app with the runtime discipline of a compiled reactive graph. Your dashboard is a set of plain Python functions; Golit infers the dependency graph between them, and a Rust kernel ensures a user interaction re-runs the minimal subgraph it touched and ships only the affected HTML fragments over the wire. The guiding thesis: update cost is proportional to the change, not the size of the program.
- Rust reactive kernel (PyO3) — dirty tracking, topological scheduling, memoized propagation.
- Polars data held Python-side; only node ids/hashes cross the FFI boundary. Nodes may return LazyFrames — plans fuse across node boundaries and collect once at the view, with an opt-in path to the streaming engine for larger-than-memory data.
- SQL nodes — reactive nodes written as in-process DuckDB SQL over Polars frames (
golit.sql). - Litestar orchestration with HTMX server-rendered fragment transport — no client framework.
- Charts — Lets-Plot static SVG, plus interactive Plotly / Altair / Bokeh / AnyChart.
- Tables — styled tables from Polars, or return a Great Tables
GTobject for a polished, auto-rendered display table. - Maps — native MapLibre GL: GeoDataFrame vector, rioxarray/xarray raster, and DuckDB spatial SQL (
golit.gis). - Components — reactive input widgets plus a shadcn-styled
golit.uilibrary, server-rendered with Tailwind. - Realtime — SSE push with pluggable pub/sub (in-memory or Redis), WebSocket chat, video (server-side MJPEG + browser-camera CV), and audio (mic recorder).
- Live sources —
@app.pollstreams external data that changes on its own (a Google Sheet, an API); only real changes re-render and hit the wire. - Scales horizontally — N single-worker instances behind a sticky load balancer with Redis fan-out.
Golit ships prebuilt wheels for Python 3.11+ (the abi3 stable ABI):
pip install golit # core
pip install "golit[charts]" # interactive Plotly / Altair / Bokeh
pip install "golit[sql]" # DuckDB SQL nodes over Polars frames
pip install "golit[gis]" # native MapLibre maps from GeoDataFrames
pip install "golit[gis-raster]" # raster maps from rioxarray/xarray arrays
pip install "golit[tables]" # Great Tables display tables (auto-rendered from a view)
pip install "golit[vision]" # webcam / MJPEG video streams (Pillow)
pip install "golit[vision-cv]" # + OpenCV for real CV models (face detection)
pip install "golit[redis]" # Redis fan-out for multi-workermake dev # uv venv (3.11) + deps + build the Rust kernel (maturin)
make test # cargo test + pytest
make run # golit run examples/sales_explorer/app.pyThen open http://127.0.0.1:8000.
Nodes are plain Python functions. Dependencies are inferred from parameters: a parameter named after another node is an edge; a parameter defaulting to a widget is an input.
import polars as pl
from golit import App, create_app, slider, upload
from golit.charts import aes, geom_bar, ggplot, ggsize
app = App(title="Sales Explorer")
SAMPLE = pl.DataFrame({"region": ["North", "South", "East", "West"], "revenue": [120, 90, 70, 40]})
@app.source
def data(file=upload("Upload CSV")) -> pl.DataFrame:
return SAMPLE if file is None else pl.read_csv(file) # renders before any upload
@app.reactive
def filtered(data: pl.DataFrame, threshold: int = slider(0, 100, default=20)) -> pl.DataFrame:
return data.filter(pl.col("revenue") > threshold) # re-runs only when data/threshold change
@app.view
def chart(filtered: pl.DataFrame):
return ggplot(filtered, aes("region", "revenue")) + geom_bar(stat="identity") + ggsize(640, 360)
application = create_app(app) # an ASGI app; `golit run app.py` serves itMoving the slider dirties threshold → filtered → chart. The data node is never touched; only
the chart fragment is re-rendered and swapped. A view that depends only on data (e.g. a dataset
overview) is not re-rendered on a slider move — that selective recompute is the whole point. See
examples/sales_explorer/app.py.
- POST in — a committed input (
hx-post) runs the dirty subgraph; the response carries only the affected view fragments as out-of-band HTMX swaps. - SSE out — nodes dirtied server-side (streaming sources, background jobs, shared nodes) are
pushed over
/eventsas namednode:<id>events. - Memoization — a node re-executes only when its inputs hash differently; an unchanged output cascades into memo hits downstream (nothing on the wire).
The thesis is update cost is proportional to the change, not the program — and the benchmark harness measures it (dev laptop, loopback; reproducible, not yet the published cloud figure). The honest summary:
- A single filter → chart updates in ~2 ms over HTTP — the same as Dash. Idiomatic Dash is a hand-wired reactive DAG, so on one chain both do identical work and tie. Saying otherwise would be a strawman.
- The gap opens on the shape real dashboards have: shared upstream work. When one expensive step (a load, a join, a sort) feeds several views, moving a control that touches only one view makes Golit re-run only that view — the shared upstream is memoized and executes zero times — while Dash recomputes it every callback. Over real HTTP that's ~1.6× faster at 100K rows, ~5.5× at 1M, ~8.3× at 2M, and the lead widens with the app.
chart_specskips the figure object. Returning a raw spec dict instead of ago.Figurecuts the per-update round-trip to ~1.5 ms and ~635 B (vs ~6.9 KB) — ~1.4× faster than figure-returning Dash with a ~10× smaller payload, same chart.
See bench/README.md for the methodology and one-command repros.
A reactive node can be written as SQL instead of Polars. golit.sql(query, **frames) runs DuckDB
in-process over the named upstream frames and returns Polars, so the node memoizes and renders like
any other — inputs feed straight into the query.
from golit import sql
@app.reactive
def by_region(data: pl.DataFrame, threshold: int = slider(0, 200, default=40)):
return sql(
"SELECT region, sum(revenue)::BIGINT AS revenue "
f"FROM d WHERE revenue > {int(threshold)} GROUP BY region ORDER BY region",
d=data,
)DuckDB exchanges data with Polars zero-copy. A raw duckdb.sql(...) relation returned from a node is
auto-detected and materialized too. Optional dependency: pip install "golit[sql]"; it is imported
only inside sql(), never at framework import time. See
examples/duckdb_sql/app.py.
Lets-Plot renders to static SVG (no client runtime). For interactivity, return a Plotly, Altair,
or Bokeh figure — Golit auto-detects it and renders a client-side chart that hydrates on the
initial load and across POST/SSE swaps. AnyChart (no Python package) is available via
anychart().
@app.view
def chart(by_region):
import plotly.express as px
return px.bar(by_region, x="region", y="revenue") # Polars frame in directlyFor a view that rebuilds its chart on every interaction, chart_spec(lib, dict) hands Golit the raw
wire-format spec directly, skipping the figure-object build and to_json (see
Performance). See examples/charts_gallery/app.py.
A map is a reactive view like any other — a control rebuilds it. Return a GeoPandas
GeoDataFrame and Golit renders a native MapLibre GL map; golit.gis.geo_map adds
choropleths, tooltips, and basemaps, and golit.gis.spatial_sql runs DuckDB ST_* queries that feed
it. No client map framework — the server ships the GeoJSON and the style rules; the GPU draws. For
large vector data, golit.gis.vector_tiles keeps the GeoDataFrame server-side and streams MVT
vector tiles (pip install "golit[gis-vector-tiles]") so 100k+ features render without inlining
the whole GeoJSON.
import golit.gis as gis
@app.view
def map(regions): # regions is a filtered GeoDataFrame
return gis.geo_map(regions, color="revenue", tooltip=["name", "revenue"])Raster works too: gis.raster(dataarray) colormaps a georeferenced rioxarray/xarray array (or
GeoTIFF) to a MapLibre image layer, gis.rgb(stack, bands=…) renders a multiband raster as a
true/false-color satellite composite (pip install "golit[gis-raster]"), and
gis.tiles("scene.tif") streams a very large COG as on-demand z/x/y tiles via rio-tiler
(pip install "golit[gis-tiles]") — only the visible window crosses the wire. And
gis.terrain(dem, "hillshade") runs WhiteboxTools terrain analysis (slope, flow accumulation, …)
into a renderable raster (pip install "golit[gis-terrain]"), and gis.ee_layer(image, vis=…)
overlays Google Earth Engine imagery as live tiles (pip install "golit[gis-ee]"). Install
vector with pip install "golit[gis]" (DuckDB spatial rides on the sql extra). Moving a control
re-runs only the filter + map node — the fragment swaps in place on the initial load and after a
POST/SSE. See
examples/geo_explorer/app.py (vector),
examples/vector_tiles/app.py (60k-feature vector tiles),
examples/raster_explorer/app.py (raster),
examples/rgb_composite/app.py (RGB composite),
examples/tiled_raster/app.py (tiled COG),
examples/terrain_analysis/app.py (terrain), and
examples/earth_engine/app.py (Earth Engine).
Some views don't re-render on a change — they hold a live connection. golit.ui.chat(channel)
opens a WebSocket-backed chat panel (@app.on_message adds bot/moderation logic). For computer
vision, @app.stream(name) + ui.webcam(name) push a server-side MJPEG feed the browser plays
in a plain <img> — a host camera, a detector drawing boxes, a synthetic animation — and
shared=True fans one producer out to many viewers. The mirror, @app.on_frame(name) +
ui.camera(name), streams the visitor's own webcam up over a WebSocket, runs your handler on each
frame server-side, and paints the annotated result back.
import numpy as np
import golit.ui as ui
@app.on_frame("faces")
def detect(frame: np.ndarray) -> np.ndarray: # (H, W, 3) uint8 RGB in and out
... # run your model, draw boxes
return frame
@app.view
def live() -> str:
return ui.camera("faces", title="Your camera")Frames are JPEG bytes or (H, W, 3) RGB arrays (encoded with Pillow). Sync handlers run in a
worker thread; one frame is in flight at a time, so a slow model lowers the rate instead of backing
up, and a producer/handler that errors is logged without dropping the stream. pip install "golit[vision]" (or [vision-cv] for OpenCV). See
examples/webcam_stream (server feed),
examples/browser_camera (browser camera), and
examples/face_detect (real OpenCV face detection).
For audio, ui.recorder(name) captures the visitor's mic and uploads each clip as 16-bit WAV
(with inline playback + a download link for the clip); the @app.on_audio(name) handler decodes it
(Python's stdlib wave — no ffmpeg) and returns a result to show, or audio to play back. See
examples/audio_recorder and
Audio recording.
Inputs (reactive) — slider, number, select, text, checkbox, upload, radio,
multiselect, switch, date, textarea, button.
Display (golit.ui) — card, columns, grid, tabs, expander, accordion, divider,
metric, scorecard, alert, badge, progress, skeleton, spinner, table, markdown,
code, json_view, heading, caption.
Realtime (golit.ui) — chat, webcam, camera, recorder.
import golit.ui as ui
@app.view
def panel(by_region, total):
return ui.card(
ui.columns([ui.metric("Revenue", f"${total:,}", delta="+8%"), chart_fig]),
title="Overview",
)Components compose through the renderer, so any argument can be a DataFrame, a chart figure, another
component, or trusted HTML. See
examples/components_gallery/app.py.
By default views stack under one controls panel. golit.layout arranges the reactive view fragments
into a sidebar, rows, tabs, etc. — the layout is static scaffold, so each view keeps its id and
still swaps in place on POST/SSE.
from golit import layout as L
app.layout = L.Sidebar(
L.Controls(), # all inputs, in the sidebar
L.Stack(
L.Row(L.View("kpi"), L.View("status")),
L.Tabs({"Chart": L.View("chart"), "Data": L.View("table")}),
),
)References are validated at build time: every View/Control must resolve to a real view/input and
be placed at most once.
A single process needs nothing extra. To scale horizontally, set GOLIT_REDIS_URL and run N
single-worker instances behind a sticky (session-cookie) load balancer — session state is
worker-local by design, and Redis fans server-side invalidations across the fleet. A runnable
podman/nginx stack lives in deploy/.
pip install "golit[redis]"
export GOLIT_REDIS_URL=redis://localhost:6379
golit run examples/sales_explorer/app.pySee DEPLOYMENT.md for the full topology and why uvicorn --workers can't provide
session affinity.
| Tier | Role | Technology |
|---|---|---|
| 0 | Reactive kernel | Rust + PyO3 (src/, golit._golit) |
| 1 | Orchestrator | Litestar + SSE/Redis fan-out (golit.server) |
| 2 | Transport | HTMX fragments; static SVG, interactive charts, native maps (golit.rendering) |
| 3 | Local shield | Alpine.js (widget immediacy, tab state) |
See project_scope.md for the full architecture and
golit_benchmark.md for the benchmark methodology.
v1.0.0 — first stable release (see the CHANGELOG).
Built end-to-end and green (17 cargo + 209 pytest, ruff + mypy clean): Rust kernel, reactive
engine, rendering (static and interactive charts, native MapLibre maps, auto-rendered Great
Tables), the golit.ui component library, page layout, DuckDB SQL nodes, GIS (vector maps + MVT
vector tiles for large data; single-band, RGB-composite, tiled-COG raster maps; WhiteboxTools
terrain; Earth Engine overlays; spatial SQL — golit.gis), Litestar server (POST + SSE), Redis
pub/sub fan-out, multi-worker deployment, live polled sources (@app.poll), realtime WebSocket chat,
video (server-side MJPEG streams + browser-camera CV), and audio (mic recorder), the benchmark
harness (bench/, with measured Golit-vs-Dash results), and the examples.
Deferred: a standard-cloud-instance benchmark publication and the wider design suite in
golit_pages/.
make dev # set up venv + build extension
make test # cargo test + pytest
make lint # ruff + mypy
make build # release wheelGolit is released under the Apache License 2.0. © 2026 Daniel Boadzie.