# Looky Studio — Documentation (full Markdown dump, language: en) > Authoritative reference for building Looky workspaces, models, visualizations, dashboards, filters, and exports. Each page below is also available as Markdown by appending `.md` to the URL. > Concatenation of every documentation page in Markdown form. Pages are separated by `---PAGE---` boundaries; each page begins with its own YAML frontmatter. ---PAGE--- --- title: Getting Started slug: docs/getting-started language: en description: "Start from zero in Looky: accept your invitation, sign in, connect the CLI, create the first workspace and invite other developers." last_modified: "2026-06-11T14:16:34.867000+00:00" docs_section: getting-started docs_summary: The day-zero path for a new owner or builder entering Looky for the first time. --- ## Two roles, two starting points Your invitation determines what you need to do on day one. Pick the path that matches your role. BA Owner / Account Operator ### Explore and govern Accept invitation → sign in → set billing context → list workspaces in your billing account → open dashboards in the UI. No BigQuery setup or model authoring required. Any workspaces your developers have published to the billing account are ready to explore in the UI. Developer / Analytics Engineer ### Build and publish Accept invitation → sign in → set billing context → set up BigQuery service account → create workspace → author sources, models, visualizations, dashboards → push. This guide uses BigQuery as the starting path, so a BigQuery service account is set up before you write models. Postgres and MySQL are equally supported — see [Sources](/docs/build-workflow/sources) if your data lives there instead. ## Prerequisites before running commands - You have an invitation to the target account in `https://my.looky.studio`. - The Looky CLI is installed and available as `looky` in your shell. - You have a local folder that will act as the linked root (example: ``). - You know the billing account id you should operate in (example: ``). ## Day-zero sequence 1. Accept invitation and verify your role and scope ([Owner Invitation]({{ elemental_url_for_slug(slug='docs/getting-started/owner-invitation') }})). 2. Sign in to the UI, then link CLI to the same instance and root ([Sign In and Connect the CLI]({{ elemental_url_for_slug(slug='docs/getting-started/access-instance') }})). 3. **Developers only:** Mint a GCP service account JSON — you will drop it into the workspace's `secrets/` folder once the workspace exists in the next step ([BigQuery Dataset Access]({{ elemental_url_for_slug(slug='docs/getting-started/bigquery-setup') }})). 4. Set billing context, create the workspace, configure `runtime/sources.runtime.yml`, and deploy settings with `looky push --settings` ([Create Your First Workspace]({{ elemental_url_for_slug(slug='docs/getting-started/create-first-workspace') }})). 5. Invite additional builders only after the first workspace has its sources deployed and at least one push of content succeeds ([Invite Developers and Collaborators]({{ elemental_url_for_slug(slug='docs/getting-started/invite-developers') }})). ## Baseline command flow macOS / Linux Windows (PowerShell) ``` looky login https://my.looky.studio looky whoami cd looky billing list looky billing use cd / looky create --name "My Workspace" cd looky status # Edit runtime/sources.runtime.yml with real source values and drop # the credentials JSON into secrets/. Then deploy: looky push --settings # Build content under content/ (models, viz, dashboards). Then: looky validate looky push ``` ``` looky login https://my.looky.studio looky whoami Set-Location looky billing list looky billing use Set-Location \ looky create --name "My Workspace" Set-Location looky status # Edit runtime\sources.runtime.yml with real source values and drop # the credentials JSON into secrets\. Then deploy: looky push --settings # Build content under content\ (models, viz, dashboards). Then: looky validate looky push ``` ## Success criteria before moving to Build Workflow - `looky whoami` returns your authenticated user for the linked root. - `looky billing use ` succeeds from ``. - `looky status` resolves a workspace id in `/` shape. - `looky push --settings` deploys the runtime source config without errors. If any one of these fails, stop and fix it before authoring dashboards. Most downstream failures are setup failures in disguise. Running `looky validate` is meaningful only after sources are deployed and you have content to compile. ---PAGE--- --- title: Owner Invitation slug: docs/getting-started/owner-invitation language: en description: How a new billing owner enters Looky, accepts the invitation and understands what access that unlocks. last_modified: "2026-06-11T14:16:42.906000+00:00" docs_section: getting-started docs_summary: Start here if someone invited you to own a billing account and bootstrap the first real working setup. --- ## Why invitation quality matters Invitation is not a formality. It defines the account and permission boundary your CLI and UI sessions will operate in. If invitation scope is wrong, every later step (billing, pull, push) becomes confusing or blocked. ## Acceptance checklist 1. Open the invitation link and verify the target email before accepting. 2. Confirm the invitation type (owner, billing developer, workspace access) matches your intended job. 3. Accept and complete sign-in at `https://my.looky.studio`. 4. Open the areas your role should expose and confirm you can access them without permission errors. ## Role decision guide ### Owner-level invitation Use only for account ownership responsibilities such as governance and broad billing management. ### Billing developer Use when the person needs to create or manage multiple workspaces under one billing account. ### Workspace access Use when the person should only work inside one or a few specific workspaces. ## Common failures and fixes ### Invitation expired or revoked Request a new link. Old links cannot be recovered. ### Email mismatch If invitation email and login email differ, ask the inviter to regenerate with the correct email. ### Role too broad or too narrow Fix invitation type before doing CLI setup. Do not workaround role mismatch with ad-hoc local changes. Exit this page only when role scope is correct. It prevents wasting time troubleshooting commands that are failing for authorization reasons. ---PAGE--- --- title: Sign In and Connect the CLI slug: docs/getting-started/access-instance language: en description: Sign in to the running instance with OTP, validate access and link your local root to the Looky CLI. last_modified: "2026-06-11T14:16:36.617000+00:00" docs_section: getting-started docs_summary: Use OTP in the UI, then authenticate the CLI against the same instance and local root. --- ## Sign in to cloud first Start in `https://my.looky.studio` and complete OTP login before touching CLI. This confirms your role and account access independently from local machine state. 1. Open `https://my.looky.studio`. 2. Authenticate with email OTP. 3. Confirm shell loads without access errors. ## Link CLI to the same instance and local root The CLI stores authentication and billing context per linked root. Pick one stable root folder and keep using it. ``` looky login https://my.looky.studio looky whoami ``` If your CLI asks for email during login, provide the same identity you used in UI. ## Activate billing context from root macOS / Linux Windows (PowerShell) ``` cd looky billing list looky billing use ``` ``` Set-Location looky billing list looky billing use ``` Run billing commands from the linked root itself. Billing context is root-scoped and must be set before workspace operations. ## Verify CLI link before moving on 1. `looky whoami` returns your email and the instance URL. 2. `looky billing list` shows at least one billing account. 3. `looky billing use ` succeeds from ``. Workspace-scoped checks (`looky status`, `looky validate`) belong in the next step once a workspace exists — see [Create Your First Workspace]({{ elemental_url_for_slug(slug='docs/getting-started/create-first-workspace') }}). If a colleague already shared a workspace with you, `looky workspaces` from the linked root lists the ones you can access; you can `cd` into any of them and run workspace commands from there. ## Cloud and local runtime parity Cloud (`my.looky.studio`) is your shared target. Local runtime (`http://localhost:8000`) is optional for quick iteration and debugging. Use both for verification when needed. If cloud and local differ, start with `looky status` and `looky diff` in the workspace root. Most mismatches are context or sync issues, not chart bugs. ---PAGE--- --- title: BigQuery Dataset Access slug: docs/getting-started/bigquery-setup language: en description: Create a GCP service account with minimum permissions, download the JSON key, and wire it into your workspace runtime config. last_modified: "2026-06-11T14:16:38.461000+00:00" docs_section: getting-started docs_summary: Create a service account with minimum permissions and wire credentials into runtime config. --- ## What you need before writing a single model Looky runs Malloy queries against BigQuery on your behalf. For that to work, your workspace needs a GCP service account with enough permission to read data and run jobs. This page walks you through creating one, downloading the credentials file, and placing it where the runtime expects it. You will need two things from GCP: - A **billing project**: the GCP project that pays for query costs. This is your own project. - A **dataset location**: the project and dataset where the data actually lives. This can be a different project — including public datasets like `bigquery-public-data`. These two are often confused. Your service account lives in the billing project, but it can be granted read access to datasets in any other project. ## Step 1: Create a service account in GCP 1. Open the [GCP Service Accounts console](https://console.cloud.google.com/iam-admin/serviceaccounts) and select your billing project. 2. Click **Create service account**. 3. Give it a clear name, for example: `looky-workspace-reader`. 4. Click **Create and continue**. ## Step 2: Grant minimum required roles In the "Grant this service account access to project" step, add these two roles: - **BigQuery Data Viewer** — allows reading table data and schema. - **BigQuery Job User** — allows running query jobs (required even for read-only queries). That is the minimum. Do not add Owner, Editor, or any broader role. Click **Done**. If the data you need to query lives in a different GCP project (for example a shared data warehouse), you also need to add **BigQuery Data Viewer** on that project for this same service account. Do that from the IAM page of the data project, not the billing project. ## Step 3: Download the JSON key 1. In the service accounts list, click the account you just created. 2. Open the **Keys** tab. 3. Click **Add key → Create new key**. 4. Select **JSON** and click **Create**. The file downloads immediately. Rename the file to something readable, for example: `my-workspace-bq.json`. ## Step 4: Place the key in your workspace Copy the JSON key into the `secrets/` folder of your workspace: ``` / / / secrets/ my-workspace-bq.json ← place it here ``` Make sure `.gitignore` excludes `secrets/` before committing anything: ``` cat .gitignore ``` You should see `secrets/` or `secrets/*` listed. If it is not there, add it before pushing to any remote — Looky does not enforce this; it is your responsibility. Never commit the JSON key to git. Anyone with the file can run queries billed to your GCP project. The `secrets/` exclusion exists for exactly this reason. ## Step 5: Reference the key in sources.runtime.yml Open `runtime/sources.runtime.yml` and set `credentials_file` to the JSON filename you dropped into `secrets/`. It is a plain filename — no path, no slashes; the platform resolves it against the workspace's `secrets/` folder. ``` sources: ecommerce: name: The Look Ecommerce type: bigquery project_id: my-gcp-billing-project credentials_file: my-workspace-bq.json datasets: - bigquery-public-data.thelook_ecommerce ``` - `project_id`: the GCP project that pays for query costs — your billing project. - `credentials_file`: filename of the service-account JSON inside `secrets/`. Pattern `^[A-Za-z0-9_-][A-Za-z0-9._-]*$` — no path separators, no leading dot. - `datasets`: one or more dataset references the runtime is allowed to query. These can be in a different GCP project than `project_id`. ## Step 6: Validate the connection From the workspace root, run: ``` looky sources list looky validate ``` If `sources list` returns your alias with no errors and `validate` shows no blocking issues, the source declaration is structurally valid and the runtime can reach BigQuery with the supplied credentials. The default validation pass does **not** verify that the service account has read access to every dataset you reference — those errors surface when a real query touches the dataset. Use `looky validate --strict` to upgrade validation to per-visualization live-source checks (BigQuery's `estimateQueryCost` is free and catches permission issues before push). If validation fails, the most common causes are: - Credentials filename in `sources.runtime.yml` does not match the file in `secrets/`, or contains path separators (it must be a plain filename). - Service account is missing **BigQuery Job User** — queries are blocked even if data is readable. - Dataset reference uses wrong project or dataset name — verify exact names in the BigQuery console. ---PAGE--- --- title: Create Your First Workspace slug: docs/getting-started/create-first-workspace language: en description: Use the Looky CLI to select billing context, create the first workspace and scaffold the local structure correctly. last_modified: "2026-06-11T14:16:39.830000+00:00" docs_section: getting-started docs_summary: Set billing context, create the workspace and start from a clean local skeleton. --- ## The scenario this page covers You have logged in to your looky instance and you have zero workspaces — nobody has shared one with you, and your operator has not seeded a starter. This page walks you through creating your first workspace from scratch and publishing a tiny dashboard so you can see the loop end to end. If a colleague has already shared a workspace with you, you do not need this page — you need [Sources](/docs/build-workflow/sources) and [Publish](/docs/build-workflow/publish) to start contributing. `looky workspaces` from your linked root will tell you which workspaces you actually have access to. ## Before you start This page assumes you have already gone through [Access Your Instance](/docs/getting-started/access-instance) — that is, you have: 1. An instance URL (the one your operator gave you, or `https://my.looky.studio` if you are on the managed deployment) and a working `looky login `. 2. An active billing account set on that linked root (`looky billing list` shows it, `looky billing use ` selects it). 3. A local root directory on disk and a billing subdirectory inside it: `//`. Every workspace you create lives under that path. If any of those is missing, fix it first — the commands below assume the CLI knows which instance to talk to and which billing account to attach the new workspace to. Quick sanity check: run `looky whoami` from `` and confirm it prints the right instance URL and email. Then `cd /` — this is where you will run every command on this page. ## Create the workspace 1. From the billing directory, register the workspace on the server and scaffold the local skeleton in one step: macOS / Linux Windows (PowerShell) ``` cd / looky create --name "My Workspace" --description "First build workspace" ``` ``` Set-Location \ looky create --name "My Workspace" --description "First build workspace" ``` The CLI calls your instance's API to register the workspace (so it appears in the UI and in `looky workspaces`) and then writes the local folder structure under `.//`. You are the workspace owner with write access. 2. Enter the new folder and confirm the CLI resolves it: macOS / Linux Windows (PowerShell) ``` cd looky status ``` ``` Set-Location looky status ``` `looky status` resolves the workspace id as `/` and confirms the CLI is talking to the right instance. Do not run `looky validate` yet — there are no sources deployed and no content to compile, so the result is not informative. Validate after the "Publish and verify" section below. ## Configure your data source Edit `runtime/sources.runtime.yml` to point at the warehouse you want to query. The exact shape is documented in [Sources](/docs/build-workflow/sources); minimal examples follow. **BigQuery** — using the public `thelook_ecommerce` dataset as a playground: ``` sources: ecommerce: type: bigquery project_id: my-gcp-billing-project credentials_file: my-workspace-bq.json datasets: - bigquery-public-data.thelook_ecommerce ``` Drop the service-account JSON in `secrets/my-workspace-bq.json`. See [BigQuery Dataset Access](/docs/getting-started/bigquery-setup) for how to mint that file. **Postgres** — DSN (libpq URI without `user:password@`) plus a credentials JSON in `secrets/`: ``` sources: warehouse: type: postgres dsn: "postgresql://db.example.com:5432/analytics?sslmode=require" credentials_file: warehouse.json ``` Drop the user/password JSON in `secrets/warehouse.json`: ``` {"user": "looky_reader", "password": "..."} ``` **MySQL** — `mysql://host:port/database` DSN (no `user:password@`, no query string) plus a credentials JSON in `secrets/`: ``` sources: shop: type: mysql dsn: "mysql://db.example.com:3306/shop" credentials_file: shop.json ``` Drop the user/password JSON in `secrets/shop.json` (same shape as Postgres). Once the file is in place, push runtime config to the server before any content push: ``` looky push --settings ``` This is the only push that uploads `runtime/**` and `secrets/**`. Every subsequent `looky push` ships content only and assumes the settings are already deployed. ## Minimal files for first dashboard ### `content/models/sales.malloy` ``` ##! experimental.parameters source: sales() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend { dimension: product is product_name measure: sales_amount is sum(sale_price) } query: total_sales is sales() -> { aggregate: sales_amount } query: sales_by_product is sales() -> { group_by: product aggregate: sales_amount order_by: sales_amount desc limit: 10 } ``` ### `content/visualizations/sales_total_kpi.yml` ``` id: sales_total_kpi title: Total Sales query: "models/sales.malloy::total_sales" type: kpi mapping: value: sales_amount published: true ``` ### `content/visualizations/sales_by_product_bar.yml` ``` id: sales_by_product_bar title: Sales by Product query: "models/sales.malloy::sales_by_product" type: bar mapping: x: product y: sales_amount published: true ``` ### `content/dashboards/sales_overview.yml` ``` id: sales_overview title: Sales Overview layout_mode: fluid_grid items: - visualization: sales_total_kpi - visualization: sales_by_product_bar published: true ``` ## Publish and verify ``` looky validate looky diff looky push looky list visualizations looky list dashboards ``` Open your instance URL in a browser (the same one you used in `looky login`), navigate into the workspace, and confirm the `sales_overview` dashboard is visible and renders both items. A green `looky push` means the server accepted the files — not that the dashboard is correct. Always open the UI and look at it before considering the workspace done. ## Expected directory shape ``` / / / workspace.yml content/ models/ visualizations/ dashboards/ exports/ runtime/ sources.runtime.yml secrets/ ``` All CLI workspace commands assume you run from `` root. ---PAGE--- --- title: Invite Developers and Collaborators slug: docs/getting-started/invite-developers language: en description: Bring other people into your billing account or specific workspaces. Invite a developer to give build access at the billing-account level, or a user (viewer) to give read-only access to a workspace. last_modified: "2026-06-11T14:16:41.407000+00:00" docs_section: getting-started docs_summary: Invite developers to your billing account, or users to specific workspaces. --- ## Who you can invite ### Developer Joins your billing account. Can create and manage workspaces, models, visualizations, and dashboards under it. Only the owner of the billing account can send this invitation. ### User (viewer) Joins one or more specific workspaces with read-only access. Can view dashboards and visualizations, cannot edit. The owner or any developer of the billing account can send this invitation, for workspaces under that billing account. A new billing account itself is not granted by invitation — Looky Studio provisions it when a customer signs up. ## What needs to exist before sending an invitation - **Inviting a developer:** just your billing account. The developer can start from an empty billing account and create their workspaces from scratch after accepting. - **Inviting a user (viewer):** at least one workspace in your billing account with at least one visualization or dashboard published. Otherwise the invitee signs in to an empty screen. ## Operational onboarding flow for each new developer 1. Create and send invitation with explicit scope in the message. 2. After acceptance, ask the developer to run: macOS / Linux Windows (PowerShell) ``` looky login https://my.looky.studio looky whoami cd looky billing list looky billing use ``` ``` looky login https://my.looky.studio looky whoami Set-Location looky billing list looky billing use ``` 3. Have the developer enter one assigned workspace and run: macOS / Linux Windows (PowerShell) ``` cd // looky status looky validate ``` ``` Set-Location \\ looky status looky validate ``` 4. Confirm they can read dashboards in UI before asking for writes. Use the narrowest role that still allows the work. This avoids accidental cross-workspace changes and keeps ownership clear. ---PAGE--- --- title: Build Workflow slug: docs/build-workflow language: en description: The end-to-end builder sequence from workspace structure to publication. last_modified: "2026-06-11T14:15:52.618000+00:00" docs_section: build-workflow docs_summary: Follow the builder flow end to end before drilling into individual file types. --- ## The only workflow order that scales Looky is deterministic when you build in this order. If you invert steps, you get ambiguous failures and slow delivery. 1. Workspace structure and context are correct. 2. Runtime source aliases are defined and valid. 3. [Malloy models](/docs/build-workflow/models) expose stable, idiomatic query contracts (semantic layer, named views — not ad hoc SQL scattered across viz YAML). 4. Visualizations map query fields to renderers. 5. Dashboards compose validated visualizations. 6. Validate, diff, push, and verify in UI. ## The canonical chain inside a workspace Every Looky workspace is the same four layers, in this dependency order — read your own workspace files in this order whenever you need to debug or extend it: - `runtime/sources.runtime.yml` declares source aliases (e.g. an alias `ecommerce` pointing at a BigQuery dataset, or a Postgres / MySQL connection string). - `content/models/*.malloy` defines reusable dimensions, measures, and named queries on top of those source aliases. - `content/visualizations/*.yml` binds one model query to one chart/table renderer. - `content/dashboards/*.yml` composes visualizations into the final surfaces the audience sees. Each layer references only the one above it. If a layer fails to validate, fix it before touching anything below — the layers below cannot recover on their own. ## Builder loop you should run every day ``` cd // looky status looky validate looky diff looky push looky list visualizations looky list dashboards ``` Run this loop for every meaningful change. It catches structural issues before users see broken dashboards. ## Definition of done for a content change - `looky validate` has no blocking errors. - `looky diff` only shows intended files. - `looky push` succeeds on target workspace. - Dashboard is visible and renders correctly in `https://my.looky.studio`. ---PAGE--- --- title: Workspaces slug: docs/build-workflow/workspaces language: en description: Workspace folder contract and the minimum structure a builder should preserve. last_modified: "2026-06-11T14:16:33.035000+00:00" docs_section: build-workflow docs_summary: Keep content files, runtime config and deployment handoff separated from the start. --- ## Workspace folder is the delivery boundary Looky CLI resolves context from your current directory. Wrong directory means wrong workspace identity, wrong billing context, and unreliable push results. ## Required directory shape ``` / / / workspace.yml content/ models/ visualizations/ dashboards/ exports/ runtime/ sources.runtime.yml secrets/ ``` ## How to verify workspace context quickly 1. Move to workspace root: ``` cd // ``` 2. Run: ``` looky status looky validate ``` 3. Confirm workspace id is `/`. 4. Open `workspace.yml` and confirm `id` matches folder slug. ## Deleting a workspace Run `looky delete ` from `/`. The CLI prompts for the slug as a confirmation; pass `--yes` to skip the prompt in automation. Who can delete: - **Billing-account owners** can delete any workspace under their billing account. - **super_admin** can delete only workspaces that they themselves created. - **Billing-account developers** cannot delete workspaces — even ones they created. Deletion is a billing-owner decision. What gets removed on the server, atomically: - The workspace directory under `workspaces_root///` (workspace.yml, content, runtime, secrets, runtime caches). - Any in-flight or zombie validation staging under `workspaces_root/.validation-staging///`. - Database rows in `auth_workspace_owners`, `auth_workspace_memberships`, `auth_invitation_workspace_grants`, `catalog_items`, `ui_user_folders`, and `ui_user_folder_items`. What stays: - Your **local** copy under `///`. The CLI leaves it in place and prints an `rm -rf` hint so you decide when to remove it. - Outstanding invitations that grant access to the deleted workspace stay valid for any *other* workspaces they grant — only the row for this workspace's grant is removed. There is no soft-delete or undo. Re-creating the workspace requires `looky create` followed by a fresh `looky push`. ## What breaks when structure is wrong - `looky validate` may fail to resolve local workspace. - `looky push` may target wrong workspace id. - `looky list dashboards` may show unexpected workspace results. Three directory tiers, enforced by the CLI. Commands that create or delete a workspace run one level *above* the workspace folder, because the folder either doesn't exist yet (`create`, `pull`) or is about to disappear (`delete`). - **From `` exactly** — `looky billing list/use`, `looky workspaces`. Running them in a subdirectory is rejected. - **From `/`** — `looky create`, `looky pull`, `looky delete`. - **From any subdirectory under `//`** — everything else (`status`, `validate`, `diff`, `push`, `list`, `sources`). The CLI walks up the path to infer the workspace root, so `content/models/` works too. ---PAGE--- --- title: Sources slug: docs/build-workflow/sources language: en description: Define runtime source aliases before models depend on them. last_modified: "2026-06-11T14:16:13.595000+00:00" docs_section: build-workflow docs_summary: Alias your runtime data sources cleanly so models are stable across environments. --- ## Sources are step one of content reliability Models depend on source aliases. If the alias, credentials filename, or dataset reference is wrong, every model, query, and visualization built on top fails. Configure and validate `runtime/sources.runtime.yml` before writing a single model. If you have not set up your GCP service account yet, do that first. See [BigQuery Dataset Access](/docs/getting-started/bigquery-setup). ## Working example: public BigQuery dataset Working example using the public `bigquery-public-data.thelook_ecommerce` dataset. Copy it as your starting point; replace `project_id` with your own GCP billing project and `credentials_file` with the filename of your service-account JSON (the file you drop into `secrets/`). The dataset itself is public, so anyone with a working BigQuery service account can query it. ``` sources: ecommerce: name: The Look Ecommerce type: bigquery project_id: my-gcp-billing-project credentials_file: my-workspace-bq.json datasets: - bigquery-public-data.thelook_ecommerce ``` Field by field: - `ecommerce`: the alias your Malloy models will use. Pick something short and stable — changing it later breaks every model that references it. - `project_id`: the GCP project that **pays for query costs**. This is your billing project, not necessarily where the data lives. - `credentials_file`: plain filename of the service-account JSON dropped into the workspace's `secrets/` folder. Pattern `^[A-Za-z0-9_-][A-Za-z0-9._-]*$` — no path separators, no leading dot. - `datasets`: the dataset locations the runtime can query. Here the data is in `bigquery-public-data`, a different GCP project from the billing project — that is normal and expected. ## Working example: your own private dataset When data lives in your own GCP project, both `project_id` and the dataset project are the same: ``` sources: sales: name: Sales Data type: bigquery project_id: my-company-gcp-project credentials_file: my-workspace-bq.json datasets: - my-company-gcp-project.sales_warehouse ``` You can define multiple sources in the same file: ``` sources: sales: name: Sales Data type: bigquery project_id: my-company-gcp-project credentials_file: my-workspace-bq.json datasets: - my-company-gcp-project.sales_warehouse marketing: name: Marketing Data type: bigquery project_id: my-company-gcp-project credentials_file: my-workspace-bq.json datasets: - my-company-gcp-project.marketing_events ``` Each alias becomes independently referenceable in models. ## Working example: Postgres source Postgres sources split network coordinates from credentials: the `dsn` is a standard libpq URI (host, port, database, any TLS or pool flags) **without** `user:password@`; the user and password live in a JSON inside `secrets/` referenced by `credentials_file`. The platform reads both, injects the credentials into the DSN in memory, and hands the result to the Postgres driver. `runtime/sources.runtime.yml` therefore never contains a password. ``` sources: warehouse: name: Internal Warehouse type: postgres dsn: "postgresql://pg.internal.example.com:5432/warehouse?sslmode=require" credentials_file: warehouse.json schemas: - reporting - public ``` Drop the matching JSON into `secrets/warehouse.json`: ``` { "user": "looky_reader", "password": "..." } ``` Field by field: - `type`: must be `postgres`. - `name`: optional human label shown in the UI when the source surfaces in operator tools. Purely cosmetic. - `dsn`: libpq URI without userinfo. Must start with `postgres://` or `postgresql://`; the schema regex rejects any URI containing `@` (i.e. `user:password@`). Any libpq query-string parameter — `sslmode`, `application_name`, `connect_timeout`, `target_session_attrs`, `channel_binding`, `gssencmode`, … — flows through unchanged. - `credentials_file`: plain filename of a JSON inside the workspace's `secrets/` folder. Pattern `^[A-Za-z0-9_-][A-Za-z0-9._-]*$` — no path separators, no leading dot. The JSON must declare `{"user": "…", "password": "…"}`. - `schemas`: optional array narrowing introspection. Defaults to all non-system schemas (`pg_catalog` and `information_schema` are always excluded). ## Working example: MySQL source MySQL sources follow the same split as Postgres: the `dsn` is a `mysql://host:port/database` URI **without** `user:password@`, and the user and password live in a JSON inside `secrets/` referenced by `credentials_file`. Unlike Postgres, a MySQL DSN carries **no query parameters** — Looky parses the host, port, and database out of the URI and rejects any `?…`. `runtime/sources.runtime.yml` never contains a password. ``` sources: shop: name: Online Shop type: mysql dsn: "mysql://db.internal.example.com:3306/shop" credentials_file: shop.json schemas: - shop ``` Drop the matching JSON into `secrets/shop.json`: ``` { "user": "looky_reader", "password": "..." } ``` Field by field: - `type`: must be `mysql`. - `name`: optional human label shown in the UI when the source surfaces in operator tools. Purely cosmetic. - `dsn`: `mysql://host:port/database` URI without userinfo. The schema regex rejects any URI containing `@` (i.e. `user:password@`) **or** a `?` query string. The port is optional and defaults to `3306`. - `credentials_file`: plain filename of a JSON inside the workspace's `secrets/` folder. Pattern `^[A-Za-z0-9_-][A-Za-z0-9._-]*$` — no path separators, no leading dot. The JSON must declare `{"user": "…", "password": "…"}`. - `schemas`: optional array of databases to expose for introspection. Defaults to the database named in the `dsn`. Two MySQL specifics worth knowing: connections are **not encrypted** (TLS to MySQL is not configurable yet), so use it over a trusted network; and MySQL has no real boolean type, so boolean-looking columns come back as numbers — add an explicit `cast(… as boolean)` in your model when you need a true/false filter. ## Field matrix per adapter Quick reference of what each adapter requires: - **BigQuery** — required: `type: bigquery`, `project_id`, `credentials_file`; optional: `location`, `datasets` (required at introspection time, not validation time). - **Postgres** — required: `type: postgres`, `dsn` (libpq URI without userinfo), `credentials_file` (filename of a JSON in `secrets/` declaring `{"user","password"}`); optional: `schemas`. - **MySQL** — required: `type: mysql`, `dsn` (`mysql://host:port/database`, no userinfo, no query string), `credentials_file` (filename of a JSON in `secrets/` declaring `{"user","password"}`); optional: `schemas` (defaults to the DSN's database). For the full per-adapter divergence matrix (auth, parameter binding, NULL handling, dryRun, introspection, caching), see [the source adapter comparison](/docs/reference/source-adapters). ## Source validation workflow 1. Edit `runtime/sources.runtime.yml` from your workspace root. 2. Run: ``` looky sources list looky sources diff looky validate ``` 3. Fix any alias, credential, or dataset errors before touching models. `sources list` shows the currently registered runtime aliases. `sources diff` shows what would change on push. `validate` checks structural consistency across the whole workspace. ## Common source mistakes - **Alias mismatch:** model references `sales` but runtime defines `ecommerce`. Every query using that alias fails. - **Wrong credentials filename:** `credentials_file` must be a plain filename inside `secrets/` — no slashes, no path prefix. - **Dataset not in list:** if a model queries a table whose dataset is not listed under `datasets:`, the query will be rejected at runtime. - **Wrong billing project:** if `project_id` does not have **BigQuery Job User** granted to the service account, queries will fail even if data is readable. Never work around source problems inside the model. Fix the alias and runtime config once, and keep models clean. ---PAGE--- --- title: Models slug: docs/build-workflow/models language: en description: "Semantic Malloy layers — strict parameterized shape plus idiomatic structure: reusable dimensions/measures/views, modular files by domain, and stable query contracts for dashboards and tooling." last_modified: "2026-06-11T14:16:10.689000+00:00" docs_section: build-workflow docs_summary: Shared governed logic — mandatory Malloy shape, idiomatic layering, reusable named queries. --- ## Models are your semantic API Put business logic in Malloy models, not in visualization YAML. If a metric is useful in more than one chart, define it once in the model layer and reuse it everywhere. Changing the definition of "revenue" should mean editing one line in one file, not hunting through visualization configs. ## Write Malloy idiomatically — not only valid Malloy Looky requires a strict file shape (pragma at the top of the file, parameterized sources, signature parameters — covered in Minimal model below). Beyond compliance, maintainable workspaces use Malloy for what it is designed for: a clear semantic layer on top of your data. ### Prefer the semantic layer before defaulting to raw SQL Declare **dimensions**, **measures**, and **joins** on `alias.table(...)` or an imported base with `extend`. Put chart-ready analytics in **named views** or top-level queries. Reach for large `alias.sql("""…""")` bodies when legacy SQL bundles the only practical shape of the data, or when you need raw-SQL `@param` substitution (often on Postgres or MySQL — see [Source adapter differences](/docs/reference/source-adapters)). When everything lives inside opaque SQL strings you lose reusability across views and make reviews harder. ### Modularize: scope files by domain Keep unrelated analytics in separate `.malloy` files. Share a stable foundation via `import` and extend once per topic — see the **Reusing a base source** pattern below. Avoid one endlessly growing file that mixes many domains; it is difficult to reuse, audit, or split later. ### Stable names are the contract — for teammates and tooling Visualizations bind to `path/model.malloy::query_or_view_name`; treat those names as APIs. Prefer output field labels that reflect business meaning. Align `p_*` parameter names across models on the same dashboard where they represent the same dimension so filters and cross-filters behave predictably. Structured, named semantics are easier for people reviewing diffs and for assistants or automation locating the authoritative definition. ## Minimal model you can ship today ``` ##! experimental.parameters source: sales() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend { dimension: product is product_name dimension: order_date is created_at::date measure: sales_amount is sum(sale_price) } query: total_sales is sales() -> { aggregate: sales_amount } query: sales_by_product is sales() -> { group_by: product aggregate: sales_amount order_by: sales_amount desc limit: 10 } ``` Two things every model needs: - `##! experimental.parameters` at the top of the file. Looky's engine compiles every model under that flag. - Empty `()` after the source name (`source: sales()`) and after every reference to it (`sales() -> {...}`). Even when the source takes no parameters, the parentheses are required — they declare the parameter list. The source alias (`ecommerce`) must match an alias defined in `runtime/sources.runtime.yml`. The query names (`total_sales`, `sales_by_product`) become the reference handles used in visualization YAML. ## Views: defining named views inside the source For larger models, define named views inside the source using `view:` instead of top-level `query:` declarations. Views live inside the source and can reference its dimensions and measures directly. ``` ##! experimental.parameters source: ec_revenue() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend { dimension: category is products.category dimension: order_month is created_at::month measure: revenue is sum(sale_price) measure: order_count is count(order_id) view: over_time is { group_by: order_month aggregate: revenue, order_count order_by: order_month asc } view: by_category is { group_by: category aggregate: revenue, order_count order_by: revenue desc limit: 12 } } ``` A visualization references a view exactly like a top-level query: ``` query: "models/ec_revenue.malloy::over_time" ``` Use views when all queries belong to the same semantic source. Use top-level queries when you need to reference multiple sources or run cross-source joins — and remember to invoke the source with `()` in the top-level form (`query: x is ec_revenue() -> {...}`). ## Parameters: wiring dashboard filters to model queries Dashboard filters control queries through parameters. Declare the parameter on the source signature (between the parentheses) and the dashboard filter passes its value through at render time. ``` ##! experimental.parameters source: ec_orders( p_cutoff_date::date is @2024-01-01 ) is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend { measure: revenue is sum(sale_price) ? created_at <= p_cutoff_date view: kpi is { aggregate: revenue } } ``` Each parameter takes a name, a type, and a default — `p_cutoff_date::date is @2024-01-01`. Common types: `date`, `string`, `number`, `boolean`. The default is what the engine uses when the dashboard does not provide a value (and what `looky validate` uses to dry-run the model). The dashboard filter binds to the parameter by name: ``` # in the dashboard YAML filters: - id: global_period type: cutoff_date granularity: year param: p_cutoff_date # must match the parameter name in the model default: "{{today}}" ``` When a user changes the filter, the parameter value is passed to every query in the dashboard that references it. ### Strict `@param` contract for raw-SQL sources If your source is built from raw SQL (`connection.sql("""…""")`) and the SQL uses `@param` placeholders, every `@param` must have a matching declaration on the source signature. There is no implicit fallback — undeclared placeholders fail validate with a clear error pointing at the missing declaration. ``` ##! experimental.parameters source: nv_asesor_kpi_sql( p_date_from::date is null, p_date_to::date is null ) is natural_vitality_ventas.sql(""" WITH date_range AS ( SELECT COALESCE(CAST(@date_from AS DATE), DATE '2026-02-01') AS sd, COALESCE(CAST(@date_to AS DATE), DATE '2026-02-28') AS ed ) -- ... """) extend { view: kpi_asesor_resumen is { select: * } } ``` The placeholders `@date_from` and `@date_to` in the SQL match the declarations `p_date_from` and `p_date_to` on the source signature (the `p_` prefix is conventional). The dashboard's filter passes the value at render time; validate uses the declared default. ## Reusing a base source across models The pattern is: define a parametric base source once and extend it in each domain model. This avoids duplicating join definitions, dimension declarations, and parameter declarations across every file. ``` # ec_orders_base.malloy — shared foundation ##! experimental.parameters source: ec_orders_base() is ecommerce.table('...order_items') extend { join_one: products is ecommerce.table('...products') on product_id = products.id measure: revenue is sum(sale_price) measure: order_count is count(order_id) } # ec_revenue.malloy — domain model extends the base ##! experimental.parameters import "ec_orders_base.malloy" source: ec_revenue() is ec_orders_base() extend { view: by_category is { group_by: products.category aggregate: revenue limit: 12 } } ``` Keep the base model stable. Add new views in domain-specific files, not in the base. Both the base source and the extending source need their own `()` — and the extending source must invoke the base with `()` too (`is ec_orders_base() extend {...}`). ## How a model's source alias is resolved A Malloy model uses a connection alias when it calls `alias.table(...)` or `alias.sql(...)`. Looky matches each alias against the workspace's `runtime/sources.runtime.yml` declarations. - If the model uses one alias, Looky picks it automatically. - If the model uses several aliases (directly or through imports), the run must say which one to use; otherwise it is rejected. See [Sources](/docs/build-workflow/sources) for the alias-declaration syntax per adapter. ## Adapter notes for model authors Looky bundles a specific, pinned Malloy version with the BigQuery, Postgres, and MySQL adapters. There are no Looky-specific Malloy extensions beyond what that Malloy version documents for those adapters. The most common adapter difference in models is around **date and timestamp parameters**. On Postgres and MySQL, prefer the raw-SQL `@param` placeholder pattern (paired with a matching declaration on the source signature, as shown above) so Looky substitutes the value into the SQL string instead of binding it natively. See [Source adapter differences](/docs/reference/source-adapters) for the full pattern. ## Model quality checklist - `##! experimental.parameters` at the top of every `.malloy` file. - Every `source:` declaration uses parentheses, even when empty (`source: foo() is …`). - Every `@param` placeholder in raw SQL has a matching declaration on the source signature. - Source alias exists in `runtime/sources.runtime.yml` (see [Sources](/docs/build-workflow/sources)). - View and query names are stable — renaming them breaks visualization references. - Field names reflect business meaning, not chart formatting needs. - Parameters are declared with sensible defaults so queries work without a filter. - Each view or query can be reused by multiple visualizations. - Prefer declarative `table` + `extend` + views before relying on very large `sql(...)` sources, except where legacy shape or Postgres `@param` patterns require them. - Scope files to one domain or a deliberate shared base — avoid cramming unrelated queries into a single monolithic `.malloy` file. ``` looky validate looky diff ``` Do not start visualization work until model validation is clean. ---PAGE--- --- title: Malloy support slug: docs/build-workflow/malloy-support language: en description: "Authoritative reference for the Malloy subset Looky runs: pinned version, BigQuery, Postgres, and MySQL adapters, named parameters, error classes, and the absence of custom Looky extensions." last_modified: "2026-06-11T14:16:09.133000+00:00" docs_section: build-workflow docs_summary: Pinned Malloy version, supported adapters, parameter binding rules. --- ## The Malloy version Looky runs Looky ships a specific, pinned version of Malloy with the BigQuery, Postgres, and MySQL adapters at the same version. Any Malloy syntax beyond what that version supports is not understood; any feature added in later Malloy releases is not available until Looky upgrades. There are **no Looky-specific Malloy extensions**. The dialect you write is what Malloy itself documents — no more, no less. ## Supported adapters - **BigQuery** — connects with a service-account JSON key. - **Postgres** — connects with a libpq connection string (host / port / database, plus any TLS or pool flags) and a user/password secret. - **MySQL** — connects with a `mysql://host:port/database` connection string and a user/password secret. Two caveats: connections are not encrypted (no TLS option yet), and MySQL has no real boolean type, so boolean columns read back as numbers — cast explicitly when you need a true/false filter. No other adapters are bundled. See [Sources](/docs/build-workflow/sources) for the per-adapter source YAML schema. ## How models are loaded Each workspace's content is rooted at `workspaces///content/`. Looky resolves `.malloy` files relative to that root. `import "..."` statements inside a model are resolved against sibling `.malloy` files in the same directory. Use imports to share a base source declaration across multiple domain models — declare the source and its joins once, extend it per topic. ``` # models/orders_base.malloy — shared foundation ##! experimental.parameters source: orders_base() is bigquery.table('...order_items') extend { join_one: products on product_id = products.id measure: revenue is sum(sale_price) } # models/revenue.malloy — domain model extends the base ##! experimental.parameters import "orders_base.malloy" source: revenue() is orders_base() extend { view: by_category is { group_by: products.category aggregate: revenue } } ``` Every model needs the `##! experimental.parameters` pragma at the top and parentheses on every source declaration (and on every reference to a source) — even when the source takes no parameters. The empty `()` declares the parameter list. ## Source aliases inside models A Malloy model uses a connection alias when it calls `alias.table(...)` or `alias.sql(...)`. Looky matches each alias against the workspace's `runtime/sources.runtime.yml` declarations. - If the model uses one alias, Looky picks it automatically. - If the model uses several aliases (directly or through imports), the run must say which one to use; otherwise it is rejected. See [Sources](/docs/build-workflow/sources) for the alias-declaration syntax per adapter. ## Named parameters Filter and cross-filter values reach a query through named parameters declared on the source signature, between the parentheses. Each parameter takes a name, a type, and a default — `p_country::string is "all"`. There is one naming rule: parameters whose Malloy name starts with `p_` have the prefix stripped for the external name. A model parameter declared as `p_start_date` is set by sending `start_date` from the dashboard filter. ``` ##! experimental.parameters source: orders( p_country::string is "all", p_start_date::date is @2024-01-01 ) is bigquery.table('...') extend { view: revenue is { where: (p_country = "all" or country = p_country) and created_at::date >= p_start_date aggregate: revenue is sum(sale_price) } } # filter / pill sends params: country: "MX" start_date: "2024-12-01" ``` Parameters can be referenced two ways inside the source body: - **Malloy expressions** — write `p_country`, `p_start_date` directly in `where:`, `aggregate:`, etc. (the example above). - **Raw-SQL `@param` placeholders** — when the source body is built from raw SQL (`connection.sql("""…""")`), use `@param` placeholders inside the SQL block. Every `@param` must have a matching declaration on the source signature; otherwise validate fails with a clear error. ## Date and timestamp parameters on Postgres and MySQL Postgres and MySQL share a known limitation when Malloy binds a date or timestamp parameter natively in some query shapes. The reliable path on both is the **raw-SQL `@param` pattern**: build the source from `connection.sql("""…""")`, reference `@param` placeholders inside the SQL, and declare each placeholder on the source signature. ``` ##! experimental.parameters source: orders( p_date_from::date is null, p_date_to::date is null ) is postgres.sql(""" select * from orders where (@date_from::date is null or created_at::date >= @date_from::date) and (@date_to::date is null or created_at::date <= @date_to::date) """) extend { view: revenue is { aggregate: revenue is sum(sale_price) } } ``` Looky substitutes the `@date_from` / `@date_to` placeholders with literal values (or typed `NULL` when the dashboard hasn't supplied a value). On BigQuery and MySQL the same pattern works — the substitution is dialect-aware. See [the source adapter comparison](/docs/reference/source-adapters) for the full divergence list. ## Cache and parameters Each cached entry is scoped to one query in one model with one specific parameter combination. A user filtering by "MX" gets a cached result for that specific combination; a user filtering by "AR" triggers a separate cache entry the first time, then hits cache on subsequent loads. Editing the `.malloy` file invalidates every cached entry for queries inside it on the next request. See [Cache](/docs/build-workflow/cache) for the cache sidecar shape. ## Worked patterns #### String parameter with an "all" sentinel ``` ##! experimental.parameters source: orders( p_status::string is "all" ) is bigquery.table('...') extend { view: detail is { where: p_status = "all" or status = p_status select: * } } ``` #### Date range parameter pair (Malloy-expression style) ``` ##! experimental.parameters source: orders( p_date_from::date is @2024-01-01, p_date_to::date is @2024-12-31 ) is bigquery.table('...') extend { view: revenue is { where: created_at::date >= p_date_from and created_at::date <= p_date_to aggregate: revenue is sum(sale_price) } } ``` #### Date range parameter pair (raw-SQL `@param` style) ``` ##! experimental.parameters source: orders( p_date_from::date is null, p_date_to::date is null ) is postgres.sql(""" select * from orders where (@date_from::date is null or created_at::date >= @date_from::date) and (@date_to::date is null or created_at::date <= @date_to::date) """) extend { view: revenue is { aggregate: revenue is sum(sale_price) } } ``` The `@date_from` / `@date_to` placeholders are substituted at run time with the values from the filter (or with typed `NULL` when the dashboard hasn't supplied a value). #### Month string parameter ``` ##! experimental.parameters source: orders( p_month::string is "2024-12" ) is bigquery.table('...') extend { view: monthly is { where: format_datetime('%Y-%m', created_at) = p_month aggregate: revenue is sum(sale_price) } } ``` ## Common pitfalls - **Missing `##! experimental.parameters` or missing `()` on the source.** Validate fails with `unsupported_model_shape`. Add the pragma at the top of the file and parentheses on every `source:` declaration (and every reference to it). - **Raw-SQL `@param` with no matching declaration on the source signature.** Validate fails with `unbound_param` naming the missing parameter. Add it to the parentheses, e.g. `p_date_from::date is null`. - **Parameter name mismatch between filter and model.** The filter sends the external name; the model receives the prefixed (`p_`) Malloy name. Audit both sides. - **The query fails when the parameter is unset.** Declare a default in the model so the query still runs. For "all" semantics, default to a sentinel value the where-clause treats as no-op. - **The model uses a Malloy feature the engine does not know.** Looky pins one Malloy version. Newer features fail at compile time. Stick to features documented in that version. - **Two models on the same dashboard declare different parameter names for the same dimension.** Cross-filter clicks on that dimension only narrow the model that uses the matching name. Standardise the parameter names across models on the same dashboard. - **The cache returns stale data.** Cache is keyed by parameter combination; if the data changed but the parameter combination is the same, the cached entry still wins until its TTL expires (or the model file changes). ---PAGE--- --- title: Query Cache slug: docs/build-workflow/cache language: en description: Add a cache sidecar file next to any model to avoid re-running the same BigQuery queries on every dashboard load. last_modified: "2026-06-11T14:15:54.086000+00:00" docs_section: build-workflow docs_summary: Add a .cache.yml sidecar next to a model to control query freshness and reduce BigQuery costs. --- ## Why cache matters Every dashboard load triggers Malloy queries against BigQuery. Without cache, the same aggregations run on every page view — which means cost and latency scale with traffic, not with how often your data actually changes. For most analytical workloads, data changes once a day or less. Adding a cache with a reasonable TTL makes dashboards fast for users and predictable in cost for you. ## How it works: the sidecar file Cache is configured per model using a sidecar file placed alongside the `.malloy` file. The sidecar has the same name as the model, with a `.cache.yml` suffix: ``` content/ models/ ec_revenue.malloy ec_revenue.cache.yml ← cache config for ec_revenue.malloy ec_performance.malloy ec_performance.cache.yml ``` If no sidecar exists, queries run live on every request. ## Working example ``` # content/models/ec_revenue.cache.yml model: models/ec_revenue.malloy defaults: cache: mode: auto ttl_seconds: 1800 ``` Field by field: - `model`: path to the model this cache config applies to, relative to the workspace root. - `defaults.cache.mode`: set to `auto`. This is the only supported mode in the current version. It caches query results keyed by query name and parameter combination. - `defaults.cache.ttl_seconds`: how long cached results are valid. After this time, the next request triggers a fresh query and repopulates the cache. `1800` = 30 minutes. ## Choosing a TTL Match TTL to how often the underlying data actually changes: - Daily batch pipelines: `ttl_seconds: 86400` (24 hours) - Hourly refreshes: `ttl_seconds: 3600` (1 hour) - Near real-time: skip cache or use `ttl_seconds: 300` (5 minutes) - Reports and document dashboards: `ttl_seconds: 1800` (30 minutes) is a safe default Setting a very short TTL on heavy queries does not make them fresher — it just makes them expensive. Match TTL to the actual data freshness SLA, not to how often users open the dashboard. ## Cache and dashboard filters Cache keys include the query parameters passed by dashboard filters. A user filtering by "2024" gets a cached result for that specific parameter combination. A user filtering by "2023" triggers a separate cache entry the first time, then hits cache on subsequent loads. This means dashboards with many distinct filter combinations will have a larger cache warm-up cost. For document dashboards with a fixed default date, the cache is typically warm within one load. ## What invalidates a cached entry Each cached entry is scoped to one query in one model with one specific parameter combination. Editing the `.malloy` file invalidates every cached entry for queries inside it on the next request — there is no manual eviction step. ## Adapter differences Cache behavior itself is the same on all three adapters. Cost implications differ: - **BigQuery** — every uncached run is a billed scan; favouring longer TTLs on stable queries dominates the cost equation. - **Postgres and MySQL** — the cost is mostly per-roundtrip latency; caching helps less when the underlying tables are small and well-indexed. See [the source adapter comparison](/docs/reference/source-adapters). ---PAGE--- --- title: Visualizations slug: docs/build-workflow/visualizations language: en description: Bind reusable views to model queries without pushing business logic back into the visualization layer. last_modified: "2026-06-11T14:16:14.968000+00:00" docs_section: build-workflow docs_summary: Keep each visualization focused on presentation and query selection, not metric reinvention. --- ## What a visualization YAML is for A visualization file binds one Malloy query to one chart or table. Every transformation belongs in the model query; the visualization YAML is presentation only — chart type, which fields go where, formatting, optional emphasis. For how to shape that Malloy layer (dimensions, measures, views, modular files), see [Models](/docs/build-workflow/models). For the per-type reference (every `chart.*` property each viz type accepts), see [Visualization Types](/docs/build-workflow/viz-types) and the page for each type underneath. ## Required top-level fields - `id` — workspace-unique identifier. Stable over time: dashboards reference it, so renaming breaks dashboards. - `title` — human-readable title shown in the dashboard. - `type` — viz type discriminator. Must be one of the supported types listed in the [viz-types index](/docs/build-workflow/viz-types); anything else is rejected at validation time. - `query` — Malloy query reference, exactly in the format `path/to/file.malloy::query_name`. The path is from the workspace root; both halves must exist or validation fails. ## Other top-level fields - `mapping` — field-role assignments. Shape varies per viz type — see the per-type page. - `chart` — typed config block for ECharts-based types (bar, line, pie, scatter, heatmap, funnel). Closed surface: only the keys listed in the per-type reference are accepted. - `kpi`, `grid`, `matrix` — type-specific blocks for the DOM-based types (kpi, grid/table, report_matrix). Use these instead of `chart`. - `pagination` — for grid and report_matrix. - `filters` — list of user-facing controls. See [Filters](/docs/build-workflow/filters). - `format` — field-keyed number / date format patterns; field name → pattern string. - `emphasis` — declarative highlight rule (per supported type — see the per-type pages). - `execution` — query execution hints (timeout, concurrency, cache override). Permissive shape today. - `tags` — free-form labels for search. - `published` — boolean. Only `true` visualizations appear in dashboards. ## The query reference format Every visualization points to a Malloy query with this exact format: ``` query: "models/ec_revenue.malloy::by_category" ``` The part before `::` is the path to the `.malloy` file from the workspace root. The part after `::` is the view or query name defined inside that file. Both must match exactly. Local validation (`looky validate`) checks that the file exists and the reference uses the `::` separator. Server-side validation additionally compiles the model and dry-runs the query; if the view name doesn't exist, or the model fails to compile, you get a clear error before push. ## Worked examples Minimal KPI: ``` id: ec_revenue_kpi title: Revenue query: "models/ec_revenue.malloy::kpi" type: kpi mapping: value: revenue delta: revenue_delta_pct format: revenue: "$#,##0a" revenue_delta_pct: "#,##0.00%" published: true ``` Bar with one series — note `mapping.series[]` (legacy `mapping.y` is no longer accepted on bar): ``` id: ec_revenue_by_category_bar title: Revenue by Category query: "models/ec_revenue.malloy::by_category" type: bar mapping: x: category series: - field: revenue label: Revenue chart: show_value_labels: true value_label: position: top format: revenue: "$#,##0.00" published: true ``` Line with dual axis: ``` id: ec_revenue_over_time_line title: Revenue Over Time query: "models/ec_revenue.malloy::over_time" type: line mapping: x: order_month y: revenue y2: order_count series_label: Revenue series_label_2: Orders format: revenue: "$#,##0" order_count: "#,##0" published: true ``` For every other shape (grouped bars, percent-stacked, donut pie, scatter, heatmap, funnel, grid with pagination, report_matrix with totals) see the per-type pages under [Visualization Types](/docs/build-workflow/viz-types). ## Cross-filtering at a glance Inside a dashboard, clicking a chart can add a "pill" that narrows every other viz on the page. Per-type emit behavior: - **Emit clicks**: bar, line, pie, scatter, funnel, grid, report_matrix, heatmap (the latter requires `chart.cross_filter_emit` set to `"x"` or `"y"`). - **Don't emit but consume pills**: kpi (no categorical click target by structure). - **Per-viz opt-out**: `chart.cross_filter: false` for ECharts-based vizs (bar, line, pie, scatter, heatmap, funnel); `grid.cross_filter: false` for grid; `matrix.cross_filter: false` for report_matrix. KPI doesn't emit by structure. Full mechanism at [Cross-filtering](/docs/build-workflow/cross-filtering). ## What validation actually checks (and what it does not) [Validation](/docs/build-workflow/publish) is two-pass — local then server-side. Per visualization, the checks are: - **Local**: YAML parses; `id`, `title`, `type`, `query` are present and non-empty; `query` uses the `::` separator; the model file exists; the `id` is unique across the workspace; the `chart` block is schema-valid (every property is recognised, values are in their allowed range — error `VZ020`). - **Server-side**: the underlying Malloy model compiles, sources are reachable, and the query dry-runs against the runtime engine (free on BigQuery, EXPLAIN-only on Postgres in `--strict`). Validation does **not** verify that every field in `mapping` exists in the query result, that every field in `format` appears in `mapping`, or that the chart "looks right". Those issues surface only at render time. Open the visualization detail page after push to catch them. ``` looky validate looky list visualizations ``` ---PAGE--- --- title: Visualization Types slug: docs/build-workflow/viz-types language: en description: Reference for all supported visualization types with working YAML examples for each. last_modified: "2026-06-11T14:16:16.439000+00:00" docs_section: build-workflow docs_summary: All supported visualization types with working examples and field reference. --- ## One type for one purpose Each visualization type answers a specific kind of question. Picking the right type is not about aesthetics — it is about making the answer legible at a glance. Every type has its own deep reference page below; each page enumerates exactly which `mapping.*` and `chart.*` fields the renderer reads, the cross-filter behavior, worked examples, and common pitfalls. All visualizations share the same top-level fields (`id`, `title`, `query`, `type`, `filters`, `format`, `published`). Per-type configuration lives in `mapping` plus exactly one of: a `chart` block (for ECharts-based types) or a type-specific block (`kpi`, `grid`, `matrix`) for the DOM-based types. ## Supported types Nine renderers are exposed behind the `type:` field. Two have aliases (kpi/number → same renderer; grid/table → same renderer). - [`kpi`](/docs/build-workflow/viz-types/kpi) — single metric with optional delta and comparison. Alias: `number`. - [`bar`](/docs/build-workflow/viz-types/bar) — categorical comparison; supports stacking, dual-axis, horizontal orientation. - [`line`](/docs/build-workflow/viz-types/line) — time series; supports dual axis and multi-series. - [`pie`](/docs/build-workflow/viz-types/pie) — part-of-whole composition; supports donut and rose variants. - [`scatter`](/docs/build-workflow/viz-types/scatter) — two-measure correlation; one point per row. - [`heatmap`](/docs/build-workflow/viz-types/heatmap) — two-dimensional intensity grid with explicit color scale. - [`funnel`](/docs/build-workflow/viz-types/funnel) — conversion or pipeline stages with drop-off. - [`grid`](/docs/build-workflow/viz-types/grid) — row-level data table; supports pagination, frozen columns, composite cells. Alias: `table`. - [`report_matrix`](/docs/build-workflow/viz-types/report-matrix) — hierarchical report with grouped rows, totals, and PDF export. Anything else passed as `type:` is unsupported and rejected at validation time. ## The chart block is typed and closed For ECharts-based types (bar, line, pie, scatter, heatmap, funnel) the `chart` block is a small, typed surface. Three categories of properties live in it: - **Looky shortcuts** — single keys that collapse multiple coordinated decisions into one decision (e.g. `chart.stack: percent`, `chart.variant: donut`, `chart.show_value_labels`). - **Pass-through fields** — scalars or arrays mirrored to a specific underlying option (e.g. `chart.center`, `chart.symbol_size`, `chart.gap`). - **Pass-through blocks** — nested objects with a curated, snake_case set of properties (e.g. `chart.legend`, `chart.tooltip`, `chart.x_axis`, `chart.y_axis`, `chart.value_label`, `chart.label`, `chart.visual_map`). Any property not listed in the per-type reference page is rejected at validation time. There is no escape hatch for raw chart-library options. ## The DOM-based types use their own block kpi, grid, and report_matrix are not built on a chart library, so they do not have a `chart` block at all. They use a top-level type-specific block instead — `kpi`, `grid`, `matrix` — plus auxiliary blocks (`pagination` for grid and report_matrix, `comparison` for kpi). ## Where to look for each topic - **What fields each viz type accepts** — the per-type reference page above. - **How filters wire in** — [Filters](/docs/build-workflow/filters). - **Cross-filter behavior** — [Cross-filtering](/docs/build-workflow/cross-filtering). - **What Malloy syntax the engine understands** — [Malloy support](/docs/build-workflow/malloy-support). - **How dashboards compose visualizations** — [Dashboards](/docs/build-workflow/dashboards). - **Adapter divergences** — [Source adapter differences](/docs/reference/source-adapters). ## Format string reference (shared by every type) Number formatting is field-keyed across all viz types. Per-field patterns win over slot patterns; slot patterns win over the root pattern. The pattern grammar: - `$#0,0a` — abbreviated currency: $1.2M, $340K - `$#0,00` — full currency with two decimals: $1,234.56 - `#0` — integer: 1234 - `#.##0,0` — number with one decimal: 1,234.5 - `#0,00%` — percentage with two decimals: 12.34% - `#.##0,00%` — percentage with more precision: 12.345% - `EUR#0` — currency code prefix: EUR1234 - `#0a` — compact: 1.2K, 340K, 1.2M - `#0b` — bytes: 1.2KB, 340MB, 1.2GB If no format pattern is set for a field, the platform falls back to a default decimal format with two fraction digits. ---PAGE--- --- title: Visualization — kpi slug: docs/build-workflow/viz-types/kpi language: en description: "Authoritative reference for the kpi (alias: number) visualization type: every supported mapping field, kpi block options, format handling, and adapter notes." last_modified: "2026-06-11T14:16:25.088000+00:00" docs_section: build-workflow docs_summary: "Single metric with optional delta and comparison; alias: number." --- ## When to use kpi KPI is the right choice when the answer is a single number — revenue total, order count, conversion rate, error budget consumed. Add a delta or a secondary value to show direction or comparison alongside the headline. KPI is the typical "headline" of a dashboard: it does not emit clicks (there is no dimension to click on), but it does react to dashboard filters and cross-filter pills like every other viz — its underlying query re-runs with the active params and the headline number recomputes. If you need a number per category instead of a single number, use [bar](/docs/build-workflow/viz-types/bar). If you need a trend over time, use [line](/docs/build-workflow/viz-types/line). If you need many numbers in a tabular layout, use [grid](/docs/build-workflow/viz-types/grid). Type identifiers `kpi` and `number` map to the same renderer; either is accepted. ## Mapping The KPI reads the first row of the query result. Mapping fields name which columns of that row become which visual element: - `mapping.value` — required. The field whose value is the headline number. - `mapping.subtitle` — string field for a label / period context line under the value. - `mapping.delta` — numeric field shown as the change indicator. Sign drives the up/down arrow; the polarity setting controls the color. - `mapping.secondary_value` — numeric field for a comparison value (e.g. "previous period") when there is no `delta`. - `mapping.secondary_label` — caption for `secondary_value`, written as a **literal string** (e.g. `"Invoices"`, `"Previous quarter"`). It also accepts a *field name*: if the value matches a column in the result row, that column's value is used as the caption. Defaults to `"Previous"` when omitted. - `mapping.secondary_plain` — plain-text field shown next to the headline when there is no delta or secondary value. ``` mapping: value: revenue subtitle: period_label delta: revenue_delta_pct ``` ## kpi block KPI-specific options live under the top-level `kpi` block. KPI does not have a `chart` block. - `kpi.comparison_label` — caption shown beneath the delta (e.g. `"vs. last week"`). When omitted, the renderer composes a default like "Up vs previous period" from the delta sign. - `kpi.delta_good_when` — `"increase"` (default) or `"decrease"`. Sets the polarity that colors the delta green vs red. Use `"decrease"` for metrics where lower is better — refunds, error rate, time-to-resolution. ## format Number formatting is field-keyed. Per-field patterns win over the root pattern. - `format.value` — pattern for the headline number. - `format.delta` — pattern for the delta indicator. - `format.secondary_value` — pattern for the secondary comparison value. - `format` at the root — fallback when a field-specific entry is missing. ``` # headline as abbreviated currency, delta as percent format: revenue: "$#,##0a" revenue_delta_pct: "#,##0.00%" ``` The full pattern grammar lives on the [viz-types overview](/docs/build-workflow/viz-types). ## Cross-filter behavior KPI participates in cross-filtering as a **consumer only**: - **Does not emit.** Clicking a KPI does not add a pill — there is no dimension to click on. - **Does react.** Pills set elsewhere on the dashboard, and any dashboard-level filter, become parameters on the next run, and the KPI's underlying query re-runs with them. The headline number recomputes accordingly. If you want a "headline that always shows the dashboard-wide total" regardless of pills, write the underlying Malloy query so its parameter defaults ignore the pill values (e.g. accept the parameter but do not use it in the where-clause), or use two separate KPI items: one bound to a model that ignores the cross-filter parameter, one bound to a model that respects it. ## Worked examples Headline with delta and percent polarity favoring decrease (lower error rate is better): ``` id: ec_error_rate_kpi title: Error Rate query: "models/ec_quality.malloy::error_rate_kpi" type: kpi mapping: value: error_rate delta: error_rate_delta_pct subtitle: period_label format: error_rate: "#,##0.00%" error_rate_delta_pct: "#,##0.00%" kpi: comparison_label: "vs previous period" delta_good_when: decrease published: true ``` Headline with a secondary comparison value (no delta): ``` id: ec_revenue_vs_prev_kpi title: Revenue query: "models/ec_revenue.malloy::current_vs_previous" type: kpi mapping: value: revenue_current secondary_value: revenue_previous secondary_label: "Previous quarter" format: revenue_current: "$#,##0a" revenue_previous: "$#,##0a" published: true ``` Headline with a plain-text annotation when the metric does not have a numeric comparison: ``` id: ec_top_brand_kpi title: Top Brand query: "models/ec_revenue.malloy::top_brand" type: kpi mapping: value: brand_name secondary_plain: market_share_label published: true ``` ## Common pitfalls - **The KPI shows the wrong number.** Only the first row of the query result is read. Make sure the underlying query aggregates to one row, or sort it so the headline value is the row you want. - **The delta polarity is inverted.** Set `kpi.delta_good_when: decrease` for metrics where lower is better (refunds, errors, latency). The default `"increase"` assumes higher is better. - **The KPI is reacting to a pill when you wanted a global headline.** KPI consumes pills like every other viz. To make a "always-total" KPI, write the underlying model so the parameter is accepted but unused in the where-clause for that view. - **The headline value is not formatted.** Set `format.value` (or the field-specific entry under `format`) — without a pattern, the value is rendered with the platform default. - **Mixing `delta` and `secondary_value` in the same KPI.** Pick one or the other; mixing makes the chart busy and the renderer prefers `delta`. ---PAGE--- --- title: Visualization — bar slug: docs/build-workflow/viz-types/bar language: en description: "Authoritative reference for the bar visualization type: every supported mapping field, every chart option, format handling, cross-filter behavior, and source-adapter notes." last_modified: "2026-06-11T14:16:18.033000+00:00" docs_section: build-workflow docs_summary: Categorical comparison; supports stacking, dual-axis, horizontal orientation. --- ## When to use bar Bar is the right choice when the question is *"how do these categories compare?"*. The category axis lists discrete things — products, regions, channels, statuses — and the value axis measures one or more numeric quantities for each. The same renderer covers four common shapes: - **Single-series** — one bar per category, one measure. The default. - **Grouped** — multiple bars per category, one per measure. Reach for it when the measures are on the same scale (revenue and refunds, for example). - **Stacked** — bars stacked on top of each other; the total height is the sum of all measures. Use `chart.stack: normal`. - **Percent-stacked** — every category rescaled to 100%, with the bars showing the share of each part. Use `chart.stack: percent`. - **Dual-axis** — two measures on different scales sharing one chart, one bound to a left y-axis and the other to a right y-axis. Set `axis: right` on the second series. - **Combo (bar + line)** — bars for one measure plus a line over the same categories for another (for example a cumulative-percent overlay). Set `type: line` on the line series; usually pair it with `axis: right` and `chart.y2_axis`. Use [line](/docs/build-workflow/viz-types/line) instead when the x-axis is ordered by time and the question is about *trend*. Use [pie](/docs/build-workflow/viz-types/pie) when there are very few categories (≤ 6) and the question is purely about composition. Use [grid](/docs/build-workflow/viz-types/grid) when the audience needs the actual numbers next to category names rather than a visual comparison. ## Mapping Two mapping fields drive the chart: - `mapping.x` — required. The categorical field. Each distinct value becomes one tick on the category axis. Use a string field for clean labels; numeric or date fields are formatted with the platform default unless you override via `format`. - `mapping.series` — required. Array. One entry per measure to plot. Each entry must declare a `field`; everything else is optional. ``` mapping: x: category series: - field: revenue ``` Use one entry for a simple chart, two or more for grouped or stacked, and the optional `axis: right` on a series to put it on the secondary y-axis. Per-series options: - `field` — required. The numeric field from the query result that drives the bar height for this series. - `label` — string. Shown in the legend and tooltip. Defaults to the field name. - `label_from_field` — string. Pull the legend label from a field in the data instead of declaring it in YAML. - `color` — hex color for this series. Defaults to the next slot in the platform palette. - `axis` — `"left"` (default) or `"right"`. Right places the series on the secondary y-axis. - `type` — `"bar"` (default) or `"line"`. `line` draws the measure as a line over the same categories (combo chart). Line series never stack. Not combinable with `chart.orientation: horizontal` — validation rejects that combination. ``` # two measures on the same scale (grouped) series: - field: revenue label: Revenue - field: refunds label: Refunds # two measures on different scales (dual-axis) series: - field: revenue label: Revenue - field: order_count label: Orders axis: right # one measure with explicit color series: - field: revenue label: Revenue color: "#6c47ff" ``` ## chart shortcuts Top-level keys on the `chart` block. The `chart` block is typed and closed — anything not listed in this page is rejected at validation time. - `chart.orientation` — `"vertical"` (default) or `"horizontal"`. Pick horizontal when category labels are long, or when the audience reads top-to-bottom (rankings, leaderboards). - `chart.stack` — `"normal"` (sum stacking) or `"percent"` (rescale every category to 100% and switch the value-axis labels to percentages). Omit for grouped bars. - `chart.show_value_labels` — boolean. Turns the value label on for every series. The label content is the bar's value, formatted by `format`; style it with `chart.value_label` below. - `chart.cross_filter` — boolean, default `true`. Set to `false` for charts that should always show the unfiltered view (e.g. a "total revenue" headline). - `chart.height` — pixel height of the viz container. Set explicitly when the chart needs more vertical room — for example, a horizontal bar with many categories. ## Value labels `chart.value_label` is an object applied to every series when `chart.show_value_labels` is on: - `position` — placement of the label relative to the bar. Common values: `"top"`, `"inside"`, `"insideTop"`, `"insideBottom"`, `"outside"`. - `rotate` — degrees, between -90 and 90. Useful when the label is wider than the bar. - `color`, `font_size`, `font_weight`. - `formatter` — string template (no callbacks). Useful for adding a unit prefix or suffix (e.g. `"{c}M"`). - `distance`, `align` (`"left"` | `"center"` | `"right"`), `vertical_align` (`"top"` | `"middle"` | `"bottom"`), `clip`. ``` # compact label inside the bar with white text chart: show_value_labels: true value_label: position: inside color: "#fff" font_size: 11 # label above each vertical bar with currency formatter chart: show_value_labels: true value_label: position: top formatter: "${c}" distance: 4 # rotated labels for narrow bars chart: show_value_labels: true value_label: rotate: -90 position: insideBottom ``` ## Legend & tooltip `chart.legend`: - `show` — boolean. Set to `false` when the chart has a single series and the legend is redundant. - `position` — shortcut: `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. - `orient` — `"horizontal"` | `"vertical"`. Use to override the orient derived from `position`. - `top` / `bottom` / `left` / `right` — pixel number or percent string for direct positioning. - `text_style` — text styling: `color`, `font_style`, `font_weight`, `font_family`, `font_size`, `line_height`. - `item_width`, `item_height`, `item_gap` — pixel sizes for the colored swatches and gaps. `chart.tooltip`: - `show` — boolean. - `trigger` — `"item"` (one bar at a time, default for single-series), `"axis"` (group-wise; preferred when there are multiple series so hover compares them), or `"none"`. - `confine` — boolean. Keep the tooltip inside the chart bounds. - `formatter` — string template. Use placeholders such as `{a}` (series), `{b}` (category), `{c}` (value). - `background_color`, `border_color`, `border_width`. - `padding` — single number or array of 2 to 4 numbers (top/right/bottom/left). - `text_style` — same shape as on the legend. - `axis_pointer.type` — `"line"` | `"shadow"` | `"none"` | `"cross"`. Common bar choice: `"shadow"`. ## Axes `chart.x_axis`, `chart.y_axis`, and `chart.y2_axis` share the same shape for value-axis blocks (`y2_axis` configures the right axis when a series uses `axis: right`; with one extra on x_axis): - `name` — axis title. - `name_location` — `"start"` | `"middle"` | `"center"` | `"end"`. - `name_gap` — pixels between axis line and name. - `min`, `max` — numeric axis bounds. Use `y2_axis.max: 1` to pin a cumulative-percent line to a 0–100% domain when values are stored as 0–1. - `axis_label.show` — boolean. - `axis_label.rotate` — degrees, -90 to 90. Use to keep long category names from overlapping. - `axis_label.interval` — integer ≥ 0 (skip every N labels) or `"auto"`. - `axis_label.color`, `axis_label.font_size`, `axis_label.font_weight`. - `axis_label.formatter` — string template. - `axis_label.max_chars` — integer ≥ 1. Truncates labels with an ellipsis. - `x_axis.visible_window` — integer ≥ 1. Restricts the visible category count and enables a horizontal range slider; useful when there are many categories. ``` # long category names with rotation and ellipsis chart: x_axis: axis_label: rotate: -30 max_chars: 14 # named y-axis with a percent formatter chart: y_axis: name: Margin name_gap: 28 axis_label: formatter: "{value}%" # data-zoom slider for many categories chart: x_axis: visible_window: 12 ``` ## format Number formatting is field-keyed at the top level of the viz YAML. Per-field patterns win over the root pattern. Common patterns for a bar chart: ``` format: revenue: "$#,##0.00" # full currency revenue: "$#,##0a" # abbreviated: $1.2M, $340K order_count: "#,##0" # integer with grouping margin_pct: "#,##0.00%" # percentage ``` Format applies to value labels, tooltips, and value-axis tick labels. The full pattern grammar lives on the [viz-types overview](/docs/build-workflow/viz-types). ## Cross-filter behavior Inside a dashboard, clicking a bar adds a "pill" that narrows every other viz on the page to the clicked category. Full mechanism at [Cross-filtering](/docs/build-workflow/cross-filtering). Bar specifics: - The clicked bar's category becomes the cross-filter value. - If the dashboard's models do not declare a parameter named after the clicked field, the click is silently ignored. To make a column cross-filterable, declare a parameter for it in the model that powers the affected charts. - Disable per viz with `chart.cross_filter: false`. Useful for "headline" charts that should always show totals. - To highlight a specific bar from an external value, use the top-level `emphasis` block: ``` emphasis: field: category value_from_param: highlight_category bar_color: "#6c47ff" ``` The bar whose `category` equals the runtime value of `highlight_category` is colored with `bar_color`. ## Worked examples Simple comparison: ``` id: ec_revenue_by_category_bar title: Revenue by Category query: "models/ec_revenue.malloy::by_category" type: bar mapping: x: category series: - field: revenue label: Revenue chart: height: 320 show_value_labels: true value_label: position: top x_axis: axis_label: rotate: -30 max_chars: 14 y_axis: name: Revenue format: revenue: "$#,##0.00" published: true ``` Grouped (two measures, same scale): ``` id: ec_revenue_vs_refunds_bar title: Revenue vs Refunds query: "models/ec_revenue.malloy::revenue_vs_refunds" type: bar mapping: x: category series: - field: revenue label: Revenue color: "#6c47ff" - field: refunds label: Refunds color: "#fb7185" chart: legend: show: true position: top tooltip: trigger: axis axis_pointer: type: shadow format: revenue: "$#,##0.00" refunds: "$#,##0.00" published: true ``` Percent-stacked composition: ``` id: ec_channel_mix_bar title: Channel Mix per Category query: "models/ec_revenue.malloy::channel_mix" type: bar mapping: x: category series: - field: rev_direct label: Direct - field: rev_organic label: Organic - field: rev_paid label: Paid chart: stack: percent show_value_labels: true value_label: position: inside color: "#fff" legend: show: true position: top format: rev_direct: "#,##0.0%" rev_organic: "#,##0.0%" rev_paid: "#,##0.0%" published: true ``` Pareto (sorted bars + cumulative-percent line on the right axis). Build a Malloy query on `bigquery-public-data.thelook_ecommerce` that returns each category's count ordered descending plus a cumulative-percent column (values 0–1). Map the count as bars and the cumulative percent as a `type: line` series on `axis: right`: ``` id: ec_return_reason_pareto title: Return reasons — Pareto query: "models/ec_returns.malloy::by_reason_pareto" type: bar mapping: x: return_reason series: - field: return_count label: Returns - field: cumulative_pct label: Cumulative % type: line axis: right color: "#6c47ff" chart: y2_axis: { max: 1 } legend: { show: true } format: return_count: "#,##0" cumulative_pct: "#0%" published: true ``` Push from your workspace with `looky push -w ` after the model and viz YAML exist under `content/visualizations/`. Dual-axis (revenue vs orders): ``` id: ec_revenue_orders_bar title: Revenue and Orders query: "models/ec_revenue.malloy::by_month" type: bar mapping: x: order_month series: - field: revenue label: Revenue - field: order_count label: Orders axis: right chart: legend: show: true position: top y_axis: name: Revenue format: revenue: "$#,##0" order_count: "#,##0" published: true ``` Horizontal ranking with many categories: ``` id: ec_top_brands_bar title: Top Brands by Revenue query: "models/ec_revenue.malloy::by_brand_desc" type: bar mapping: x: brand series: - field: revenue label: Revenue chart: orientation: horizontal height: 540 show_value_labels: true value_label: position: right x_axis: axis_label: max_chars: 18 y_axis: visible_window: 25 format: revenue: "$#,##0a" published: true ``` Note: with horizontal orientation, the long category names live on the y-axis, so the y-axis's `axis_label` gets `max_chars`, and `visible_window` moves to `y_axis`. ## Common pitfalls - **Percent stack is not adding to 100%.** Make sure every series in `mapping.series[]` represents a part of the same whole. If one of the measures is on a different scale, the chart shows what you asked for but it is not meaningful as a composition. - **Dual-axis labels collide.** Two value scales need room. Either reduce the data density (fewer categories), set explicit `name_gap` on both axes, or split into two charts. - **Long category labels wrap or overlap.** Add `chart.x_axis.axis_label.rotate: -30` and `max_chars: 14`, or switch to `chart.orientation: horizontal` (horizontal bars cannot include `type: line` series — use vertical combo charts for Pareto-style overlays). - **Cross-filter clicks have no effect.** The clicked field must be declared as a parameter in at least one model used by the dashboard. Without that, clicks are silently ignored. - **Value labels are clipped on small bars.** Set `chart.value_label.clip: false`, or move the position to `"top"` / `"outside"`, or reduce `font_size`. - **Legend takes too much space.** Use `position: bottom` or set `show: false` when there is only one series. - **Too many categories blow up the layout.** Use `chart.x_axis.visible_window` to enable a slider, or sort and limit in the Malloy query so the chart shows the top N. - **Color is per series, not per individual bar.** To highlight one specific bar, use the top-level `emphasis` block (see above) instead of trying to color a category directly. ---PAGE--- --- title: Visualization — line slug: docs/build-workflow/viz-types/line language: en description: "Authoritative reference for the line visualization type: every supported mapping field, every chart option, dual-axis and multi-series modes, format handling, cross-filter behavior." last_modified: "2026-06-11T14:16:26.660000+00:00" docs_section: build-workflow docs_summary: Time series and trends; supports dual axis and multi-series. --- ## When to use line Line is the right choice when the x-axis is ordered and the question is about *trend* — typically a time series (daily revenue, monthly orders, hourly errors) or any other naturally ordered category. The same renderer covers three shapes: - **Single line** — one measure plotted across the x-axis. The default. - **Dual-axis** — two measures on different scales sharing one chart. Add `mapping.y2`; the second measure renders against a right-hand y-axis. - **Multi-series** — one line per category value, all sharing the same y-axis. Add `mapping.series` with the categorical field. `y2` and `series` are mutually exclusive in intent: dual-axis is for two measures on the same x; multi-series is for one measure split by a category. Do not combine them in one viz. Use [bar](/docs/build-workflow/viz-types/bar) instead when the x-axis is unordered and the question is about *comparison*. Use [scatter](/docs/build-workflow/viz-types/scatter) when the relationship is between two continuous measures rather than a measure across an ordered axis. ## Mapping - `mapping.x` — required. The ordered axis field (date, month, integer, ordinal category). - `mapping.y` — required. Numeric field for the (left) y-axis values. - `mapping.y2` — optional. Numeric field. When present, the chart switches to **dual-axis** mode. - `mapping.series` — optional. Categorical field. When present, the chart switches to **multi-series** mode. - `mapping.series_label` — legend / tooltip label for the primary y series. Default `"value"`. - `mapping.series_label_2` — legend / tooltip label for the y2 series in dual-axis mode. Default `"y2"`. ``` # single line mapping: x: order_month y: revenue series_label: Revenue # dual-axis mapping: x: order_month y: revenue y2: order_count series_label: Revenue series_label_2: Orders # multi-series (one line per channel) mapping: x: order_month y: revenue series: channel ``` ## chart shortcuts The `chart` block is typed and closed — anything not listed in this page is rejected at validation time. - `chart.show_value_labels` — boolean. Turns on data-point labels for every series. Style them with `chart.value_label` below. - `chart.cross_filter` — boolean, default `true`. Set `false` to suppress click-to-filter for this viz. - `chart.height` — pixel height of the viz container. Line intentionally exposes a small option surface — most styling decisions are taken from the platform theme. Series-level customisation goes through `mapping`, not `chart`. ## Value labels `chart.value_label` is an object applied to every series when `chart.show_value_labels` is on: - `position` — placement of the label relative to the point. Common values: `"top"`, `"bottom"`, `"right"`, `"insideTop"`, `"insideBottom"`. - `rotate` — degrees, between -90 and 90. - `color`, `font_size`, `font_weight`. - `formatter` — string template (no callbacks). Use `{c}` for the value. - `distance`, `align`, `vertical_align`, `clip`. Value labels on a line are noisy for dense series; consider showing them only on the last point by post-processing the data, or rely on the hover tooltip instead. ## Legend & tooltip `chart.legend`: - `show` — boolean. - `position` — shortcut: `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. - `orient` — `"horizontal"` | `"vertical"`. - `top` / `bottom` / `left` / `right` — pixel number or percent string. - `text_style` — `color`, `font_style`, `font_weight`, `font_family`, `font_size`, `line_height`. - `item_width`, `item_height`, `item_gap`. `chart.tooltip`: - `show` — boolean. - `trigger` — `"item"`, `"axis"` (recommended for line — shows all series at the hovered x), or `"none"`. - `confine`, `formatter`, `background_color`, `border_color`, `border_width`, `padding`, `text_style`. - `axis_pointer.type` — `"line"` (recommended for line), `"shadow"`, `"none"`, `"cross"`. ## Axes `chart.x_axis` and `chart.y_axis` share the same shape (with one extra on x_axis): - `name` — axis title. - `name_location` — `"start"` | `"middle"` | `"center"` | `"end"`. - `name_gap` — pixels between axis line and name. - `axis_label.show` — boolean. - `axis_label.rotate` — degrees, -90 to 90. - `axis_label.interval` — integer or `"auto"`. - `axis_label.color`, `axis_label.font_size`, `axis_label.font_weight`. - `axis_label.formatter` — string template. - `axis_label.max_chars` — integer ≥ 1, ellipsizes long labels. - `x_axis.visible_window` — integer ≥ 1. Restricts the visible range and enables a horizontal range slider; useful for long time series. ## format - `format.y` or `format[]` — pattern for the left y-axis labels and tooltip values. - `format.y2` or `format[]` — pattern for the right y-axis (dual-axis mode). - `format` at the root — fallback. ``` format: revenue: "$#,##0" order_count: "#,##0" ``` ## Cross-filter behavior Inside a dashboard, clicking a line cross-filters the rest of the dashboard. Full mechanism at [Cross-filtering](/docs/build-workflow/cross-filtering). Line specifics: - In single / dual-axis mode, clicking a point cross-filters by the clicked x value. - In multi-series mode, clicking a line cross-filters by the series name. - The clicked field must be declared as a parameter in at least one model used by the dashboard, otherwise the click is silently ignored. - Disable per viz with `chart.cross_filter: false`. ## Worked examples Single line, with a rotated x-axis label for monthly periods: ``` id: ec_revenue_over_time_line title: Revenue Over Time query: "models/ec_revenue.malloy::over_time" type: line mapping: x: order_month y: revenue series_label: Revenue chart: height: 320 x_axis: axis_label: rotate: -30 y_axis: name: Revenue tooltip: trigger: axis axis_pointer: type: line format: revenue: "$#,##0" published: true ``` Dual-axis (revenue on the left, order count on the right): ``` id: ec_revenue_orders_line title: Revenue and Orders query: "models/ec_revenue.malloy::over_time" type: line mapping: x: order_month y: revenue y2: order_count series_label: Revenue series_label_2: Orders chart: height: 320 legend: show: true position: top format: revenue: "$#,##0" order_count: "#,##0" published: true ``` Multi-series (one line per channel): ``` id: ec_revenue_by_channel_line title: Revenue by Channel query: "models/ec_revenue.malloy::by_channel_over_time" type: line mapping: x: order_month y: revenue series: channel chart: legend: show: true position: top tooltip: trigger: axis format: revenue: "$#,##0" published: true ``` Long time series with a horizontal data-zoom slider: ``` chart: x_axis: visible_window: 30 ``` ## Common pitfalls - **Mixing dual-axis and multi-series.** Setting both `y2` and `series` in the same viz produces undefined behavior — pick one. - **Dual-axis labels collide.** Two value scales need room. Set explicit `name_gap` on both axes, reduce data density, or split into two charts. - **Multi-series legend is too wide.** If the categorical field has many distinct values, the legend can dominate the chart. Move it to `position: bottom`, or filter to top-N in the underlying Malloy query. - **Date formatting on the x-axis is wrong.** Format the x value with `format[]` or pre-format in the Malloy query (e.g. derive an `order_month_label` string). - **Time-zone shifts in the x values.** Time-series x values are interpreted in the user's session timezone. Pre-bucket dates to days or months in the Malloy query if you need consistent groupings across users. - **Cross-filter clicks have no effect.** The clicked field must be declared as a parameter in at least one model used by the dashboard. Without that, clicks are silently ignored. ---PAGE--- --- title: Visualization — pie slug: docs/build-workflow/viz-types/pie language: en description: "Authoritative reference for the pie visualization type: every supported mapping field, every chart option, donut variant, format handling, cross-filter behavior, and the ECharts escape hatch." last_modified: "2026-06-11T14:16:28.310000+00:00" docs_section: build-workflow docs_summary: Part-of-whole composition; supports donut variant. --- ## When to use pie Pie is the right choice for part-of-whole composition when the number of slices is small (typically ≤ 6) and proportional weight matters. Use the donut variant when you want to free the chart center for an inner label or a related KPI; use the rose variant for an alternative encoding where slice angle is fixed but radius reflects the value. Use [bar](/docs/build-workflow/viz-types/bar) with `chart.stack: percent` instead when the categories are many, or when you want to compare composition across multiple groups. Use [grid](/docs/build-workflow/viz-types/grid) when the audience needs the actual percentages in tabular form. ## Mapping - `mapping.x` — required. String field used as the slice name and the legend label. - `mapping.y` — required. Numeric field that drives the slice angle. ``` mapping: x: status y: order_count ``` Slice colors come from the platform palette in query order. To control order, sort in the underlying Malloy query. ## chart shortcuts The `chart` block is typed and closed. - `chart.variant` — `"pie"` (default), `"donut"`, or `"rose"`. Donut adds an inner radius (free center); rose switches to a rose-area layout where slice angle is uniform and radius encodes the value. - `chart.inner_radius` — number (pixels) or string (e.g. `"55%"`). Use with `variant: donut`; the larger the inner radius, the thinner the donut ring. - `chart.outer_radius` — number or string. - `chart.slice_border_radius` — pixels. Rounded corners on slices for a softer look. - `chart.show_value_labels` — boolean. Turns slice labels on. Style them with `chart.label` below. - `chart.cross_filter` — boolean, default `true`. - `chart.height` — pixel height of the viz container. Pass-through fields (1:1 with the underlying chart): - `chart.center` — center of the pie. Array `[x, y]` of percent strings (e.g. `["50%", "50%"]`) or pixel numbers. - `chart.start_angle` — degrees. Default `90`. - `chart.min_angle` — degrees. Minimum angle for tiny slices to remain visible. - `chart.rose_type` — when using a rose layout: `"radius"` or `"area"`. ## Slice labels `chart.label` is an object that styles slice labels when `chart.show_value_labels` is on: - `position` — `"inside"`, `"outside"`, `"top"`, `"bottom"`, `"left"`, `"right"`, plus the `"insideLeft"` / `"insideRight"` variants. - `rotate` — degrees, between -90 and 90. - `color`, `font_size`, `font_weight`. - `formatter` — string template. Use `{b}` for the slice name, `{c}` for the value, `{d}` for the percentage. - `distance`, `align`, `vertical_align`, `clip`. ``` chart: show_value_labels: true label: position: outside formatter: "{b}: {d}%" ``` ## Legend & tooltip `chart.legend` controls the legend; `chart.tooltip` controls the hover tooltip. Both share the same shape as on [bar](/docs/build-workflow/viz-types/bar) — see that page for the full sub-block reference. Pie-specific tip: for a donut variant with the legend on the right, set `center: ["42%", "50%"]` to nudge the donut left so the legend has more room. ## format - `format.y` or `format[]` — pattern for slice tooltip values and inside-slice labels. - `format` at the root — fallback. ## Cross-filter behavior - Clicking a slice cross-filters the rest of the dashboard by the slice label. - The clicked field (the slice-label field, e.g. `status`) must be declared as a parameter in at least one model used by the dashboard, or the click is silently ignored. - Disable per viz with `chart.cross_filter: false`. See [Cross-filtering](/docs/build-workflow/cross-filtering) for the full mechanism. ## Worked examples Donut with outside labels and bottom legend: ``` id: ec_orders_by_status_pie title: Orders by Status query: "models/ec_revenue.malloy::by_status" type: pie mapping: label: status value: order_count chart: variant: donut inner_radius: "55%" outer_radius: "78%" show_value_labels: true label: position: outside formatter: "{b}: {d}%" legend: show: true position: bottom format: order_count: "#,##0" published: true ``` Solid pie with inside labels: ``` type: pie mapping: label: category value: revenue chart: show_value_labels: true label: position: inside color: "#fff" font_weight: bold legend: show: false format: revenue: "$#,##0" ``` Rose layout (slice angle uniform, radius encodes value): ``` chart: variant: rose rose_type: area start_angle: 0 show_value_labels: true ``` ## Common pitfalls - **Too many slices.** A pie with more than ~6 slices becomes hard to read. Sort + limit in the Malloy query, or aggregate small slices into an "Other" bucket. - **Slices with zero or near-zero values disappear.** Use `chart.min_angle` to enforce a minimum visible angle, or filter zeros out in the query. - **Labels overlap on a tight chart.** Move them inside (`chart.label.position: "inside"`) or rotate. - **Donut inner content collides with labels.** When you put a KPI in the donut center via dashboard layout, set `chart.label.position: "outside"` so the slice labels do not collide. - **Slice colors are not what you expected.** Colors are assigned in query-result order from the platform palette. Sort the query so the largest or most important slice gets the dominant color. - **Cross-filter clicks have no effect.** The slice-label field must be declared as a parameter in at least one model used by the dashboard. ---PAGE--- --- title: Visualization — scatter slug: docs/build-workflow/viz-types/scatter language: en description: "Authoritative reference for the scatter visualization type: every supported mapping field, every chart option, format handling, cross-filter behavior, emphasis, and the ECharts escape hatch." last_modified: "2026-06-11T14:16:31.442000+00:00" docs_section: build-workflow docs_summary: Two- or three-measure correlation; one point per row. --- ## When to use scatter Scatter is the right choice when the question is whether two continuous measures move together across a population — margin vs revenue across brands, conversion rate vs traffic across pages, latency vs throughput across endpoints. Each row of the query becomes one point. Use [line](/docs/build-workflow/viz-types/line) instead when the x-axis is ordered (typically time) and you care about *trend*. Use [heatmap](/docs/build-workflow/viz-types/heatmap) when the data is dense and you need to see distribution rather than individual points. ## Mapping - `mapping.x` — required. Numeric x-coordinate field. Rows where this value is non-finite are dropped silently. - `mapping.y` — required. Numeric y-coordinate field. Same finite-only filter. - `mapping.label` — optional. Field name used as the per-point tooltip label and as the cross-filter event payload when emission is enabled. - `mapping.series` — optional. Categorical field name. When present the chart switches to **multi-series mode**: every distinct value becomes its own colored group of points with a legend entry, all sharing the same x/y axes. One row is still one point — the field only decides which group (and color) the point belongs to. - `mapping.size` — optional. Numeric field encoded as point diameter (bubble chart). Turns the scatter into a bubble chart: x × y position plus a third measure as size. Works in both single-series and multi-series mode; bubble sizes use a **global domain across all series** so cohorts stay comparable. Rows with non-finite or negative size values are dropped, same as bad x/y coordinates. Mutually exclusive with `chart.symbol_size` and `chart.large_threshold` — validation rejects the combination. ``` mapping: x: revenue y: margin_pct label: brand series: cohort # optional — one colored group per distinct value ``` ## chart shortcuts The `chart` block is typed and closed. - `chart.point_color` — base color for the points (hex), used in single-series mode. The emphasis block (see below) overrides this for the highlighted point. In multi-series mode use `chart.series_colors` instead. - `chart.series_colors` — multi-series only. A map of `series value → color`, e.g. `{ Champions: "#6c47ff", Rest: "#94a3b8" }`. Any value not listed falls back to the default palette in order. Use hex/CSS colors, not Tailwind brand class names. - `chart.symbol_size` — base point size in pixels for **unsized** scatter (no `mapping.size`). Default depends on the chart density. Cannot be combined with `mapping.size`. - `chart.size_range` — sized mode only (`mapping.size` set). Min and max point diameter in pixels; the size field's domain is scaled into this range. Default `[8, 40]`. Inert when `mapping.size` is absent. - `chart.size_scale` — sized mode only. `sqrt` (default) makes bubble *area* proportional to the value — the perceptually correct encoding. `linear` maps value to diameter directly (over-emphasizes large values). Inert without `mapping.size`. - `chart.large_threshold` — multi-series only, default `2000`. When a single series has more points than this, it switches to a faster bulk-draw mode; the trade-off is that per-point hover/emphasis is turned off *for that series only*. Smaller groups keep full hover. Raise it if you need hover on a big group and can afford the slower draw; lower it to keep very large groups responsive. **Cannot be combined with `mapping.size`** — bulk draw ignores per-point sizing. - `chart.cross_filter` — boolean, default `true`. Set to `false` to disable click emission entirely; in that case `cross_filter_emit` is not required. - `chart.cross_filter_emit` — `"label"`, `"x"`, or `"series"`. Picks which value is emitted on click; `"series"` emits the clicked point's group value and is only meaningful in multi-series mode. **Required** whenever `chart.cross_filter` is not explicitly `false`; the schema rejects a chart block that has neither. - `chart.legend` — set `legend.show: true` to display the series legend (the cohort names) in multi-series mode. - `chart.height` — pixel height of the viz container. ## Legend & tooltip `chart.legend` and `chart.tooltip` share the same shape as on [bar](/docs/build-workflow/viz-types/bar). For scatter the tooltip is most useful with `trigger: item` — hovering reveals the label and both coordinates of one point at a time. ## Axes `chart.x_axis` and `chart.y_axis` share the same shape (with one extra on x_axis): - `name` — axis title. Setting both is recommended for scatter so the audience can read the relationship. - `name_location`, `name_gap`. - `axis_label.show`, `axis_label.rotate`, `axis_label.interval`, `axis_label.color`, `axis_label.font_size`, `axis_label.font_weight`, `axis_label.formatter`, `axis_label.max_chars`. - `x_axis.visible_window` — integer ≥ 1. Restricts the visible x range. ## format - `format.x` or `format[]` — pattern for x-axis labels and tooltip x value. - `format.y` or `format[]` — pattern for y-axis labels and tooltip y value. - `format.size` or `format[]` — pattern for the size value in the tooltip when `mapping.size` is set. - `format` at the root — fallback. ## Cross-filter behavior - Clicking a point cross-filters the rest of the dashboard by the point's label / series / x value. - The clicked field must be declared as a parameter in at least one model used by the dashboard, or the click is silently ignored. - The top-level `emphasis` block can declaratively highlight the matching point inside the same viz when a related cross-filter is active. - Disable per viz with `chart.cross_filter: false`. ``` emphasis: field: brand value_from_param: highlight_brand marker_color: "#6c47ff" marker_size: 18 ``` The point whose `brand` equals the runtime value of `highlight_brand` is rendered in `marker_color` at `marker_size` (unsized scatter) or with a relative size boost (bubble / sized scatter); the rest stay at `chart.point_color` / `chart.symbol_size`. ## Worked examples Margin vs revenue across brands: ``` id: brand_margin_vs_revenue title: Margin vs Revenue by Brand query: "models/ec_revenue.malloy::by_brand" type: scatter mapping: x: revenue y: margin_pct label: brand chart: height: 360 point_color: "#0f766e" symbol_size: 12 x_axis: name: Revenue y_axis: name: Margin tooltip: trigger: item formatter: "{b} — {c0} / {c1}" format: revenue: "$#,##0" margin_pct: "#,##0.00%" published: true ``` With emphasis from a dashboard pill: ``` type: scatter mapping: x: revenue y: margin_pct label: brand chart: point_color: "#94a3b8" symbol_size: 10 emphasis: field: brand value_from_param: highlight_brand marker_color: "#6c47ff" marker_size: 18 ``` Multi-series — comparing two customer cohorts on one frequency × ticket plane. Each row is one customer; `series` splits them into colored groups with a shared scale, so the two cohorts are directly comparable in a single viz instead of two side-by-side charts: ``` id: cohorts_freq_vs_ticket title: Frequency vs Ticket by cohort query: "models/rfm.malloy::cohort_points" type: scatter mapping: x: order_frequency y: avg_ticket label: customer_id series: cohort # e.g. "Champions" vs "Rest" chart: height: 360 series_colors: Champions: "#6c47ff" Rest: "#94a3b8" large_threshold: 2000 # the large "Rest" group draws fast; small "Champions" keeps hover legend: show: true cross_filter: true cross_filter_emit: series x_axis: name: Order frequency y_axis: name: Avg ticket format: order_frequency: "#,##0" avg_ticket: "$#,##0.00" published: true ``` Bubble chart — frequency × ticket × lifetime revenue per customer, anchored on the public BigQuery ecommerce dataset. Add `mapping.size` to encode a third measure as point diameter; the tooltip shows x, y, and size: ``` id: customer_value_bubbles title: Frequency × Ticket × Lifetime value query: "models//customer_value.malloy::value_points" type: scatter mapping: x: order_frequency y: avg_ticket size: lifetime_revenue label: customer_id series: segment # optional — multi-series bubble chart: size_range: [8, 40] size_scale: sqrt # default — area proportional to value series_colors: Champions: "#6c47ff" legend: show: true cross_filter: true cross_filter_emit: series x_axis: name: Order frequency y_axis: name: Avg ticket format: order_frequency: "#,##0" avg_ticket: "$#,##0.00" size: "$#,##0" published: true ``` The Malloy model should query `bigquery-public-data.thelook_ecommerce` (or a view derived from it). Sized scatters work best up to a few thousand points — beyond that, bubbles overlap and readability drops; for very large cohorts use unsized scatter with `large_threshold` instead. ## Common pitfalls - **Too many overlapping points.** Scatter loses signal when there are thousands of points in the same area. `large_threshold` keeps a big group responsive but does not declutter it — for readability, pre-aggregate in the Malloy query (e.g. group by bucket and use a heatmap), or filter to top-N by some interesting measure. Bubble charts (`mapping.size`) overlap even faster; keep point counts in the low thousands or switch to unsized scatter. - **Mixing bubble sizing with bulk draw.** `mapping.size` cannot be combined with `chart.symbol_size` or `chart.large_threshold` — validation rejects it. Pick one mode per viz: data-driven sizes, or static size + adaptive bulk draw for huge cohorts. - **Negative or extreme outliers compress the rest.** Filter outliers in the query, or use a log scale by formatting the field. Rows with negative `mapping.size` values are dropped silently. - **Axes are unnamed.** Always set `chart.x_axis.name` and `chart.y_axis.name` — scatter is the viz where the audience most needs the labels to read the relationship. - **Cross-filter clicks have no effect.** The clicked field (label / series / x) must be declared as a parameter in at least one model used by the dashboard. ---PAGE--- --- title: Visualization — heatmap slug: docs/build-workflow/viz-types/heatmap language: en description: "Authoritative reference for the heatmap visualization type: every supported mapping field, every chart option, color scale, format handling, cross-filter behavior, emphasis, and the ECharts escape hatch." last_modified: "2026-06-11T14:16:23.503000+00:00" docs_section: build-workflow docs_summary: Two-dimensional intensity grid with an explicit color scale. --- ## When to use heatmap Heatmap is the right choice when you want to render a measure across two categorical or temporal dimensions at once. Common uses: day-of-week vs hour-of-day for activity patterns; channel vs region for revenue density; product category vs month for seasonality. Use [grid](/docs/build-workflow/viz-types/grid) instead when the audience needs to read the actual numbers and one of the dimensions is high-cardinality. Use [scatter](/docs/build-workflow/viz-types/scatter) when the dimensions are continuous rather than categorical. ## Mapping - `mapping.x` — required. Column-axis field (one tick per distinct value). - `mapping.y` — required. Row-axis field. - `mapping.value` — required. Numeric field that drives the color intensity of each cell. ``` mapping: x: hour_of_day y: day_of_week value: order_count ``` ## chart shortcuts The `chart` block is typed and closed. - `chart.show_cell_labels` — boolean. Renders the cell value inside each cell. Style the labels with `chart.label` below. - `chart.cross_filter_emit` — `"x"` or `"y"`. Heatmap has two categorical axes, so you must pick which one's value is emitted on click. **Required** whenever `chart.cross_filter` is not explicitly `false`; the schema rejects a chart block that has neither. - `chart.cross_filter` — boolean, default `true`. Set to `false` to disable click emission entirely; in that case `cross_filter_emit` is not required. - `chart.height` — pixel height of the viz container. ## Color scale `chart.visual_map` is an object that controls the color gradient and the optional color-scale legend: - `show` — boolean. Show the color-scale legend on the side of the chart. - `orient` — `"horizontal"` | `"vertical"`. - `min`, `max` — explicit floor / ceiling. Override the data-derived range — useful when several heatmaps on the same dashboard need to share a scale. - `left` / `right` / `top` / `bottom` — pixel number or percent string for placing the color-scale legend. - `in_range.color` — array of hex strings: the gradient stops from low to high. Two stops produce a simple low-to-high gradient. - `text_style` — text styling for the color-scale labels. ``` chart: visual_map: show: true orient: vertical in_range: color: ["#e0f7f4", "#0d9488"] ``` ## Cell labels `chart.label` styles the per-cell numeric label when `chart.show_cell_labels` is on: - `position`, `rotate`, `color`, `font_size`, `font_weight`. - `formatter` — string template (no callbacks). Use `{c}` for the value. - `distance`, `align`, `vertical_align`, `clip`. Cell labels are useful when the heatmap is small and the audience needs the exact number; on dense heatmaps the labels become noise — leave them off and rely on the tooltip. ## Legend & tooltip `chart.legend` and `chart.tooltip` share the same shape as on [bar](/docs/build-workflow/viz-types/bar). For heatmap the tooltip with `trigger: item` reveals one cell at a time with both axis values and the cell value. ## Axes `chart.x_axis` and `chart.y_axis` share the same shape: - `name`, `name_location`, `name_gap`. - `axis_label.show`, `axis_label.rotate`, `axis_label.interval`, `axis_label.color`, `axis_label.font_size`, `axis_label.font_weight`, `axis_label.formatter`, `axis_label.max_chars`. ## format - `format.value` or `format[]` — pattern for cell labels and tooltip values. - `format` at the root — fallback. ## Cross-filter behavior - Click emits a value **only** when `chart.cross_filter_emit` is set to `"x"` or `"y"`. - The chosen axis becomes the cross-filter field; the clicked label becomes the value. - The clicked field must be declared as a parameter in at least one model used by the dashboard. - Disable per viz with `chart.cross_filter: false` (or simply leave `cross_filter_emit` unset). See [Cross-filtering](/docs/build-workflow/cross-filtering) for the full mechanism. ## Worked examples Activity by hour and day of week: ``` id: orders_by_day_hour title: Orders by Day and Hour query: "models/ec_orders.malloy::by_day_hour" type: heatmap mapping: x: hour_of_day y: day_of_week value: order_count chart: height: 320 show_cell_labels: true cross_filter_emit: x visual_map: show: true orient: vertical in_range: color: ["#e0f7f4", "#0d9488"] x_axis: name: Hour y_axis: name: Day format: order_count: "#,##0" published: true ``` Shared color scale across multiple heatmaps (set explicit min / max): ``` chart: visual_map: show: true min: 0 max: 5000 in_range: color: ["#e0f7f4", "#0d9488"] ``` ## Common pitfalls - **The chart looks empty even though the query has rows.** Make sure both `mapping.x` and `mapping.y` contain the right fields and the cell value field is non-null. - **The color scale is dominated by an outlier.** Set `visual_map.max` to clip the scale; values above the cap render at the top color. - **Labels are unreadable in dark cells.** Set a contrasting `chart.label.color`, or turn cell labels off and rely on the tooltip. - **Click does nothing.** Heatmap requires `chart.cross_filter_emit` set to `"x"` or `"y"`. Without it, no event fires regardless of `cross_filter`. - **Click should emit both x and y.** Heatmap can emit only one axis. If you need both, use a grid. - **Diverging color scale with a midpoint.** The exposed `visual_map.in_range.color` is a simple low-to-high gradient; for diverging scales, encode the divergence in the underlying value (e.g. signed delta). ---PAGE--- --- title: Visualization — funnel slug: docs/build-workflow/viz-types/funnel language: en description: "Authoritative reference for the funnel visualization type: every supported mapping field, every chart option, percent modes, format handling, cross-filter behavior, and the ECharts escape hatch." last_modified: "2026-06-11T14:16:19.803000+00:00" docs_section: build-workflow docs_summary: Conversion or pipeline stages with drop-off visualization. --- ## When to use funnel Funnel is the right choice for conversion flows, fulfillment pipelines, or any process where order between steps and drop-off matters — site visitor → product view → cart → checkout → purchase, or order created → packed → shipped → delivered. Each row of the query is one stage. Use [bar](/docs/build-workflow/viz-types/bar) instead when the categories are unordered or when you do not care about cumulative drop-off. Use [grid](/docs/build-workflow/viz-types/grid) when the audience needs the exact step counts and conversion rates side by side. ## Mapping - `mapping.stage` — required. String field whose values become the per-stage labels. - `mapping.value` — required. Numeric field for the per-stage size. ``` mapping: stage: stage value: order_count ``` The order of stages in the chart follows the order of rows in the query result. Sort in the underlying Malloy query to control sequence (typically by `order_index` or by descending value). ## chart shortcuts The `chart` block is typed and closed. - `chart.percent_mode` — `"first"` (default), `"total"`, or `"none"`. Picks the base for the percent shown next to each stage value: - **first** — relative to the first stage (conversion cascade: 100% → 80% → 60% …). - **total** — relative to the sum of all stages (share of the funnel). - **none** — no percentages, raw values only. - `chart.show_value_labels` — boolean. Turns stage labels on. Style them with `chart.label` below. - `chart.cross_filter` — boolean, default `true`. Set to `false` to suppress click emission for this viz (it still consumes pills set elsewhere). - `chart.height` — pixel height of the viz container. Pass-through layout fields: - `chart.left` / `chart.top` / `chart.bottom` — distance from the corresponding edge of the chart container. - `chart.width` — funnel horizontal width (string percent or pixel number). - `chart.gap` — pixels between stages. - `chart.min_size` — minimum stage width (e.g. `"10%"`). The narrowest stage will not get smaller than this even if its value is tiny. - `chart.max_size` — maximum stage width. - `chart.sort` — stage sort order. Default lets row order win; set to a known value if you want the renderer to reorder. ## Stage labels `chart.label` styles each stage's label when `chart.show_value_labels` is on: - `position` — `"inside"` (label sits on top of the stage), `"left"`, `"right"`, `"top"`, `"bottom"`. - `rotate`, `color`, `font_size`, `font_weight`. - `formatter` — string template. Use `{b}` for the stage name, `{c}` for the value, `{d}` for the percent (when `percent_mode` is `"first"` or `"total"`). - `distance`, `align`, `vertical_align`, `clip`. ## Legend & tooltip `chart.legend` and `chart.tooltip` share the same shape as on [bar](/docs/build-workflow/viz-types/bar). For a single-funnel viz the legend is usually redundant; set `chart.legend.show: false`. ## format - `format.y` or `format[]` — pattern for stage value labels and tooltip values. - `format` at the root — fallback. ## Cross-filter behavior Funnel participates fully in cross-filtering. Inside a dashboard: - **Emits** — clicking a stage adds a pill `{ field: , value: }`, provided `chart.cross_filter !== false` AND the field is declared as a parameter in at least one model on the dashboard. - **Consumes** — pills set elsewhere, and dashboard-level filters, become parameters on the next run; the funnel's underlying query re-runs and the stages recompute accordingly. - **Opt-out** — set `chart.cross_filter: false` to suppress click emission while still consuming pills. ## Worked examples Standard conversion funnel with first-stage percent: ``` id: ec_fulfillment_funnel title: Fulfillment Funnel query: "models/ec_fulfillment.malloy::funnel" type: funnel mapping: stage: stage value: order_count chart: height: 360 width: "80%" show_value_labels: true label: position: inside formatter: "{b}: {c} ({d}%)" percent_mode: first legend: show: false format: order_count: "#,##0" published: true ``` Total-share funnel (each stage shown as % of the sum): ``` chart: percent_mode: total show_value_labels: true ``` Compact funnel embedded in a dashboard row alongside KPIs: ``` chart: height: 240 width: "100%" gap: 6 min_size: "20%" max_size: "100%" show_value_labels: true label: position: right formatter: "{b}: {c}" percent_mode: none ``` ## Common pitfalls - **Stages are in the wrong order.** The funnel respects query-result order. Sort in the Malloy query to control sequence. - **Tiny last stages disappear.** Set `chart.min_size` to enforce a minimum visible width. - **Percent mode produces unexpected numbers.** Pick deliberately: `"first"` for conversion cascades (each stage ÷ first stage), `"total"` for share of total (each stage ÷ sum), `"none"` for raw counts. - **Stage labels overlap the bars.** Move the labels outside (`chart.label.position: "right"`) or reduce `font_size`. - **Clicking a stage does nothing.** Funnel emits cross-filter pills only inside a dashboard, and only if (a) `chart.cross_filter` is not `false`, and (b) the stage field is declared as a parameter in at least one model on the dashboard. Add the parameter declaration in the underlying Malloy model to enable the pill. - **Multi-funnel comparison.** Not supported in one viz; place two side-by-side funnels in a dashboard for A/B comparison. ---PAGE--- --- title: "Visualization — grid (alias: table)" slug: docs/build-workflow/viz-types/grid language: en description: "Authoritative reference for the grid / table visualization type: every supported mapping field, grid block options, frozen columns, composite cells, pagination, format handling, cross-filter behavior, and adapter notes." last_modified: "2026-06-11T14:16:21.763000+00:00" docs_section: build-workflow docs_summary: Row-level data table; supports pagination, frozen columns, composite cells. --- ## 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. Type identifiers `grid` and `table` map to the same renderer; either is accepted. Use a chart-style viz ([bar](/docs/build-workflow/viz-types/bar), [line](/docs/build-workflow/viz-types/line)) when the question is about a comparison or a trend rather than the raw rows. Use [report_matrix](/docs/build-workflow/viz-types/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 (alias `table` is also accepted). 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: changing pages re-runs the underlying query with the new page parameters. Cost characteristics differ by adapter — see [Source adapter differences](/docs/reference/source-adapters). ## 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 (when the column is configured as clickable) cross-filters the rest of the dashboard by the column name and clicked value. - The clicked field must be declared as a parameter in at least one model used by the dashboard, or the click is silently ignored. - The top-level `emphasis` block can declaratively highlight a row matching a related cross-filter value. - Disable per viz with `chart.cross_filter: false`. See [Cross-filtering](/docs/build-workflow/cross-filtering) for the full mechanism. ## Worked examples Order detail with frozen first column, currency formats, and a comparison column: ``` 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 column_widths: order_date: "120px" revenue: "140px" column_formats: revenue: currency avg_order_value: currency item_count: integer formats: currency: "$#,##0.00" integer: "#,##0" comparison_columns: - revenue pagination: page_size: 50 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 ``` ## Common pitfalls - **The grid takes up too much horizontal space.** Drop columns from `mapping.columns` or set explicit narrower widths in `grid.column_widths`. - **Column widths do not stick.** Make sure the column names in `grid.column_widths` match the field names in the query result exactly. - **Sorting is per-page only.** Server-side pagination means client-side sorts only see one page. To sort across all rows, sort in the Malloy query. - **The header label is wrong.** Set `grid.labels` to override the raw column name (or rename in the Malloy query). - **The comparison indicator points the wrong way.** The renderer derives direction from the sign of the value. Encode "good" deltas with positive sign and "bad" with negative. - **Cross-filter clicks have no effect.** The clicked column must be declared as a parameter in at least one model used by the dashboard. - **Grid expected to group rows with subtotals.** Grid is flat; use [report_matrix](/docs/build-workflow/viz-types/report-matrix) for grouping. ---PAGE--- --- title: Visualization — report_matrix slug: docs/build-workflow/viz-types/report-matrix language: en description: "Authoritative reference for the report_matrix visualization type: matrix block configuration, sections, totals, formats, behavior, and PDF export options." last_modified: "2026-06-11T14:16:29.855000+00:00" docs_section: build-workflow docs_summary: Hierarchical report with grouped rows, totals, and PDF export. --- ## When to use report_matrix report_matrix is the right choice when the audience needs a structured report with grouped rows, nested sections, subtotals, and group totals. It is designed for document-mode dashboards and PDF export, while still supporting click-to-filter inside interactive dashboards. Use [grid](/docs/build-workflow/viz-types/grid) instead when the rows do not need grouping. Use a chart-style viz when the question is about a single metric across categories rather than a structured tabular report. ## Mapping report_matrix takes its column structure from the query result. There is no required `mapping` block; the matrix configuration lives under the `matrix` block instead. ## Columns & presentation These keys control which columns the matrix shows and how each column renders: - `matrix.key_columns` — array of column names rendered as **key cells** (bold, polarity-based coloring). Use for the metric column readers should focus on (e.g. cumplimiento, conversion rate). - `matrix.trend_columns` — array of column names rendered with a trend icon (up / down / dash) next to the value. - `matrix.labels` — object mapping column name to display label (overrides the raw column name in the header). - `matrix.column_formats` — object mapping column name to a format key. - `matrix.formats` — object mapping format key to a pattern (two-step indirection, like `grid.formats`). - `matrix.column_widths` — object mapping column name to a width (`"150px"`, `"20%"`, or numeric). - `matrix.uniform_column_widths` — boolean or `"auto"`. When set, all columns share the same auto-fit width. - `matrix.uniform_column_widths_min_px` — integer. Default `80`. - `matrix.uniform_column_widths_max_px` — integer. Default `520`. ## Summary table The matrix can render a comparative summary above the detail rows: - `matrix.show_summary` — boolean. Default `true`. - `matrix.summary_title` — string title above the summary. - `matrix.summary_columns` — array of column names included in the summary. ## Grouping & sections Two ways to organise the rows: automatic grouping by a column, or manual sections. #### Automatic grouping - `matrix.group_by` — column name. Group result rows by this field; each group becomes a collapsible section. - `matrix.group_key_field` — column name to render as the first column inside each group section. - `matrix.group_columns` — array of column names rendered inside each group. #### Manual sections ``` matrix: sections: - id: revenue title: Revenue breakdown columns: [period, units, revenue] - id: cost title: Cost breakdown columns: [period, cogs, opex] ``` ## Totals Totals live in two siblings under `matrix`: `group_totals` (one row per group section) and `overall_totals` (one row across all groups). Both share the same shape: - `enabled` — boolean. - `label` — string. Default `"TOTAL"`. - `columns` — array of column names to sum. If empty, numeric columns are summed automatically. - `computed` — array of computed field objects rendered alongside the sums. Each item: - `field` — output field name. - `type` — `"ratio"` or `"percent_delta"`. - `numerator` — field name for the dividend (ratio) or current period (delta). - `denominator` — field name for the divisor (ratio) or baseline (delta). ``` matrix: group_totals: enabled: true label: TOTAL ZONA columns: [unidades, ventas] computed: - field: cumplimiento type: percent_delta numerator: ventas denominator: presupuesto overall_totals: enabled: true label: TOTAL GENERAL columns: [unidades, ventas] ``` ## Behavior `matrix.behavior` controls how sections expand and how the matrix renders for PDF export: - `web_mode` — `"all_open"`, `"single_open"`, `"expand_all"`, or empty. How sections behave in the browser when the user clicks a header. - `default_open` — `"all"` or a specific section id. Which sections start expanded on first load. - `pdf_expand_all` — boolean. Default `false`. **Critical for PDF export**: without this, collapsed groups disappear from the printed document. ## format Use `matrix.column_formats` + `matrix.formats` as the primary formatting path; the root `format` field is checked as a fallback only. ## Cross-filter behavior - Clicking a body cell whose column is declared as a parameter in at least one model on the dashboard emits a pill `{ field: , value: }`. - Cells in **group totals** and **overall totals** rows are **not** clickable — they aggregate across multiple key values, so a click would be ambiguous. - Cells in **trend columns** (rendered with up/down direction badges) are not clickable — they show direction over a numeric value, not a categorical pick. - Section headers (auto-grouped via `matrix.group_by` or hand-crafted via `matrix.sections`) are not clickable in this version. The `
` click is reserved for expand/collapse. - **Document mode** (`tableContext: document`) suppresses cell-click wiring entirely — document/PDF rendering has no interactive target. - Disable per viz with `chart.cross_filter: false`. See [Cross-filtering](/docs/build-workflow/cross-filtering) for the full mechanism. ## Worked example ``` id: nv_visitador_resumen title: Resumen por visitador query: "models/nv_comercial.malloy::resumen_por_visitador" type: report_matrix matrix: group_by: zona group_key_field: visitador group_columns: [periodo, unidades, ventas, cumplimiento] key_columns: [cumplimiento] trend_columns: [cumplimiento] group_totals: enabled: true label: TOTAL ZONA columns: [unidades, ventas] computed: - field: cumplimiento type: percent_delta numerator: ventas denominator: presupuesto overall_totals: enabled: true label: TOTAL GENERAL columns: [unidades, ventas] column_formats: ventas: currency presupuesto: currency cumplimiento: percent formats: currency: "$#,##0" percent: "#,##0.0%" behavior: web_mode: single_open default_open: all pdf_expand_all: true published: true ``` ## Common pitfalls - **PDF export shows fewer rows than the browser.** Set `matrix.behavior.pdf_expand_all: true`. Without it, collapsed groups stay collapsed in the printed document. - **Auto-summed totals include the wrong columns.** List the columns you want summed explicitly in `group_totals.columns` / `overall_totals.columns`; leaving them empty auto-sums all numeric columns, which can include things like ID-style numbers. - **Computed total field shows a strange number.** Both `numerator` and `denominator` must reference fields present in the rows being summed. Check spelling against the query result. - **Sections do not start expanded.** Set `matrix.behavior.default_open: "all"`, or the specific section id you want open on first load. - **Click on a cell does nothing.** Check that (a) the column name is declared as a parameter in at least one model on the dashboard, (b) the value is non-null, (c) the cell isn't in a totals or trend row (those are non-clickable by design), and (d) `matrix.cross_filter` isn't set to `false`. - **Cell formats are inconsistent across columns.** Use the `matrix.column_formats` + `matrix.formats` indirection so the same currency / percent pattern applies everywhere it should. ---PAGE--- --- title: Filters slug: docs/build-workflow/filters language: en description: "Filter reference: how user-facing filter controls produce Malloy params, the resolver flow, and the per-type subpages." last_modified: "2026-06-11T14:16:00.218000+00:00" docs_section: build-workflow docs_summary: How filters wire into Malloy queries; per-type reference. --- ## What a filter is in Looky A filter is a user-facing control on a visualization or a dashboard. The user picks a value; Looky sends it to the underlying Malloy query as a named parameter. Filters live in the visualization (or dashboard) YAML under `filters: []`, one entry per control. Filters and cross-filtering are different mechanisms. Filters are declarative controls the user explicitly interacts with. [Cross-filtering](/docs/build-workflow/cross-filtering) is automatic — clicking on a chart inside a dashboard adds a "pill" that becomes a parameter without any YAML. ## Choosing a filter type Five filter types are supported. Pick the one whose UI matches the input the user is choosing. - [`select`](/docs/build-workflow/filters/select) — dropdown picker. The option list is either declared in YAML or resolved dynamically by running a Malloy query at page load. - [`cutoff_date`](/docs/build-workflow/filters/cutoff-date) — single date that automatically expands to a `date_from` / `date_to` range running from the first day of that month to the chosen day. Use for "month-to-date as of" reports. - [`date_range`](/docs/build-workflow/filters/date-range) — open from / to date picker. Use when the user needs to pick both endpoints freely. - [`date_range_preset`](/docs/build-workflow/filters/date-range-preset) — date range chosen from named presets (*last_30_days*, *this_month*, *last_month*, *this_year*, etc.). The default for most operational dashboards. - [`month`](/docs/build-workflow/filters/month) — year + month picker with configurable bounds. Use when the analysis is keyed on a single month (monthly close, monthly billing). Other filter types are not supported. ## Anatomy of a filter declaration Every filter entry is an object with at least a `type`. The other keys depend on the type — see each type's reference page for the full list. ``` id: orders_grid type: grid query: "models/ec_fulfillment.malloy::detail" filters: - type: select id: status label: Status param: status options_query: "models/ec_fulfillment.malloy::status_options" default: all - type: date_range_preset id: period label: Period default: preset: last_30_days ``` The order in the array is the order the controls render in the UI. ## How a filter value reaches the Malloy query The value picked in the filter control is sent to the query as a named parameter. The model declares the parameter and uses it inside the query. - For **select**, **cutoff_date**, and **month**, the parameter name is whatever you put in the filter's `param` field (or, when omitted, the filter's `id`). - For **date_range** and **date_range_preset**, the parameters are `date_from` and `date_to` by default. Override the names through the filter's `bindings: { date_from, date_to }` block when the model expects different parameter names. Naming convention: parameters whose Malloy name starts with `p_` have the prefix stripped for the external name. A model parameter declared as `p_start_date` is set by sending `start_date`. See [Malloy support](/docs/build-workflow/malloy-support). ## Where filters live - **On a single visualization.** Use the visualization's `filters: []` when the control is local to that chart — for example, a per-grid status filter. - **On a dashboard.** Use the dashboard's `filters: []` when the control should narrow every viz on the page — for example, a global "Period" filter at the top of an executive dashboard. A dashboard-level filter applies to every viz the dashboard contains, but only if each viz's underlying model declares the matching parameter. ## Filters vs cross-filtering Filters are declarative — you decide in YAML which controls the user sees. Cross-filtering is interactive — the user clicks a chart and the dashboard adds the implied filter as a pill. Both end up in the same place: a Malloy parameter on the next run. The only difference is who initiates the filter. For most dashboards, mix the two: a small set of declared filters at the top (period, region, channel) plus the implicit cross-filter that responds to clicks. See [Cross-filtering](/docs/build-workflow/cross-filtering). ## Adapter differences Filter UI behaves the same on any source. Differences appear when the filter value is bound to the underlying parameter — most importantly for date / timestamp filters against Postgres- or MySQL-backed models, which use the raw-SQL `@param` placeholder pattern (each `@param` declared on the source signature, value substituted into the SQL string at run time). See [Source adapter differences](/docs/reference/source-adapters) and [Models](/docs/build-workflow/models). ## Common pitfalls - **The filter shows up but does not narrow the data.** The model must declare a parameter with the matching name (after the `p_` prefix is stripped) inside the source's parentheses. Check the source signature in the `.malloy` file. - **Two filters bind to the same parameter.** Only one wins. Pick a single source of truth per parameter — either a dashboard-level filter or a per-viz filter, not both. - **Validate fails with `unbound_param`.** The model uses a raw-SQL `@param` placeholder that is not declared on the source signature. The error names the missing parameter — add it to the source's parentheses. - **Date filter on a Postgres or MySQL model fails.** Use the raw-SQL `@param` placeholder pattern in the model's SQL with a matching declaration on the source signature — see [Source adapter differences](/docs/reference/source-adapters). - **The default value does not load.** For `select`, the default must match the `id` of one of the options. For date filters, use the supported tokens (`{{today}}`, `{{start_of_month}}`, etc.) or a literal ISO string. - **The filter expects a value the user does not provide.** Either set a sensible default in the filter, or declare a default on the source signature so the query still runs when the parameter is missing. ---PAGE--- --- title: Filter — select slug: docs/build-workflow/filters/select language: en description: "Authoritative reference for the select filter: required and optional fields, static options vs options_query, default handling, and how the value reaches the Malloy query." last_modified: "2026-06-11T14:16:07.615000+00:00" docs_section: build-workflow docs_summary: Dropdown filter with static options or a Malloy options_query. --- ## When to use select Use `select` when the user needs to pick one value from a known set: a status, a region, a brand, a category. The dropdown reflects either a static option list declared in YAML or a dynamic list resolved by running a Malloy query at page load. Reach for one of the date filters instead when the input is a date or a date range. Reach for [cross-filtering](/docs/build-workflow/cross-filtering) when the user is meant to drill in by clicking a chart rather than picking from a dropdown. ## Required fields - `type: select` Either `options` or `options_query` must be present (and one of them only). ## Optional fields - `id` — internal identifier; also the default parameter name when `param` is not set. - `label` — display label above the dropdown. - `param` — the Malloy parameter name to bind the selected value to. Defaults to `id`. - `default` — initial selected value. Must match the `id` of one of the options. - `options` — array of `{id, label}` objects. Use this for a static option list. - `options_query` — string in the format `path/to/source.mal::QueryName`. Looky runs this query at page load and turns the rows into options. ## Static options Declare the list inline. Best for small, stable enumerations — statuses, on/off toggles, periodicities. ``` filters: - type: select id: status label: Status param: status default: all options: - { id: all, label: All } - { id: active, label: Active } - { id: cancelled, label: Cancelled } - { id: refunded, label: Refunded } ``` Each option must have an `id` (the value sent to the query) and a `label` (the text shown in the dropdown). The `id` is what your Malloy model receives — design the model parameter to accept the same shape. ## Dynamic options (options_query) Use when the option list comes from data — brands, customers, countries, anything that changes over time. ``` filters: - type: select id: brand label: Brand param: brand options_query: "models/ec_revenue.malloy::brand_options" default: all ``` The query `brand_options` must return rows with at least `id` and `label` columns. The Malloy side typically looks like: ``` view: brand_options is { group_by: id is brand label is brand order_by: brand asc } ``` Each row of the query becomes one option. Looky normalises every row to an `{id, label}`: - **id** — first non-null of `id`, `indicator_code`, `group_code`. - **label** — first non-null of `label`, `indicator_label`, `group_label`. - **sort key** — if a row has `sort_order` (or `indicator_order` / `group_order`), options are ordered numerically by that field; otherwise they are ordered alphabetically by label. Rows missing both an id and a label are dropped. ## Default value If `default` is set, it is the value of the parameter when the page loads. The reset button restores this value. If no default is set, the parameter is unset until the user picks a value, and the underlying query must accept the parameter's absence (typically by declaring its own default). For dynamic option lists, declaring an "all" sentinel option and using it as the default is a common pattern — your Malloy model treats `"all"` as "no filter". ## How the value reaches the Malloy query The chosen option's `id` is sent as the named parameter — by default named after the filter's `id`, or `param` if you set it. The Malloy model declares a parameter with the matching name (after the `p_` prefix is stripped — see [Malloy support](/docs/build-workflow/malloy-support)) and uses it in the query. ``` # in the Malloy model ##! experimental.parameters source: orders( p_status::string is "all" ) is bigquery.table('...') extend { view: detail is { where: p_status = "all" or status = p_status select: * } } ``` ## Adapter differences `select` values are typically strings or short identifiers; all three adapters bind them identically. The only edge case is when the parameter on the model side is declared as a date or timestamp — that pattern follows the rules at [Source adapter differences](/docs/reference/source-adapters). ## Worked examples Static enumeration with an "all" sentinel: ``` filters: - type: select id: status label: Status param: status default: all options: - { id: all, label: All } - { id: active, label: Active } - { id: cancelled, label: Cancelled } ``` Dynamic from a query, with custom parameter binding: ``` filters: - type: select id: brand_filter label: Brand param: p_brand options_query: "models/ec_revenue.malloy::brand_options" default: all ``` Multiple selects on the same dashboard, each filtering a different dimension: ``` filters: - type: select id: country label: Country options_query: "models/ec_revenue.malloy::country_options" default: all - type: select id: channel label: Channel options: - { id: all, label: All channels } - { id: organic, label: Organic } - { id: paid, label: Paid } - { id: direct, label: Direct } default: all ``` ## Common pitfalls - **The default value does not load.** The default must match the `id` of one of the options. For dynamic lists, ensure the "all" sentinel is included in the query result (or in `options` alongside it). - **The filter takes too long to load.** The `options_query` runs at page load. If the underlying query is slow, the dropdown will block. Pre-aggregate the option list in the Malloy query, or cache it via a sidecar. - **The Malloy model does not accept the parameter.** Declare the parameter in the model with a sensible default, so the query still runs when the parameter is missing or set to a sentinel. - **The option labels and the option ids drift apart.** Keep the `label` human-readable and the `id` stable; the `id` is the contract with the query, not the label. - **Capitalisation mismatch between the option id and the data.** The model's where-clause is exact-match by default. Either normalise both sides in the model (`lower(status) = lower(p_status)`) or align the option ids with the data exactly. ---PAGE--- --- title: Filter — cutoff_date slug: docs/build-workflow/filters/cutoff-date language: en description: "Authoritative reference for the cutoff_date filter: token defaults, range expansion, and Malloy param mapping." last_modified: "2026-06-11T14:16:01.690000+00:00" docs_section: build-workflow docs_summary: Single cutoff date that expands to date_from / date_to range. --- ## When to use cutoff_date Use `cutoff_date` when the analysis is "everything from the start of the month up to a chosen day" — month-to-date revenue, monthly closing reports, "as-of" snapshots. The user picks a single date; Looky automatically sends both `date_from` (the first day of that month) and `date_to` (the chosen date) as parameters. Use [date_range](/docs/build-workflow/filters/date-range) when the user needs to pick both endpoints freely. Use [date_range_preset](/docs/build-workflow/filters/date-range-preset) when the choice is among named presets like "last 30 days" or "this quarter". ## Required fields - `type: cutoff_date` ## Optional fields - `label` — display label above the picker. - `default` — date token (see below) or ISO date string. Defaults to `"{{today}}"`. - `granularity` — controls how `date_from` is derived from the chosen date. One of `day`, `month` (default), `quarter`, `year`. With `day`, `date_from` equals the chosen date (one-day window). With `month`, the first day of the chosen date's month. With `quarter`, the first day of the chosen date's calendar quarter (Jan, Apr, Jul, or Oct). With `year`, January 1 of the chosen date's year. Honored at both dashboard and visualization level. - `include_current_day` — boolean. Default `false`. See [Maximum selectable date](#maximum-selectable). ## Maximum selectable date — today or yesterday By default, the `cutoff_date` filter **caps the maximum date at `today − 1 day`**. The picker does not let the user select today, the `{{today}}` token resolves to today but is clamped down to yesterday, and the `date_to` sent to the query always lands on yesterday. This is the historical convention for "as-of yesterday's close" reports: in-progress day data is usually incomplete until end-of-day, so the report anchors to the last closed day. For live operational dashboards — where you do want to include the in-progress day — add `include_current_day: true`: ``` filters: - type: cutoff_date label: Cutoff default: "{{today}}" include_current_day: true ``` With the flag on: - The picker allows today as the maximum selectable date. - `{{today}}` resolves to today and is not clamped. - The `date_to` sent to the query is inclusive of the current day. The flag is opt-in (default `false`) to preserve back-compat with daily-report dashboards (PDFs, period closes) whose semantics expect "through yesterday's close". **When to turn it on:** live operational dashboards, "what have I accrued so far" KPIs, in-progress billing, progress monitoring. **When to leave it off (default):** reports exported to PDF and sent as end-of-day summaries, historical snapshots, reports that need post-close consolidation. The flag is declared independently on each filter — on the visualization (if opened standalone) and on the dashboard (if it has a global filter that propagates via `param:`). If the viz lives inside a dashboard with a global filter, the dashboard flag is sufficient; the resolved value reaches the viz via the param. ## Date tokens for defaults These tokens resolve against the user's current timezone at page load: - `{{today}}` - `{{yesterday}}` - `{{start_of_week}}` / `{{end_of_week}}` - `{{start_of_month}}` / `{{end_of_month}}` Anything not in this list is treated as a literal ISO date string (e.g. `"2024-12-31"`). ## How the value reaches the Malloy query The chosen date sets two parameters on every query that the filter applies to: - `date_from` — derived from the chosen date according to `granularity` (defaults to first day of its month). - `date_to` — the chosen date itself. Your Malloy model declares those two parameters and uses them in `where:` clauses: ``` ##! experimental.parameters source: orders( p_date_from::date is @2024-01-01, p_date_to::date is @2024-12-31 ) is bigquery.table('...') extend { view: revenue_mtd is { where: created_at::date >= p_date_from and created_at::date <= p_date_to aggregate: revenue is sum(sale_price) } } ``` ## Adapter differences Both `date_from` and `date_to` are date / timestamp parameters. On BigQuery they bind natively. On Postgres and MySQL the model must use the `@param` placeholder pattern in the underlying SQL — see [the source adapter comparison](/docs/reference/source-adapters) and [Malloy support](/docs/build-workflow/malloy-support). ## Worked examples Default cutoff is today: ``` filters: - type: cutoff_date label: Cutoff default: "{{today}}" ``` Default cutoff is end of last month (for monthly close reports): ``` filters: - type: cutoff_date label: Closing date default: "{{end_of_month}}" ``` Default cutoff is a literal date (for a fixed historical snapshot): ``` filters: - type: cutoff_date label: As of default: "2024-12-31" ``` ## Common pitfalls - **The query needs a different range shape.** `cutoff_date` is opinionated: `date_from` snaps to the start of the chosen date's `granularity` window (day, month, quarter, year). If you need the user to pick the lower bound freely, use [date_range](/docs/build-workflow/filters/date-range). - **The Malloy model expects different parameter names.** `cutoff_date` always emits `date_from` / `date_to`. Adapt the model parameter names accordingly, or use [date_range_preset](/docs/build-workflow/filters/date-range-preset) which supports custom bindings. - **The query fails on Postgres or MySQL with a date-binding error.** Add the `@param` placeholder pattern in the model's SQL — see [the source adapter comparison](/docs/reference/source-adapters). - **The default token is not recognised.** Only the tokens listed above are valid. Anything else is treated as a literal date string and silently does nothing useful if it is malformed. - **Multiple cutoff_date filters in one viz.** Only one cutoff applies. Use a different filter type (or two separate parameter pairs) if you need multiple time windows. - **"The report only shows up to yesterday even though I set `{{today}}`".** That is the default behaviour — the cutoff caps at `today − 1`. To include the in-progress day, add `include_current_day: true` to the filter. See [Maximum selectable date](#maximum-selectable). - **The `include_current_day` flag is on the viz but the dashboard still shows yesterday.** When the viz lives inside a dashboard with a global filter (`param: cutoff_date`), it is the *dashboard*'s filter flag that wins — the dashboard resolves the value and propagates it to the viz via the param. Turn the flag on at the dashboard filter, not just at the viz. ---PAGE--- --- title: Filter — date_range slug: docs/build-workflow/filters/date-range language: en description: "Authoritative reference for the date_range filter: from/to defaults, token resolution, and Malloy param mapping." last_modified: "2026-06-11T14:16:04.674000+00:00" docs_section: build-workflow docs_summary: Open from/to date picker. --- ## When to use date_range Use `date_range` for an open from / to date picker. The user picks both endpoints freely; the values are sent verbatim as parameters. Use [cutoff_date](/docs/build-workflow/filters/cutoff-date) when the lower bound is always the start of the chosen date's month. Use [date_range_preset](/docs/build-workflow/filters/date-range-preset) when the user picks among named presets (last 30 days, this month, etc.) — preferred for most operational dashboards. ## Required fields - `type: date_range` ## Optional fields - `label` — display label above the picker. - `default` — object with `from` and `to` keys. Each accepts a date token (see below) or an ISO date string. Either or both may be omitted. ## Date tokens for defaults The same set of tokens works on the `from` and `to` sub-keys (resolved against the user's current timezone at page load): - `{{today}}` - `{{yesterday}}` - `{{start_of_week}}` / `{{end_of_week}}` - `{{start_of_month}}` / `{{end_of_month}}` Anything not in the list is treated as a literal ISO date string. ## How the value reaches the Malloy query The picker emits a `{from, to}` pair on submit. Looky sets two parameters on every query the filter applies to: - `date_from` — the chosen "from" date. - `date_to` — the chosen "to" date. The Malloy model declares those parameters and uses them in `where:` clauses, exactly like with [cutoff_date](/docs/build-workflow/filters/cutoff-date). ## Adapter differences Same date / timestamp caveat as [cutoff_date](/docs/build-workflow/filters/cutoff-date) — see [the source adapter comparison](/docs/reference/source-adapters) for the Postgres / MySQL pattern. ## Worked examples Default to month-to-date: ``` filters: - type: date_range label: Period default: from: "{{start_of_month}}" to: "{{today}}" ``` Default to a fixed historical period (for a frozen report): ``` filters: - type: date_range label: Period default: from: "2024-01-01" to: "2024-12-31" ``` Default open to the current day, with no lower bound (the model's parameter default kicks in): ``` filters: - type: date_range label: Period default: to: "{{today}}" ``` ## Common pitfalls - **The query fails on Postgres or MySQL with a date-binding error.** Add the `@param` placeholder pattern in the model's SQL — see [the source adapter comparison](/docs/reference/source-adapters). - **"From" is later than "to".** The picker prevents the user from picking an inverted range, but a default with `from` later than `to` renders an empty selection. Use a sensible default and let the user adjust. - **Time-of-day precision matters but the picker is date-only.** The picker only sends dates. Encode time-of-day in the Malloy query (e.g. compare against `created_at::date`) or use a different parameter pattern. - **Multiple date_range filters in one viz.** They all bind to `date_from` / `date_to` — only one wins. Either keep one date_range filter, or use [date_range_preset](/docs/build-workflow/filters/date-range-preset) with custom `date_from_param` / `date_to_param` bindings to use different parameter names. - **The default tokens are not recognised.** Only the listed tokens are valid; anything else is taken as a literal date string. ---PAGE--- --- title: Filter — date_range_preset slug: docs/build-workflow/filters/date-range-preset language: en description: "Authoritative reference for the date_range_preset filter: built-in presets, custom param bindings, and Malloy param mapping." last_modified: "2026-06-11T14:16:03.110000+00:00" docs_section: build-workflow docs_summary: Date range with named presets like last_30_days, this_month. --- ## When to use date_range_preset Use `date_range_preset` when the user picks a date range from a small set of named presets — last 30 days, this month, this quarter, year-to-date, etc. This is the right default for most operational dashboards: it gives users one click to switch period without typing dates. Use [date_range](/docs/build-workflow/filters/date-range) when the user must pick arbitrary endpoints. Use [cutoff_date](/docs/build-workflow/filters/cutoff-date) when the input is a single date instead of a range. ## Required fields - `id` — unique within the dashboard's filters block. - `type: date_range_preset` ## Optional fields - `label` — display label above the picker. - `default` — object selecting the preset to apply on first load: `{ preset: }`. Must reference an id from `presets` (or one of the built-ins if `presets` is omitted). - `presets` — array of `{ id }` objects in the order they should appear in the picker. The id must be one of the built-in preset ids below; the schema rejects unknown ids at `looky validate` time. - `bindings` — object overriding the parameter names: `{ date_from: , date_to: }`. Use this when the model expects parameter names other than the defaults `date_from` / `date_to`. ## Built-in preset ids All preset ids are computed in the picker's runtime. Authors do not declare `from`/`to` tokens — the picker resolves the id to a concrete `{date_from, date_to}` on each run, using the workspace timezone. - `today` — single day: today's date. - `yesterday` — single day: today minus one. - `last_7_days`, `last_30_days`, `last_90_days` — rolling N-day windows ending today (inclusive). - `this_week`, `last_week` — ISO week (Monday through Sunday). - `this_month`, `last_month` — calendar month. - `this_quarter`, `last_quarter` — calendar quarter (Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec). - `this_year`, `last_year` — calendar year. Writing any other id is rejected by `looky validate` with a schema error pointing at the offending `presets[i].id`. If a preset you need isn't in the list, that's a request for a new built-in — not something authors patch in YAML. ## How the value reaches the Malloy query 1. The picker resolves the selected preset id to a `{date_from, date_to}` pair using the workspace timezone. 2. Those values flow as the `date_from` and `date_to` parameters into every viz on the dashboard. 3. If `bindings` is set, the same values are also bound to the parameter names listed there. The Malloy model declares the parameters and uses them in `where:` clauses, exactly like with the other date filters. ## Adapter differences Same date / timestamp caveat as [cutoff_date](/docs/build-workflow/filters/cutoff-date) — see [the source adapter comparison](/docs/reference/source-adapters) for the Postgres / MySQL pattern. ## Worked examples Default to this year, with five presets in the picker: ``` filters: - id: period type: date_range_preset label: Period default: preset: this_year presets: - id: last_30_days - id: this_month - id: this_quarter - id: this_year - id: last_year ``` Bind to a custom parameter pair (the model uses `start_date` / `end_date`): ``` filters: - id: period type: date_range_preset label: Period default: preset: this_month bindings: date_from: start_date date_to: end_date ``` Two independent ranges in the same dashboard, each on its own parameters: ``` filters: - id: current_period type: date_range_preset label: Current default: preset: this_month bindings: date_from: current_from date_to: current_to - id: comparison_period type: date_range_preset label: Compare to default: preset: last_month bindings: date_from: compare_from date_to: compare_to ``` ## Common pitfalls - **`looky validate` rejects a preset id.** The id must be one of the built-ins. Typos like `last_7_dys` or invented ids like `fiscal_q1` are rejected at push time so the dashboard never lands in prod with a silently-broken preset. - **The default doesn't apply on first load.** `default.preset` must exactly match an id from `presets` (or, if `presets` is omitted, one of the built-ins). - **The query doesn't pick up the date range.** The Malloy model must declare the parameters that the picker binds to — by default `date_from` / `date_to`, or the names in `bindings` if you set them. - **Two date filters fight over `date_from` / `date_to`.** If you have multiple date_range_preset filters on the same dashboard, set `bindings` on each to bind to distinct parameter names. ---PAGE--- --- title: Filter — month slug: docs/build-workflow/filters/month language: en description: "Authoritative reference for the month filter: token defaults, year-window bounds, and Malloy param mapping." last_modified: "2026-06-11T14:16:06.089000+00:00" docs_section: build-workflow docs_summary: Year + month picker with configurable bounds. --- ## When to use month Use `month` when the analysis is keyed on a single month — monthly close, monthly billing, comparable-month-over-comparable-month reporting. The user picks a year + month; the value is sent as a `YYYY-MM` string to the configured parameter. Use a date filter ([cutoff_date](/docs/build-workflow/filters/cutoff-date), [date_range](/docs/build-workflow/filters/date-range), [date_range_preset](/docs/build-workflow/filters/date-range-preset)) instead when the analysis spans an arbitrary date range rather than a calendar month. ## Required fields - `type: month` ## Optional fields - `id` — internal identifier. - `label` — display label above the picker. - `param` — parameter name to bind the value to. Defaults to `id`; if both are missing, defaults to the literal `"month"`. - `default` — month token (see below) or ISO string in `YYYY-MM` format. Defaults to the current month. - `year_min` / `year_max` — explicit year bounds for the picker. - `year_window`, `year_window_past`, `year_window_future` — relative bounds. Defaults to a 5-year window centred on the current year. - `month_picker` — nested object that lets you override the year-bound config without polluting the top level. ## Default tokens Recognised tokens for the `default` field: - `{{current_month}}` — the current calendar month in the user's timezone. Aliases: `{{this_month}}`, `{{month}}`. - `{{previous_month}}` — the calendar month before the current one (rolls back across year boundaries). Alias: `{{last_month}}`. Anything else is treated as a literal `YYYY-MM` string (e.g. `"2024-12"`). Unrecognised tokens fall through to literal parsing and silently default to the current month if malformed. ## How the value reaches the Malloy query The picker emits a `YYYY-MM` string on submit. Looky sets the named parameter to that string. There is no automatic expansion to `date_from` / `date_to` — if the model needs a date range, derive it inside the query from the month string. ``` ##! experimental.parameters source: revenue( p_month::string is "2024-01" ) is bigquery.table('...') extend { view: monthly_revenue is { where: format_datetime('%Y-%m', created_at) = p_month aggregate: revenue is sum(sale_price) } } ``` ## Adapter differences The month value is a string, not a date / timestamp parameter, so the Postgres / MySQL caveat does not apply directly. If the model casts the month string to a date inside its parameter declaration, the same caveat applies for that derived parameter — see [Source adapter differences](/docs/reference/source-adapters). ## Worked examples Default to the current month, 5-year window centred on this year: ``` filters: - type: month id: month label: Month param: month default: "{{current_month}}" ``` Default to a literal historical month, with custom year window (3 years past, 1 year future): ``` filters: - type: month id: month label: Month default: "2024-12" year_window_past: 3 year_window_future: 1 ``` Frozen historical month (default fixed to a literal): ``` filters: - type: month label: Reporting month default: "2024-12" year_min: 2020 year_max: 2024 ``` Two month filters comparing periods: ``` filters: - type: month id: current_month label: Current param: current_month default: "{{current_month}}" - type: month id: comparison_month label: Compare to param: comparison_month default: "2024-11" ``` ## Common pitfalls - **The picker does not show the year you want.** Set explicit `year_min` / `year_max`, or expand `year_window_past` / `year_window_future`. - **The model expects a date, not a string.** The month picker emits `YYYY-MM`. Either make the model parameter a string and parse it in the query, or use a date filter ([date_range_preset](/docs/build-workflow/filters/date-range-preset) with the *this_month*/*last_month* presets) instead. - **Two month filters bind to the same parameter.** Each must have a distinct `param` (or `id` when `param` is omitted), otherwise only one wins. - **Multi-month selection is needed.** Not supported by this filter — use `date_range` or `date_range_preset`. - **Quarter or week selection is needed.** Not implemented — derive these inside the model from a date filter, or pre-compute the periodicity in the underlying query. ---PAGE--- --- title: Cross-filtering slug: docs/build-workflow/cross-filtering language: en description: "Authoritative reference for cross-filtering: which viz types emit, the dashboard pill mechanism, the per-viz opt-out, and what is NOT supported (no dashboard-YAML cross_filter declaration)." last_modified: "2026-06-11T14:15:55.551000+00:00" docs_section: build-workflow docs_summary: How click events become dashboard pills and Malloy params. --- ## What cross-filtering is in Looky Inside a dashboard, clicking a data point in one visualization narrows every other visualization on the page. The clicked field/value pair becomes a removable "pill" the user sees at the top of the dashboard, and Looky applies that field/value to every other viz as a parameter on its next run. Cross-filtering is a runtime behavior, not a YAML feature. There is **no** dashboard-YAML `cross_filter` block — the wiring is automatic, driven by which fields the underlying models declare as parameters. ## Where cross-filtering works - **In dashboards.** Any chart click anywhere inside a dashboard adds (or removes) a pill. - **Not on the standalone visualization page.** Clicking a chart that is open as its own page does not produce a pill. If you want cross-filter behavior, the user must be looking at the chart through a dashboard. ## Which viz types emit clicks - **bar** — clicking a bar emits the x-axis category. - **line** — clicking a point emits the x value (single / dual-axis) or the series name (multi-series). - **pie** — clicking a slice emits the slice label. - **scatter** — clicking a point emits its label / series / x value. - **funnel** — clicking a stage emits the stage label. - **heatmap** — emits only when the viz has `chart.cross_filter_emit` set to `"x"` or `"y"`. - **grid** — clicking a cell in a column configured as clickable emits the column name and the cell value. - **report_matrix** — clicking a body cell emits the column name and the cell value, same shape as grid. Totals rows, trend cells, and section headers are not clickable; document-mode rendering suppresses all click wiring. - **kpi** — does not emit clicks (no categorical click target by structure), but consumes pills: its query re-runs with the active params just like every other viz. ## Pills Pills are the visual representation of active cross-filters at the top of a dashboard. The lifecycle: 1. User clicks a chart — the field / value pair becomes a pill. 2. Pills are additive — clicking on a second chart adds another pill, and both apply to every viz on the next run. 3. Clicking a pill's remove button drops that filter and re-runs the dashboard without it. 4. Pills last for the dashboard session — reloading the page clears them. A click is only kept as a pill if the clicked field is also a parameter declared by at least one model on the dashboard. Clicks on fields no model accepts are silently ignored — there is no error, just no pill. To make a column cross-filterable, declare a parameter for it in the model that powers the affected charts (see [Malloy support](/docs/build-workflow/malloy-support)). ## How cross-filter values reach the underlying queries Active pill values are merged with any dashboard-level filter values and passed as parameters to every viz's query on the next run. From the model's point of view, a cross-filter pill is indistinguishable from a value the user typed in a filter control — the same parameter binding applies. Pills **override** declared filter values when both set the same parameter. So a click on a chart that emits `{country: "MX"}` wins over the dashboard's "country" select that was set to `"all"`. Removing the pill restores the declared value. Practical consequence: every model used by a dashboard should declare a Malloy parameter for any field you want users to be able to cross-filter on. Use a sensible default (typically a sentinel like `"all"`, an empty string, or the broadest valid value) so the query still runs when the parameter is unset. ``` # in a Malloy model ##! experimental.parameters source: orders( p_country::string is "all", p_brand::string is "all" ) is bigquery.table('...') extend { view: by_category is { where: (p_country = "all" or country = p_country) and (p_brand = "all" or brand = p_brand) group_by: category aggregate: revenue is sum(sale_price) } } ``` With the parameters above, clicking a country bar adds a `{country: "MX"}` pill, the dashboard re-runs with `p_country = "MX"`, and every chart using this view narrows accordingly. ## Per-viz opt-out Suppress click emission for a specific viz by setting the cross-filter flag to `false` in its YAML. The flag lives in the viz's primary config block — `chart` for ECharts-based vizs, the type-named block for DOM-based vizs (because they are not "charts" in the ECharts sense). - **bar, line, pie, scatter, heatmap, funnel** → `chart.cross_filter: false` - **grid** → `grid.cross_filter: false` - **report_matrix** → `matrix.cross_filter: false` - **kpi** → no flag; KPI does not emit by structure (no categorical click target). Use opt-out for "headline" charts that should always show the unfiltered total — a top-line revenue chart that should not change when the user clicks elsewhere, for instance. ## Adapter differences Cross-filter routing itself is the same on all three adapters. Pill values become query parameters; from that point the same adapter caveats apply as for filter values — see [Source adapter differences](/docs/reference/source-adapters). Numeric and string pills are unaffected; date / timestamp pills follow the Postgres / MySQL pattern. ## Patterns #### One drill path per dashboard Decide which fields users should be able to drill on (typically the dimensions in your group-by clauses) and declare parameters for those in every model used by the dashboard. Skip parameters for fields that should not drive cross-filter — clicks on them silently fall through. #### Headline + drill grid Use a KPI at the top showing the total (KPI does not react to pills, by design). Below it, a bar chart that emits clicks. Below the bar, a grid that consumes the cross-filter and shows the underlying rows. Each click on the bar narrows the grid to the matching subset. #### Cross-filter + declared filters together Mix a date filter at the top of the dashboard (e.g. [date_range_preset](/docs/build-workflow/filters/date-range-preset)) with implicit cross-filtering on the chart layout. Both end up as parameters on the same queries. ## Common pitfalls - **Click does nothing.** The clicked field must be declared as a parameter in at least one model on the dashboard. If no model declares it, the click is silently ignored. - **Pills disappear after reload.** Pills do not persist across page reloads. If you need shareable filter state, use a declared filter ([Filters](/docs/build-workflow/filters)) — those are reflected in the URL. - **One viz reacts to the pill, another does not.** Both viz must use models that declare the same parameter. Audit each viz's underlying model. - **The "headline" KPI changes when it should not.** KPI reacts to pills like every other viz. If you want a KPI that ignores them, write the underlying model so the parameter doesn't influence the where-clause; or use a non-KPI viz with `chart.cross_filter: false` as the headline. - **Clicking a kpi does not add a pill.** KPI is a consumer-only viz (no categorical click target by structure). Its query re-runs with active params but it cannot emit. report_matrix and grid both emit on body-cell clicks. - **Funnel clicks ARE pills.** Funnel emits like bar / line / pie / scatter. If you want a funnel that does not emit, set `chart.cross_filter: false` on it. - **Two pills should be combined as OR but combine as AND.** The current behavior is AND across all pills. To support OR semantics, encode it in the underlying model parameter (e.g. accept a list, default to `"all"`). ---PAGE--- --- title: Dashboards slug: docs/build-workflow/dashboards language: en description: Compose multiple visualizations into guided exploration or reporting views. last_modified: "2026-06-11T14:15:57.128000+00:00" docs_section: build-workflow docs_summary: Assemble views for exploration, guided analysis and reporting without breaking the underlying logic contract. --- ## One dashboard, one question Most BI tools push you toward one giant dashboard that tries to answer everything at once. The result is a screen full of charts that no one reads. Looky is built around the opposite principle: one focused dashboard per business question. A dashboard that answers "Where is revenue leaking this quarter?" is useful. A dashboard that shows all metrics for all dimensions for all time periods is a loading screen with charts. Build small, sharp, and publishable. Two layout modes support different use cases. `layout_mode` is required; the only allowed values are: - **`fluid_grid`**: interactive, responsive canvas. Charts sit side by side and cross-filter each other when users click. Best for exploration and operational monitoring. - **`document`**: fixed top-to-bottom reading order. Designed to be read, shared, and exported to PDF. Best for reports, briefings, and scheduled deliveries. Any other value is rejected by the validator. ## Fluid grid dashboard with layout control Use `width` on items to control how many grid columns each visualization occupies. The default grid is 3 columns. Width 1 = one column, width 2 = two columns, width 3 = full width. Optional advanced spans: width 4 = quarter row, width 6 = half row. ``` {% raw %}id: ec_sales_breakdown title: Where Is Revenue Coming From? layout_mode: fluid_grid published: true filters: - id: global_period type: cutoff_date granularity: year label: Period default: "{{today}}" items: - visualization: ec_revenue_kpi - visualization: ec_orders_kpi - visualization: ec_revenue_by_category_bar - visualization: ec_revenue_by_country_bar - visualization: ec_top_brands_bar - visualization: ec_traffic_source_bar width: 2 - visualization: ec_revenue_by_department_bar width: 1 - visualization: ec_orders_detail_grid{% endraw %} ``` ## Document dashboard for reports and exports Document mode renders in a fixed single-column layout. It is the right choice when the dashboard will be shared via PDF export or when the reading order carries meaning. ``` {% raw %}id: ec_business_overview title: What Is Happening In The Business? layout_mode: document published: true filters: - id: global_period type: cutoff_date granularity: year label: Period default: "{{today}}" items: - visualization: ec_revenue_kpi - visualization: ec_orders_kpi - visualization: ec_aov_kpi - visualization: ec_revenue_over_time_line - visualization: ec_category_brands_matrix - visualization: ec_orders_by_status_bar{% endraw %} ``` Document dashboards can be exported to PDF on a schedule. See [Scheduled Exports](/docs/build-workflow/exports). ## What is NOT a dashboard-YAML field Cross-filter relationships are **not** declared at the dashboard level. There is no `cross_filter` block on the dashboard. Cross-filter is a per-viz toggle and an automatic dashboard-wide runtime behavior — see [Cross-filtering](/docs/build-workflow/cross-filtering). Similarly, the `items[]` entry shape is small: each item is either `{visualization: }` or the same plus an optional `width:`. There is no `height`, `position`, `row`, or `col`; the fluid grid lays out items in declaration order and the document layout is single-column. ## Pagination inside a dashboard Grid and report_matrix items inside a dashboard paginate server-side; changing pages re-runs the underlying query with the new page parameters. The behavior is identical across BigQuery, Postgres, and MySQL sources. Cost characteristics differ — see [Source adapter differences](/docs/reference/source-adapters). ## Filter types Filters declared on a dashboard apply to every viz the dashboard contains. The full per-type reference lives at [Filters](/docs/build-workflow/filters); the two most common shapes for dashboards are shown below. ### cutoff_date — time-period selection The most common dashboard filter. The user picks a single date and Looky sends two parameters to every query: `date_from` and `date_to`. The lower bound depends on `granularity` — month (default) sets `date_from` to the first day of the chosen date's month; year sets `date_from` to January 1 of that year. ``` {% raw %}filters: - id: global_period type: cutoff_date granularity: month # month (default) or year label: Period default: "{{today}}"{% endraw %} ``` Supported `granularity` values for dashboard-level cutoff_date filters: - `month` (default): `date_from` = first day of the chosen date's month, `date_to` = the chosen date. Useful for month-to-date dashboards. - `year`: `date_from` = January 1 of the chosen date's year, `date_to` = the chosen date. Useful for year-to-date dashboards. The `granularity` key is dashboard-level only; per-visualization `cutoff_date` filters always behave as `month`. Default tokens: `{{ '{{today}}' }}`, `{{ '{{yesterday}}' }}`, `{{ '{{start_of_week}}' }}`, `{{ '{{end_of_week}}' }}`, `{{ '{{start_of_month}}' }}`, `{{ '{{end_of_month}}' }}`. Anything else is treated as a literal ISO date string. ### select — dimension filter from a Malloy query Populates a dropdown from a live query. Use when users need to filter by a dimension value (region, category, brand) rather than time. ``` filters: - id: category type: select label: Category param: category options_query: "models/ec_revenue.malloy::category_options" default: all ``` `options_query` points to a Malloy query that returns rows with at least `id` and `label` columns (or one of the recognised aliases — see [select](/docs/build-workflow/filters/select)). The model parameter declared as `p_category` receives the value (the `p_` prefix is stripped for the external name; the filter sends `category`). ## Cross-filtering in fluid grid In `fluid_grid` dashboards, clicking a bar, point, or cell in any interactive chart filters all other charts in the dashboard to that selection. This works automatically — no configuration required. To disable cross-filtering for a specific visualization (for example a summary KPI that should not change when other charts are filtered), set `cross_filter: false` in the visualization's `chart` block: ``` # in the visualization YAML, not the dashboard chart: cross_filter: false ``` Cross-filtering is not available in document mode. Document dashboards are designed for reading, not exploration. The full mechanism — pills, supported-params whitelist, per-viz emit/consume rules — lives at [Cross-filtering](/docs/build-workflow/cross-filtering). ## Composition checklist - Every `items[].visualization` references an existing published visualization id. - `layout_mode` is chosen deliberately: `fluid_grid` for exploration, `document` for reports. - Dashboard title is a question, not a category label. - Filter `param` matches the parameter name declared in the Malloy model. - `granularity` matches the time resolution of the underlying data. ``` looky validate looky list dashboards ``` ---PAGE--- --- title: Scheduled Exports slug: docs/build-workflow/exports language: en description: Schedule PDF exports of document-mode dashboards to run automatically on a cron schedule. last_modified: "2026-06-11T14:15:58.672000+00:00" docs_section: build-workflow docs_summary: Define export jobs in content/exports/ to produce scheduled PDFs from document dashboards. --- ## Document mode and exports are the same idea A dashboard in `layout_mode: document` is designed to be read top to bottom, like a report. It renders in a fixed layout that maps cleanly to a printed or PDF page. Exports take that layout and deliver it on a schedule — without anyone having to open the UI. The combination of document dashboards and scheduled exports replaces the manual "screenshot and paste into slide" workflow. You define the report once and it lands wherever it needs to go, with fresh data, on time. ## Where export definitions live Exports are YAML files inside `content/exports/` in your workspace: ``` / content/ exports/ sales_daily_pdf.yml fulfillment_weekly_pdf.yml ``` Each file defines one export job: which dashboard, which schedule, which parameters, and where to deliver the output. ## Working example ``` {% raw %}id: ec_business_daily_pdf enabled: true dashboard_id: ec_business_overview schedule: cron: "0 8 * * *" timezone: "America/New_York" params: cutoff_date: "{{today}}" destination: type: local_folder folder: "." filename: "business-overview-{{today}}.pdf" policy: on_missed_run: skip max_retries: 2 timeout_seconds: 180{% endraw %} ``` Field by field: - `id`: unique identifier for this export job. - `enabled`: set to `false` to pause without deleting. - `dashboard_id`: must match the `id` of a published dashboard. Document layout mode is required for PDF exports. - `schedule.cron`: standard five-field cron expression. `0 8 * * *` runs every day at 08:00. - `schedule.timezone`: IANA timezone name. The cron expression is evaluated in this timezone. - `params`: dashboard filter values injected at render time. These match the `param` field defined in the dashboard's `filters` block. - `destination.type`: `local_folder` saves the PDF to the specified folder on the export engine host. - `destination.filename`: supports template variables. `{{ '{{today}}' }}` expands to the current date in ISO format. - `policy.on_missed_run`: `skip` skips missed runs silently. Use `backfill` if you need missed runs to execute on recovery. - `policy.timeout_seconds`: if the PDF render takes longer than this, the job is marked as failed. ## Send the report to subscribers An export can deliver the finished PDF straight to people — by email or WhatsApp — without anyone opening Looky. Add a `subscribers` block and list each recipient under the channel you want to reach them on: ``` subscribers: email: - finance@acme.com - ops@acme.com whatsapp: - "+15551234567" ``` - `email`: email addresses that receive the PDF as an attachment. - `whatsapp`: phone numbers in `+` form that receive the PDF on WhatsApp. You choose the channel explicitly — put an email under `email` and a phone number under `whatsapp`. Recipients don't need a Looky account, so a scheduled report is a simple way to keep stakeholders in the loop. Both lists are optional; an export with no `subscribers` just produces the PDF without sending it anywhere. ## Template variables Both `params` values and `destination.filename` support these template variables: - `{{ '{{today}}' }}` — today's date in the export timezone (ISO format: YYYY-MM-DD). - `{{ '{{yesterday}}' }}` — yesterday's date in the export timezone. This keeps export filenames and dashboard filter values in sync without manual configuration: ``` {% raw %}params: cutoff_date: "{{today}}" destination: filename: "sales-report-{{today}}.pdf"{% endraw %} ``` ## Cron schedule examples - `0 8 * * *` — every day at 08:00 - `0 8 * * 1` — every Monday at 08:00 - `0 8 1 * *` — first day of every month at 08:00 - `0 6,18 * * *` — twice daily at 06:00 and 18:00 Always set a `timezone`. A cron without a timezone runs in UTC, which will produce off-by-one dates when using `{{ '{{today}}' }}` if your users are in a different timezone. ## Push and verify exports Export definitions are pushed with the rest of workspace content: ``` looky validate looky diff looky push ``` After push, the export engine picks up the new job on its next scheduling cycle. To verify the job is registered, check the workspace status in the UI or run `looky list exports`. ---PAGE--- --- title: Publish slug: docs/build-workflow/publish language: en description: Move workspace changes into the running environment and validate the effective result there. last_modified: "2026-06-11T14:16:12.089000+00:00" docs_section: build-workflow docs_summary: Finish with a repeatable publish path and a runtime validation step, not a filesystem assumption. --- ## The publish lifecycle Publishing a workspace is a four-step rhythm — read the local state, see what differs, validate, push. Run all of these from the workspace root (or any subdirectory inside it): ``` looky status # confirm root, instance, billing, workspace, sync state looky diff # show local-vs-server file differences looky validate # run the validation gate looky push # publish content (only after validate is clean) ``` `push` runs the same validation gate as `validate` and refuses to publish if anything fails — `validate` is the way to run that gate without publishing. ## Two push scopes — content vs settings `looky push` targets one of two distinct surfaces, never both at once: - **Default (content push)** — publishes `content/**` + `workspace.yml`. Hard-gated by validation. Fails if your runtime configuration has not been deployed yet. - **`--settings` (config push)** — publishes `runtime/**` + `secrets/**`. Validates the source declarations, then writes. Use it on first setup, after rotating credentials, or whenever you change source declarations. Typical first-time order: `looky push --settings` first to deploy sources + secrets, then `looky push` for content. ## What validation checks When you run `looky validate` or `looky push`: - **Local pass.** YAML parses, files referenced exist, IDs are unique, `query` uses the `::` separator, dashboards reference real visualizations, exports reference real dashboards, cron expressions are valid, the `chart` block on every visualization passes its typed schema. - **Server pass.** Source aliases are reachable, every Malloy model compiles, every published visualization dry-runs cleanly against the configured data sources. If any check fails, the command exits non-zero and prints the error code, the file path, and a human-readable message. Warnings do **not** block — they appear in the output and let the push proceed. ## Validation flags - `--strict` — adds per-visualization live-source validation. On Postgres, runs `EXPLAIN` against the live database (one round-trip per viz, with a 10-second statement timeout). On BigQuery, runs `estimateQueryCost` (free, no scanned bytes). Catches dialect errors and missing permissions that the default fast pass cannot. Use it before a release; skip it for quick iteration. To validate without publishing, use `looky validate`. On push, the client is the source of truth for its content scope — files present on the server but absent locally are deleted as part of the push. ## Error code families Validation errors and warnings are tagged with a stable code prefix. The prefix tells you which gate fired and which file family it concerns. - **`WS`***** — workspace.yml issues (missing fields, invalid identifier shape, unknown billing account). - **`RT`***** — runtime / source declarations (missing or unsupported source type, missing `credentials_file`, missing `dsn` for postgres, credentials file not found in `secrets/`). Mostly warnings. - **`MD`***** — model file issues (missing source alias reference, file outside content/, unreadable file). - **`VZ`***** — visualization YAML structure (missing id/title/type/query, malformed query reference, duplicate id). - **`VZ020`, `VZ021`** — visualization typed-schema violations: a `chart.*` property that is not in the schema, a wrong type, an out-of-range value. - **`DB`***** — dashboard YAML issues (missing fields, layout_mode not in `fluid_grid` / `document`, references to unknown visualizations). - **`EX`***** — export YAML issues (cron format, unknown dashboard reference). - **`MR`***** — runtime errors reported by the server. `MR000` load failure, `MR001` Malloy compile / parse error, `MR002` field not defined, `MR003` unknown source alias, `MR004` schema introspection failed, `MR005` query engine unreachable, `MR006` query engine timed out, `MR008` model file missing, `MR009` precheck catch-all, `MR010` source unreachable from probe. ## What the push uploads A successful content push publishes the validated file set and reports counts of created / updated / unchanged / deleted files. Disaster recovery is your responsibility — keep the workspace in git, or rely on the local `.bk//` snapshot that `looky pull` writes when it overwrites local files. A successful settings push publishes `runtime/sources.runtime.yml` and the files in `secrets/` referenced by it. Validation runs first; if it fails the push prints the issues and nothing is written. ## Post-publish verification 1. Run `looky list visualizations` and `looky list dashboards` — confirm what is actually published on the server. 2. Open `https://my.looky.studio` and confirm dashboards render. 3. Open at least one visualization detail page and confirm data shows up. Validation does not check that `mapping` field names match the query result; that only surfaces when the chart actually renders. 4. If a dashboard renders blank or a chart shows "no rows", check the underlying model query in the visualization detail page — that's the most direct trace. A successful push only means your files were accepted and published. It does not mean every chart renders correctly. Always confirm visually after every push. ## Failure recovery playbook - **Validation failed locally (WS / RT / MD / VZ / DB / EX).** Read the error code and file path; fix the YAML; re-run `looky validate`. - **Validation failed on the server pass (MR* or VZ020).** The error mentions the model or visualization that triggered it. Open that file, fix the Malloy or chart spec, re-run `looky validate`. If the error is `MR010` (source unreachable), confirm that `looky push --settings` ran successfully recently with current credentials. When the server reports any error, nothing in the workspace is changed. - **Push went through but the dashboard is wrong on the live site.** The files published correctly but a chart is failing to render. Check mapping field names, format keys, and the actual query output in the viz detail. - **Need to undo a push.** Restore the prior content from git or from the local `.bk//` directory `looky pull` wrote, then `looky push` again. ---PAGE--- --- title: Reference slug: docs/reference language: en description: Focused reference material for recurring implementation or debugging questions. last_modified: "2026-06-11T14:16:44.486000+00:00" docs_section: reference docs_summary: Use this section when you already know the topic and need quick operational guidance. --- ## CLI quick reference Use this page as your operational cheatsheet. ``` # Auth and root linkage (anywhere under a linked root) looky login https://my.looky.studio looky whoami looky logout # Discovery and billing (run from itself; the CLI rejects subdirs) looky billing list looky billing use looky workspaces # Create/pull/delete a workspace (run from /) looky pull looky create --name "My Workspace" --description "..." looky delete [--yes] # Build/publish loop (run from anywhere inside the workspace tree) looky status looky validate looky diff looky push # Runtime source checks (anywhere inside the workspace tree) looky sources diff looky sources list # Published content checks (anywhere inside the workspace tree) looky list visualizations looky list dashboards ``` ## Command usage by phase ### Day-zero onboarding `looky login` → `looky whoami` → `looky billing list/use` ### Start workspace work `looky pull` or `looky create` → `looky status` → `looky validate` ### Publish safely `looky diff` → `looky push` → `looky list dashboards/visualizations` ### Investigate source mismatch `looky sources diff` and `looky sources list` ## Placeholder conventions used in docs - ``: local root linked during `looky login`. - ``: active billing account selected with `looky billing use`. - ``: one workspace folder under the billing account. ## Troubleshooting entrypoint For symptom-driven fixes, continue with [Troubleshooting]({{ elemental_url_for_slug(slug='docs/reference/troubleshooting') }}). ---PAGE--- --- title: Troubleshooting slug: docs/reference/troubleshooting language: en description: Quick checks for common runtime, publication and navigation problems in the docs or builder flow. last_modified: "2026-06-11T14:16:49.280000+00:00" docs_section: reference docs_summary: Use these checks when the runtime result does not match the authored intent. --- ## Root, billing, or workspace context errors ### Symptoms CLI reports invalid root, missing workspace, or path mismatch. ### Fix sequence ``` looky login https://my.looky.studio looky whoami cd looky billing list looky billing use cd // looky status ``` Run billing commands from `` and workspace commands from workspace root. ## Validation fails before push ### Symptoms `looky validate` reports model, visualization, or dashboard issues. ### Fix order 1. Fix source alias/runtime config issues first. 2. Fix model query definitions and names. 3. Fix visualization `query` and `mapping` references. 4. Fix dashboard item references and filters. 5. Re-run `looky validate` until no blocking errors remain. ## Push succeeded but dashboard is wrong or missing ### Symptoms Dashboard not visible, empty, or inconsistent with expected output in `my.looky.studio`. ### Fix sequence ``` looky status looky diff looky push looky list visualizations looky list dashboards ``` Then reload UI. If dashboard still fails, confirm visualization ids referenced in dashboard YAML exactly match published visualization ids. ## Source/runtime mismatch ### Symptoms Models cannot resolve tables or data source behavior differs unexpectedly. ### Fix sequence ``` looky sources diff looky sources list looky validate ``` Ensure aliases used in models exactly match aliases defined in `runtime/sources.runtime.yml`. Do not patch source failures per model. Correct the runtime source config once, then keep model logic focused on analytics semantics. ## Recovering from a bad push Disaster recovery is the caller's responsibility. The cleanest forward path is to fix the offending file locally and push again. To revert to a prior state, you need that prior state on your laptop — keep the workspace in git, or rely on the local `.bk//` snapshot that `looky pull` writes inside your local workspace whenever it overwrites a file. The local `.bk/` is plain files sitting next to your workspace — copy them back into place and run `looky push` to restore that state on the server. ## Reading validation error codes Every validation error or warning carries a stable code prefix. The prefix tells you which gate fired and which file family it concerns: - **`WS`***** — issues in `workspace.yml` (missing fields, identifier shape, billing-account reference). - **`RT`***** — issues in `runtime/sources.runtime.yml` (source type missing or unsupported, missing `credentials_file`, missing `dsn` for postgres/mysql, credentials file not found on disk under `secrets/`). Often warnings rather than blocking errors. - **`MD`***** — issues in model files (unknown source alias, model file outside `content/`, file unreadable). - **`VZ`***** — visualization YAML structural issues (missing required fields, malformed query reference, duplicate id). `VZ020` and `VZ021` specifically flag `chart.*` properties that violate the typed schema for the viz type. - **`DB`***** — dashboard YAML issues (`layout_mode` outside `fluid_grid` / `document`, references to unknown visualizations, duplicate ids). - **`EX`***** — export YAML issues (cron format, unknown dashboard reference, duplicate ids). - **`MR`***** — errors raised by the server when running validation against the live runtime: - `MR000` — failed to load visualizations / dashboards / sources. - `MR001` — Malloy compile / parse / syntax error, or any other failure surfaced by the query engine. The line that follows the code starts with `Malloy service HTTP :` and carries the engine's specific error. See "Engine error messages under MR001" below for the common ones. - `MR002` — field or query name referenced in a query is not defined in the Malloy source. - `MR003` — model references an unknown source alias. - `MR004` — schema introspection failed (cannot read schema for relation). - `MR005` — query engine unreachable. - `MR006` — query engine timed out — split the workspace if it is unusually large. - `MR008` — model file missing or outside `content/`. - `MR009` — generic precheck failure. - `MR010` — source unreachable from the connectivity probe (check `looky push --settings` ran with current credentials). Local codes (WS / RT / MD / VZ / DB / EX) come from the local check the CLI runs before contacting the server. Server codes (MR* and VZ020 / VZ021 emitted by the server) come from the validation run against the live runtime. See [Publish](/docs/build-workflow/publish) for the full flow. ## Engine error messages under MR001 `MR001` reports anything the query-engine refuses, prefixed with `Malloy service HTTP :`. The trailing message identifies the specific cause and points at the fix: - **`HTTP 400 — unsupported_model_shape`** — the model is missing the strict shape every Looky model needs: the `##! experimental.parameters` pragma at the top of the file, plus parentheses on the source declaration (`source: name() is …`, even when the source takes no parameters). Add both and re-validate. See [Models](/docs/build-workflow/models). - **`HTTP 400 — unbound_param`** — the model uses a raw-SQL `@param` placeholder that has no matching declaration on the source signature. The error names the missing parameter; declare it inside the source parentheses, e.g. `p_date_from::date is null`. Common types: `date`, `string`, `number`, `boolean`. - **`HTTP 400 — missing_sources_config`** — this workspace has no published settings yet. Run `looky push --settings` once and validate again. See [Publish](/docs/build-workflow/publish). - **`HTTP 400 — malformed_request`** — the CLI sent an invalid request body to the server. This is a CLI bug, not a model issue. Retry; if it persists, share the request id with your administrator. - **`HTTP 500`** — an unexpected failure on the platform or your data source (BigQuery / Postgres / MySQL errors, timeouts). Check the request id, retry, and escalate if it persists. ---PAGE--- --- title: Source adapter differences slug: docs/reference/bigquery-vs-postgres language: en description: "Adapter divergence reference: every place BigQuery, Postgres, and MySQL behave differently in Looky — sources, parameter binding, NULL casting, dryRun, introspection, and connection caching." last_modified: "2026-06-11T14:16:46.061000+00:00" docs_section: reference docs_summary: Adapter divergences across sources, parameters, dialect, and pagination. --- ## Why this page exists Looky supports three source adapters: BigQuery, Postgres, and MySQL. Most of the platform behaves identically across all three. A handful of areas behave differently — those are the ones model and dashboard authors trip on. This page lists each one in observable terms (what you see, not how it is implemented). ## Source declaration #### BigQuery - Required: `type: bigquery`, `project_id` (the GCP project paying for queries), `credentials_file` (plain filename of a service-account JSON inside `secrets/` — no path). - Optional: `location` for multi-region datasets, `datasets` (required at the time of schema introspection). #### Postgres - Required: `type: postgres`, `dsn` (libpq URI without `user:password@`; carries host, port, database, and any libpq query-string flag like `sslmode=require`, `application_name`, `connect_timeout`, …), `credentials_file` (filename of a JSON inside `secrets/` declaring `{"user", "password"}`). - Optional: `name` (cosmetic label), `schemas` (limits introspection scope; default is all non-system schemas). #### MySQL - Required: `type: mysql`, `dsn` (`mysql://host:port/database` URI without `user:password@` **and without any `?query` parameters** — the port defaults to `3306`), `credentials_file` (filename of a JSON inside `secrets/` declaring `{"user", "password"}`). - Optional: `name` (cosmetic label), `schemas` (databases to introspect; default is the database named in the `dsn`). - Note: MySQL connections are **not encrypted** — there is no TLS option yet, so use MySQL over a trusted network. See [Sources](/docs/build-workflow/sources) for working examples per adapter. ## Schema introspection - **BigQuery** — requires an explicit dataset list. Without it, introspection cannot find tables. - **Postgres** — discovers tables across the schemas in your `schemas` list (or the smart default of every non-system schema). No table-list declaration is needed. - **MySQL** — discovers tables in the database named in the `dsn` (or the databases in your `schemas` list). System databases (`mysql`, `performance_schema`, `sys`, `information_schema`) are always excluded. No table-list declaration is needed. The error messages on introspection failures look completely different per adapter — when in doubt, run a small test query first to confirm Looky can reach the source at all. ## Parameter binding #### String, numeric, and array parameters Behave identically on all three adapters. No special handling needed. #### Date and timestamp parameters - **BigQuery** — bind natively, no extra work. - **Postgres and MySQL** — native binding fails with an explicit error in some query shapes. Add an `@param` placeholder in the underlying SQL of that source so Looky substitutes the value into the SQL string. See [Malloy support](/docs/build-workflow/malloy-support) for a worked example. Filter types that produce date / timestamp parameters: [cutoff_date](/docs/build-workflow/filters/cutoff-date), [date_range](/docs/build-workflow/filters/date-range), [date_range_preset](/docs/build-workflow/filters/date-range-preset). Plan for the `@param` pattern when those filters drive a Postgres- or MySQL-backed model. #### NULL parameter values Each adapter writes a typed null differently: BigQuery and MySQL require an explicit cast, Postgres uses a bare typed null. Looky handles this for you — there is nothing to configure — but it is the reason a malformed nullable parameter can fail on one adapter and silently work on another. #### Boolean values (MySQL only) MySQL has no real boolean type — boolean-looking columns come back as numbers (`0`/`1`). To filter on a true/false condition, add an explicit `cast(… as boolean)` in the model. BigQuery and Postgres have native booleans and need no such cast. ## Query execution - **BigQuery** — exposes a free pre-flight cost estimate (in scanned bytes) before the actual run. Useful for planning around scan-heavy queries. - **Postgres and MySQL** — pre-flight validates the SQL plan (via `EXPLAIN`) but does not estimate cost. ## Filters and cross-filtering The filter resolver and the cross-filter pill mechanism are adapter-agnostic. Differences only show up at the parameter-binding step described above. - A [select](/docs/build-workflow/filters/select) filter behaves identically on all three. - The date filters land on the temporal-parameter caveat for Postgres and MySQL — fix on the model side with `@param`. - Pill values from [Cross-filtering](/docs/build-workflow/cross-filtering) are typically strings or short identifiers, so they are unaffected. ## Pagination (grid & report_matrix) Server-side pagination uses the same protocol on all three adapters. Cost characteristics differ: - **BigQuery** — every uncached page is a billed scan. Larger page size + caching is cheaper overall. Avoid unbounded grids on un-cached queries. - **Postgres and MySQL** — connection roundtrip latency dominates; cost depends mostly on indexed-scan performance of the underlying tables. Make sure the columns referenced in `order by` have appropriate indexes. ## Cache TTL recommendations Cache logic itself is the same on all three adapters. The TTL choice is editorial: - **BigQuery** — favor longer TTLs for queries that scan large tables. A 30-minute TTL on a daily-batch dataset is overkill; a 24-hour TTL is usually fine. - **Postgres and MySQL** — TTL is mostly about freshness rather than cost. Caching helps less if the underlying table is small and well-indexed; consider whether the cost saved outweighs the staleness introduced. ## Aggregations and dialect features Looky does not branch on adapter for aggregate or windowing functions. Anything dialect-specific (BigQuery-only ARRAY functions, Postgres-only `date_trunc` on certain types, MySQL-only date functions, etc.) surfaces as a Malloy compilation error. The fix is on the Malloy side — adjust the model to use a feature every target adapter supports, or guard the model against the wrong adapter. ## Worked migration patterns #### A date filter that works on BigQuery but breaks on Postgres or MySQL The native-binding model: ``` # works on BigQuery, fragile on Postgres / MySQL in some shapes ##! experimental.parameters source: orders( p_date_from::date is @2024-01-01, p_date_to::date is @2024-12-31 ) is bigquery.table('...') extend { view: revenue is { where: created_at::date >= p_date_from and created_at::date <= p_date_to aggregate: revenue is sum(sale_price) } } ``` Switch the source to `sql()` with placeholders for Postgres / MySQL compatibility (this example uses Postgres syntax; on MySQL use the equivalent `mysql.sql("""…""")` with MySQL-dialect casts): ``` # works on Postgres (and BigQuery) ##! experimental.parameters source: orders( p_date_from::date is null, p_date_to::date is null ) is postgres.sql(""" select * from orders where (@date_from::date is null or created_at::date >= @date_from::date) and (@date_to::date is null or created_at::date <= @date_to::date) """) extend { view: revenue is { aggregate: revenue is sum(sale_price) } } ``` Each `@param` placeholder must have a matching declaration on the source signature; Looky substitutes the value (or typed `NULL` when unset) before compile. #### A grid that paginates well on Postgres / MySQL but blows BigQuery scan budget Increase `pagination.page_size`, add a cache sidecar with a long TTL, and pre-aggregate when possible. On BigQuery, paging through millions of rows uncached is expensive — pre-summarise. ## Quick reference - **Source auth** — BigQuery: service-account file. Postgres / MySQL: connection string + a `{"user","password"}` secret. - **Transport** — BigQuery / Postgres: TLS available. MySQL: not encrypted yet. - **Introspection** — BigQuery: explicit datasets required. Postgres: smart-default schemas. MySQL: the DSN's database (or a `schemas` list). - **Numeric / string parameters** — identical. - **Date / timestamp parameters** — BigQuery: native. Postgres / MySQL: need `@param` placeholder in SQL. - **Booleans** — native on BigQuery / Postgres. MySQL: numeric — cast explicitly. - **NULL parameters** — handled automatically; no configuration. - **Pre-flight** — BigQuery: free cost estimate. Postgres / MySQL: SQL plan check. - **Filters & cross-filter routing** — identical. - **Pagination protocol** — identical; cost characteristics differ. - **Aggregations / dialect features** — delegated entirely to Malloy; no Looky-side branches. ---PAGE--- --- title: Source adapter differences slug: docs/reference/source-adapters language: en description: "Adapter divergence reference: every place BigQuery, Postgres, and MySQL behave differently in Looky — sources, parameter binding, NULL casting, dryRun, introspection, and connection caching." last_modified: "2026-06-11T14:16:47.620000+00:00" docs_section: reference docs_summary: Adapter divergences across sources, parameters, dialect, and pagination. --- ## Why this page exists Looky supports three source adapters: BigQuery, Postgres, and MySQL. Most of the platform behaves identically across all three. A handful of areas behave differently — those are the ones model and dashboard authors trip on. This page lists each one in observable terms (what you see, not how it is implemented). ## Source declaration #### BigQuery - Required: `type: bigquery`, `project_id` (the GCP project paying for queries), `credentials_file` (plain filename of a service-account JSON inside `secrets/` — no path). - Optional: `location` for multi-region datasets, `datasets` (required at the time of schema introspection). #### Postgres - Required: `type: postgres`, `dsn` (libpq URI without `user:password@`; carries host, port, database, and any libpq query-string flag like `sslmode=require`, `application_name`, `connect_timeout`, …), `credentials_file` (filename of a JSON inside `secrets/` declaring `{"user", "password"}`). - Optional: `name` (cosmetic label), `schemas` (limits introspection scope; default is all non-system schemas). #### MySQL - Required: `type: mysql`, `dsn` (`mysql://host:port/database` URI without `user:password@` **and without any `?query` parameters** — the port defaults to `3306`), `credentials_file` (filename of a JSON inside `secrets/` declaring `{"user", "password"}`). - Optional: `name` (cosmetic label), `schemas` (databases to introspect; default is the database named in the `dsn`). - Note: MySQL connections are **not encrypted** — there is no TLS option yet, so use MySQL over a trusted network. See [Sources](/docs/build-workflow/sources) for working examples per adapter. ## Schema introspection - **BigQuery** — requires an explicit dataset list. Without it, introspection cannot find tables. - **Postgres** — discovers tables across the schemas in your `schemas` list (or the smart default of every non-system schema). No table-list declaration is needed. - **MySQL** — discovers tables in the database named in the `dsn` (or the databases in your `schemas` list). System databases (`mysql`, `performance_schema`, `sys`, `information_schema`) are always excluded. No table-list declaration is needed. The error messages on introspection failures look completely different per adapter — when in doubt, run a small test query first to confirm Looky can reach the source at all. ## Parameter binding #### String, numeric, and array parameters Behave identically on all three adapters. No special handling needed. #### Date and timestamp parameters - **BigQuery** — bind natively, no extra work. - **Postgres and MySQL** — native binding fails with an explicit error in some query shapes. Add an `@param` placeholder in the underlying SQL of that source so Looky substitutes the value into the SQL string. See [Malloy support](/docs/build-workflow/malloy-support) for a worked example. Filter types that produce date / timestamp parameters: [cutoff_date](/docs/build-workflow/filters/cutoff-date), [date_range](/docs/build-workflow/filters/date-range), [date_range_preset](/docs/build-workflow/filters/date-range-preset). Plan for the `@param` pattern when those filters drive a Postgres- or MySQL-backed model. #### NULL parameter values Each adapter writes a typed null differently: BigQuery and MySQL require an explicit cast, Postgres uses a bare typed null. Looky handles this for you — there is nothing to configure — but it is the reason a malformed nullable parameter can fail on one adapter and silently work on another. #### Boolean values (MySQL only) MySQL has no real boolean type — boolean-looking columns come back as numbers (`0`/`1`). To filter on a true/false condition, add an explicit `cast(… as boolean)` in the model. BigQuery and Postgres have native booleans and need no such cast. ## Query execution - **BigQuery** — exposes a free pre-flight cost estimate (in scanned bytes) before the actual run. Useful for planning around scan-heavy queries. - **Postgres and MySQL** — pre-flight validates the SQL plan (via `EXPLAIN`) but does not estimate cost. ## Filters and cross-filtering The filter resolver and the cross-filter pill mechanism are adapter-agnostic. Differences only show up at the parameter-binding step described above. - A [select](/docs/build-workflow/filters/select) filter behaves identically on all three. - The date filters land on the temporal-parameter caveat for Postgres and MySQL — fix on the model side with `@param`. - Pill values from [Cross-filtering](/docs/build-workflow/cross-filtering) are typically strings or short identifiers, so they are unaffected. ## Pagination (grid & report_matrix) Server-side pagination uses the same protocol on all three adapters. Cost characteristics differ: - **BigQuery** — every uncached page is a billed scan. Larger page size + caching is cheaper overall. Avoid unbounded grids on un-cached queries. - **Postgres and MySQL** — connection roundtrip latency dominates; cost depends mostly on indexed-scan performance of the underlying tables. Make sure the columns referenced in `order by` have appropriate indexes. ## Cache TTL recommendations Cache logic itself is the same on all three adapters. The TTL choice is editorial: - **BigQuery** — favor longer TTLs for queries that scan large tables. A 30-minute TTL on a daily-batch dataset is overkill; a 24-hour TTL is usually fine. - **Postgres and MySQL** — TTL is mostly about freshness rather than cost. Caching helps less if the underlying table is small and well-indexed; consider whether the cost saved outweighs the staleness introduced. ## Aggregations and dialect features Looky does not branch on adapter for aggregate or windowing functions. Anything dialect-specific (BigQuery-only ARRAY functions, Postgres-only `date_trunc` on certain types, MySQL-only date functions, etc.) surfaces as a Malloy compilation error. The fix is on the Malloy side — adjust the model to use a feature every target adapter supports, or guard the model against the wrong adapter. ## Worked migration patterns #### A date filter that works on BigQuery but breaks on Postgres or MySQL The native-binding model: ``` # works on BigQuery, fragile on Postgres / MySQL in some shapes ##! experimental.parameters source: orders( p_date_from::date is @2024-01-01, p_date_to::date is @2024-12-31 ) is bigquery.table('...') extend { view: revenue is { where: created_at::date >= p_date_from and created_at::date <= p_date_to aggregate: revenue is sum(sale_price) } } ``` Switch the source to `sql()` with placeholders for Postgres / MySQL compatibility (this example uses Postgres syntax; on MySQL use the equivalent `mysql.sql("""…""")` with MySQL-dialect casts): ``` # works on Postgres (and BigQuery) ##! experimental.parameters source: orders( p_date_from::date is null, p_date_to::date is null ) is postgres.sql(""" select * from orders where (@date_from::date is null or created_at::date >= @date_from::date) and (@date_to::date is null or created_at::date <= @date_to::date) """) extend { view: revenue is { aggregate: revenue is sum(sale_price) } } ``` Each `@param` placeholder must have a matching declaration on the source signature; Looky substitutes the value (or typed `NULL` when unset) before compile. #### A grid that paginates well on Postgres / MySQL but blows BigQuery scan budget Increase `pagination.page_size`, add a cache sidecar with a long TTL, and pre-aggregate when possible. On BigQuery, paging through millions of rows uncached is expensive — pre-summarise. ## Quick reference - **Source auth** — BigQuery: service-account file. Postgres / MySQL: connection string + a `{"user","password"}` secret. - **Transport** — BigQuery / Postgres: TLS available. MySQL: not encrypted yet. - **Introspection** — BigQuery: explicit datasets required. Postgres: smart-default schemas. MySQL: the DSN's database (or a `schemas` list). - **Numeric / string parameters** — identical. - **Date / timestamp parameters** — BigQuery: native. Postgres / MySQL: need `@param` placeholder in SQL. - **Booleans** — native on BigQuery / Postgres. MySQL: numeric — cast explicitly. - **NULL parameters** — handled automatically; no configuration. - **Pre-flight** — BigQuery: free cost estimate. Postgres / MySQL: SQL plan check. - **Filters & cross-filter routing** — identical. - **Pagination protocol** — identical; cost characteristics differ. - **Aggregations / dialect features** — delegated entirely to Malloy; no Looky-side branches.