Docs / Build Workflow

Visualization — grid (alias: table)

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, default true. Set to false to suppress click-to-filter for this grid.

Pagination

Pagination options live under the top-level pagination block:

  • pagination.page_size — integer. Rows per page. Default 25. 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. Default 8. 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:

  1. Declare p_page_size::number and p_page_offset::number on the source signature.
  2. Slice inside the SQL with those parameters — LIMIT @page_size OFFSET @page_offset, or the equivalent row-number window shown below.
  3. Return the total in a column named __looky_total_rows (typically COUNT(*) 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 emphasis block can declaratively highlight a row matching a related cross-filter value.
  • Disable per viz with grid.cross_filter: false (the flag lives in the grid block — grid has no chart block).

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.columns subset and explicit grid.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.