Edit a pandas DataFrame as an interactive Lattice Grid inside Jupyter, with a live two-way round-trip back to Python.
import pandas as pd
from lattice_grid_jupyter import LatticeGridWidget
df = pd.DataFrame({"name": ["Ada", "Grace"], "score": [91, 88], "active": [True, False]})
w = LatticeGridWidget(df) # renders an editable grid
w # display it in a notebook cell
# ...edit cells in the grid...
w.df # the DataFrame, reflecting your edits (dtypes preserved)Built on anywidget, so it works in JupyterLab, classic Notebook, VS Code, Colab, and anywhere ipywidgets render.
pip install lattice-grid-jupyterDepends on anywidget and pandas. The wheel vendors the published Lattice
Grid bundle (@toclocoinc/lattice-grid@1.70.0) so offline notebooks work with no
network at render time.
The one the version number says. A wheel is numbered <grid version>.<revision>
— 1.69.0.0 carries Lattice Grid 1.69.0 — so the answer is on the tin, and in
Python:
import lattice_grid_pandas
lattice_grid_pandas.GRID_VERSION # '1.69.0' -- read from the vendored bundleAll three packages (lattice-grid-pandas, lattice-grid-jupyter,
lattice-grid-dash) share the number and are released together; the two host
wrappers pin the shared one exactly, so a mixed install is refused rather than
quietly assembled. A revision above 0 is a packaging fix for the same grid.
Every grid release rebuilds these wheels automatically, so pip install --upgrade lattice-grid-jupyter gets you the current grid.
Two ways the browser gets the grid JS/CSS, chosen per widget:
| Mode | How | When |
|---|---|---|
| CDN (default) | dynamic import() from jsDelivr at render time |
online notebooks; smallest saved-notebook size |
Vendored (offline=True) |
the bundle text ships in the widget model | air-gapped / offline notebooks |
w = LatticeGridWidget(df) # CDN (default)
w = LatticeGridWidget(df, offline=True) # vendored, no network neededThe default is CDN. Vendored mode carries ~3 MB of grid JS in the widget model,
which inflates a saved .ipynb; prefer CDN when you have network. (An alternative
not implemented here would serve the vendored asset over Jupyter's own static
handler instead of the comm; see Notes below.)
dtypes -> grid column types
| pandas dtype | grid column type |
|---|---|
int*, Int* (nullable), float* |
number |
bool, boolean (nullable) |
boolean |
datetime64 (naive or tz-aware) |
timestamp (ISO 8601) |
category, object, string, timedelta |
text |
Index
- A default
RangeIndex(0..n-1)is not shown as a column (it adds nothing). - A named / non-default index becomes one read-only leading column.
- A
MultiIndexbecomes one read-only column per level. - Row identity is a stable, positional id (
__row_id__), never the index label — so duplicate index labels and every other index shape work correctly and edits always land on the right row.
Missing values — NaN, NaT and pd.NA all serialize to JSON null.
Every committed cell edit in the grid flows back to w.df, cast to the column's
dtype (an int column stays int, etc.). Index columns are read-only.
w.set_data(new_df) # replace the whole frame, repaint
keys = w.append_rows([{...}, ...]) # append rows (DataFrame or list-of-dicts)
w.append_rows(other_df)
w.delete_rows(keys) # delete by the keys append_rows returnedappend_rows / delete_rows push incremental changes to the grid
(grid.rows.apply({add|remove})); set_data does a full grid.rows.load().
Row keys are stable, so keys returned by append_rows stay valid for
delete_rows and for later edits.
The frame is serialized column-major ({field: [values...]}), not as a
list-of-dicts:
- field names are not repeated per row (roughly halves the payload for wide frames);
- each column is built with a vectorized pass, not
DataFrame.iterrows()— which is the real reason a naive list-of-dicts build stalls at 100k rows; - the grid's memory source virtualizes rendering, so only the visible window is ever in the DOM.
This handles a 100,000-row frame without freezing (see tests/ and the demo
notebook). For frames far larger than fit in the browser (millions of rows), the
grid also exposes paged / remote / stream source modes whose fetch
callback would page back into the Python kernel over the comm — that is the
documented extension point, not wired in this release.
The grid renders fully and unwatermarked on localhost (the normal
data-science case) with no key — that is the free tier. A widget served from
a non-localhost origin (a deployed notebook server, Voila, Binder on a public
host) should pass a key:
w = LatticeGridWidget(df, licence="LG-...")The key is threaded straight into createGrid({ licence }); grid.licence.state()
resolves to localhost, licensed, or trial.
pip install -e ".[dev]"
pytest tests/test_serialize.py tests/test_roundtrip.py # Python round-trip (no browser)
pytest tests/test_smoke_browser.py # real-browser smoke testThe smoke test drives a real Chromium via Playwright: it renders the widget's front end, performs a real edit in the grid, and asserts the edit payload reached the (mocked) model — the one gap the headless Python tests cannot cover. It uses the vendored bundle, so it needs no network. If no browser is available it is skipped (never faked).
- Vendored mode transfers the bundle through the widget comm; serving it over Jupyter's static handler would keep saved notebooks small — a future option.
set_dataassumes the same schema (columns/index shape). A different schema should use a fresh widget.- Binary (Arrow/typed-array) transfer would beat columnar-JSON for very wide numeric frames; columnar-JSON is what ships here.
Built with Lattice Grid, a JavaScript data grid with a Data Router: one live feed keeps grids, charts, boards, Gantt and KPI tiles in step. Documentation · Demos · Licence