When to use grid
Grid is the right choice when the audience needs to read individual records, export data, or verify the detail behind a summary. The type identifier is grid — there is no table type; validation rejects it.
Use a chart-style viz (bar, line) when the question is about a comparison or a trend rather than the raw rows. Use report_matrix when the records need grouping with subtotals or are designed for PDF export.
Mapping
Mapping for grid is minimal — the columns come from the query result.
mapping.columns— optional. Array of column names. The subset to display, in the order given. Use this to drop noisy columns or to force a specific column order without changing the query. When omitted, every column in the query result is rendered, in query order.
mapping:
columns:
- order_date
- category
- brand
- country
- status
- revenue
grid block
Grid options live under the top-level grid block. Grid does not have a chart block.
Column display
grid.column_widths— object mapping column name to a fixed width ("120px"), proportional ("25%"), or numeric (pixel) value.grid.frozen_columns— number of leftmost columns to freeze, or an array of column names. Useful when the grid scrolls horizontally and the audience needs the row identifier always visible.grid.nowrap_columns— array of column names that should never wrap; overflow shows ellipsis.grid.labels— object mapping column name to display label (overrides the raw column name in the header).
Composite cells
grid.composite_columns renders one cell as multiple lines drawn from other fields:
grid:
composite_columns:
customer:
lines:
- field: name
class: font-semibold
- field: city
prefix: "📍 "
show_empty: false
Comparison cells
grid.comparison_columns— array of column names to render as up / down / dash trend indicator next to the value. Useful for delta columns where the audience needs the direction at a glance.
Formats
grid.column_formats— object mapping column name to a format key.grid.formats— object mapping format key to a pattern. Two-step indirection lets you reuse the same pattern across many columns.
Cross-filter
grid.cross_filter— boolean, defaulttrue. Set tofalseto suppress click-to-filter for this grid.
Pagination
Pagination options live under the top-level pagination block:
pagination.page_size— integer. Rows per page. Default25. Pick larger when the audience does data-export work; smaller when scanning is the typical use.pagination.column_page_size— integer. When the visible columns exceed this, a horizontal column-pager kicks in. Default8. Aliases:columns_per_page,columns_page_size.
Pagination is server-side: the pagination block sets the page window, and the model applies it. Enabling it takes three things — the YAML block above, plus two in the model:
- Declare
p_page_size::numberandp_page_offset::numberon the source signature. - Slice inside the SQL with those parameters —
LIMIT @page_size OFFSET @page_offset, or the equivalent row-number window shown below. - Return the total in a column named
__looky_total_rows(typicallyCOUNT(*) OVER()). This is what the pager reads to know how many pages exist.
Changing pages re-runs the underlying query with the new page parameters. Cost characteristics differ by adapter — see Source adapter differences. The working shape:
##! experimental.parameters
source: ec_orders_paged(
p_page_offset::number is null,
p_page_size::number is null
) is ecommerce.sql("""
WITH ranked AS (
SELECT
ROW_NUMBER() OVER (ORDER BY created_at DESC, order_id) AS ranking,
order_id, status, sale_price, created_at
FROM `bigquery-public-data.thelook_ecommerce.order_items`
),
tot AS (
SELECT COUNT(*) AS __looky_total_rows FROM ranked
)
SELECT r.order_id, r.status, r.sale_price, r.created_at, t.__looky_total_rows
FROM ranked AS r
CROSS JOIN tot AS t
WHERE r.ranking > COALESCE(SAFE_CAST(@page_offset AS INT64), 0)
AND r.ranking <= COALESCE(SAFE_CAST(@page_offset AS INT64), 0)
+ GREATEST(1, COALESCE(SAFE_CAST(@page_size AS INT64), 25))
ORDER BY r.ranking ASC
""") extend {
view: main is { select: * }
}
The grid sends the page window through those parameters on every page change. Note that the parameters are declared as p_page_size / p_page_offset but referenced in SQL as @page_size / @page_offset — the p_ prefix is dropped on the SQL side. The COALESCE wrappers supply the values for the first load, before any page has been chosen.
Pagination together with cross-filtering
Give a grid that needs both row pagination and cross-filtering its own model file. Pagination works over the aggregated result with LIMIT / OFFSET in SQL, which is a different grain from the line-level shared source the rest of the dashboard uses, and a .sql("""…""") source stands on its own rather than extending an imported table source.
That model combines both mechanisms — SQL pagination plus an SQL-level filter parameter — while the other vizs keep using the shared model:
##! experimental.parameters
source: orders_paged(
p_page_offset::number is null,
p_page_size::number is null,
p_country::string is null
) is ecommerce.sql("""
WITH agg AS (
SELECT category, country, SUM(sale_price) AS revenue
FROM `bigquery-public-data.thelook_ecommerce.order_items` oi
JOIN `bigquery-public-data.thelook_ecommerce.users` u ON oi.user_id = u.id
WHERE (@country IS NULL OR @country = '' OR @country = 'all' OR u.country = @country)
GROUP BY category, country
)
SELECT *, COUNT(*) OVER() AS __looky_total_rows
FROM agg
ORDER BY revenue DESC
LIMIT GREATEST(1, COALESCE(SAFE_CAST(@page_size AS INT64), 25))
OFFSET COALESCE(SAFE_CAST(@page_offset AS INT64), 0)
""") extend {
view: main is { select: * }
}
The filter parameter is SQL-level here so the predicate runs before the aggregation and the slice, and it carries the same guard as the page parameters so the first load returns the unfiltered first page. See Cross-filtering for the full parameter-scope rules.
format
The grid.column_formats + grid.formats pair is the primary way to format columns. The root format field acts as a fallback for any column not covered. Use the indirection pattern when the same number style applies to many columns:
grid:
column_formats:
revenue: currency
avg_order_value: currency
refunds: currency
item_count: integer
formats:
currency: "$#,##0.00"
integer: "#,##0"
Cross-filter behavior
- Clicking a cell cross-filters the rest of the dashboard by the column name and clicked value. A column is clickable exactly when its name is declared as a parameter on a source signature in this grid's own model file — there is no separate per-column toggle.
- The top-level
emphasisblock can declaratively highlight a row matching a related cross-filter value. - Disable per viz with
grid.cross_filter: false(the flag lives in thegridblock — grid has nochartblock).
See Cross-filtering for the full mechanism.
Worked examples
Order detail with a frozen first column, header labels, currency formats, and row + column pagination:
id: ec_orders_detail_grid
title: Order Detail
query: "models/ec_fulfillment.malloy::detail"
type: grid
mapping:
columns:
- order_date
- category
- brand
- country
- status
- item_count
- revenue
- avg_order_value
grid:
frozen_columns: 1
labels:
order_date: Date
item_count: Items
revenue: Revenue
avg_order_value: Avg order
column_widths:
order_date: "120px"
revenue: "140px"
column_formats:
revenue: currency
avg_order_value: currency
item_count: integer
formats:
currency: "$#,##0.00"
integer: "#,##0"
pagination:
page_size: 50
column_page_size: 8
published: true
Customer roster with composite cells:
id: customers_grid
title: Customers
query: "models/customers.malloy::roster"
type: grid
grid:
composite_columns:
customer:
lines:
- field: name
class: font-semibold
- field: email
prefix: "✉ "
- field: city
prefix: "📍 "
show_empty: false
column_widths:
customer: "260px"
lifetime_value: "140px"
column_formats:
lifetime_value: currency
formats:
currency: "$#,##0.00"
pagination:
page_size: 25
published: true
Design notes
- Control horizontal space with a
mapping.columnssubset and explicitgrid.column_widths— width keys match the query-result field names exactly. - Sorting across all rows lives in the Malloy query — with server-side pagination, a client-side sort sees one page.
- Header text comes from
grid.labels(or a rename in the query). - The comparison indicator reads the sign of the value — encode "good" deltas as positive, "bad" as negative.