task guide
Render a Parquet file in a JavaScript grid
A Parquet file, written once by a pandas job, is queried where it sits: a grid's sort or filter becomes one SQL statement DuckDB answers, and only the rows on screen cross into the browser. Below is a live grid over ten million rows, and the complete standalone files for your own file.
Ten million rows, live
This grid never receives ten million transactions. The Parquet file sits next to the page, DuckDB reads it in the tab, and the grid receives the hundred rows it is about to paint. Filter, sort or scroll and the panel beside the grid prints the exact SQL statement DuckDB was given, with the time it took, measured in this browser rather than written into the page.
Open the full demo, including the React and Angular versions of the same pattern, or the step-by-step how-to for how the SQL is built, one clause at a time.
The complete files
A pandas job writes a Parquet file once, df.to_parquet("orders.parquet"), and puts it
next to a static page. The page never parses it: it hands a DuckDB-Wasm connection to
duckdbAdapter, wraps that in createPushdownSource, and gives the source to
createGrid. Two files, both complete below: index.html loads the grid and
declares the mount points, grid.js connects DuckDB and configures the grid.
index.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orders, queried from a Parquet file</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.70.0/lattice-grid.min.css"
/>
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#toolbar { padding: 12px; display: flex; gap: 8px; }
#grid { height: 480px; }
</style>
</head>
<body>
<div id="toolbar">
<button id="by-region" type="button">Group by region</button>
<button id="clear" type="button">Clear</button>
</div>
<div id="grid"></div>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.70.0/lattice-grid.min.js"></script>
<script src="./grid.js"></script>
</body>
</html>
grid.js
// orders.parquet was written once by pandas: df.to_parquet("orders.parquet").
// It never comes into this page as JSON; DuckDB-Wasm reads it where it sits.
(async () => {
const duckdb = await import('https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0/+esm');
const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
const worker = await duckdb.createWorker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(duckdb.LogLevel.ERROR), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
const connection = await db.connect();
await connection.query('LOAD httpfs;'); // lets DuckDB read part of the file over HTTP, not all of it
// DuckDB-Wasm resolves a bare relative path against its own worker, not
// this page, so the URL is made absolute here first.
const FILE = new URL('./orders.parquet', location.href).href;
const adapter = LatticeGrid.duckdbAdapter({
connection,
from: `read_parquet('${FILE}')`,
});
const source = LatticeGrid.createPushdownSource({ adapter, compute: LatticeGrid, pageSize: 100 });
const grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'order_id',
columnDefaults: { sort: true },
columns: [
{ field: 'order_id', title: 'Order' },
{ field: 'region', title: 'Region' },
{ field: 'sku', title: 'SKU' },
{ field: 'quantity', title: 'Qty', type: 'number' },
{ field: 'revenue', title: 'Revenue', type: 'number', format: 'currency' },
],
source,
});
document.getElementById('by-region').addEventListener('click', () => {
grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
});
document.getElementById('clear').addEventListener('click', () => {
grid.filters.clear();
});
})();
Grouping by region above pushes down the same way: grid.filters.set() becomes a
WHERE clause, DuckDB answers it, and the grid never sees a row that does not match.
Nothing about the columns, the formatting or the grid's own behaviour differs from a grid built over
an in-memory array; only where the rows come from changes.
What actually crosses the wire
Measured in Chrome against a ten million row, 162.4 MB Parquet file served from a range-capable origin, counting the bytes the origin actually sent: showing the first hundred rows pulled 5.71 MB rather than the whole 162.5 MB file. A selective filter on two columns pulled 45.2 MB, or 3.2 MB with the running total turned off, against 191.1 MB for the same filter read in full. Those are the two shapes of query this pattern answers: a page of rows, and a filtered page with a count, both read as a slice of the file rather than the whole of it.
That saving is not unconditional. A session that ends up sorting the whole table on an unindexed column still has to read the whole file to do it, the same as loading it all up front would, and overlapping range reads across a long session can end up paying for some bytes twice. It is a trade that wins for the query shapes a grid actually makes, browsing and filtering, not a claim that every byte is free.
What next
For grouping, credentialled buckets and everything else the adapter pushes down, see the DuckDB adapter guide. For a Parquet file that fits comfortably in memory and you want the whole matching set for subtotals rather than a page at a time, see a hundred thousand rows in DuckDB. To read the same kind of file back into pandas instead of a browser grid, see edit a DataFrame in the browser.