Mastering nested and repeated fields in LookML
Working with nested STRUCT and repeated ARRAY data types in modern cloud data warehouses like BigQuery, Snowflake, and Databricks is standard practice for performance and storage efficiency. However, BI tools often struggle with semi-structured data, forcing engineers to build complex ETL pipelines that flatten everything into massive, slow tables.
Think of nested data like a modular bento box: instead of scattering your main course, side dish, and sauce across separate plates in different rooms, everything for a single order is packaged neatly in one self-contained box.
When a standard SQL developer sees a bento box, their first instinct is to dump all the boxes onto a conveyer belt and unroll every item flat. Before you spin up a dbt model or ETL pipeline to flatten your nested data, realize that Looker handles STRUCT and ARRAY fields natively inside LookML—preserving columnar storage efficiency while generating clean SQL on demand.
Looker out of the box
When you generate LookML from a BigQuery schema containing nested and repeated data, Looker handles the initial modeling automatically across different nesting depths:
- Single 1:1
STRUCTfields (likegeo.country) become dimensions that reference the leaf field directly using dot notation (sql: ${TABLE}.geo.country ;;). - Repeated 1:N
ARRAY<STRUCT>fields (records in arrays) trigger Looker to hide the raw array column in the parent view (hidden: yes), generate a dedicated child view for the array elements, and build aLEFT JOIN UNNEST(${parent.array})Explore join withrelationship: one_to_many. - Structs inside arrays (such as
items.item_dimensions.width) are parsed as dot-notation dimensions directly inside the unnested array view (sql: ${TABLE}.item_dimensions.width ;;). - Arrays inside arrays (such as
items.item_params) generate chained child views and cascading unnest joins in the Explore, unnesting the deeper array relative to the first-level view (LEFT JOIN UNNEST(${items.item_params})).
Here is how nested fields work in Looker, how symmetric aggregates protect you from fanout bugs, and how to model deeply nested structures cleanly.
The problem: semi-structured data meets traditional BI
Cloud data warehouses prefer nested and repeated structures because they preserve data locality and cut disk I/O. In Google BigQuery, accessing a single subfield inside a STRUCT reads only that specific leaf column—giving you maximum column-pruning performance without physical table joins.
As BigQuery performance guidelines emphasize, keeping data in ARRAY<STRUCT> format localizes child records to individual processing slots. Pre-flattening relational data forces expensive network data shuffling across slots for GROUP BY operations; keeping arrays nested allows BigQuery to execute queries in parallel at full speed without shuffle overhead.
However, repeated fields (ARRAY<STRUCT>) represent a 1:N relationship embedded directly inside a single row. If a naive SQL query un-nests an array, the parent record is multiplied for every item in that array.
+------------+--------------------+----------------+
| event_id | item_name (array) | event_revenue | <-- Unnested row explosion!
+------------+--------------------+----------------+
| evt_101 | Ergonomic Mouse | $150 |
| evt_101 | Mechanical Keyboard| $150 | <-- Double counted revenue!
+------------+--------------------+----------------+
Without special handling, any SUM(event_revenue) across this unnested dataset produces a fanout bug, double-counting revenue for multi-item events. Looker resolves this without requiring pre-flattened data tables.
LookML views are virtual wrappers, not database objects
When Looker suggests creating a new view for every nested record or repeated array, engineers coming from traditional SQL backgrounds often worry that creating dozens of views will clutter their database, slow down queries, or bloat their schema.
LookML views do not work that way.
A LookML view is not a physical database view (CREATE VIEW ...). It executes zero DDL statements, creates zero database objects, and consumes zero storage or memory in BigQuery. It is strictly a virtual metadata wrapper in LookML code mapping SQL column references to user-facing dimensions and measures.
Creating dedicated LookML views for repeated arrays provides several architectural advantages:
- Item-level measures like
total_item_revenueoraverage_unit_pricelive inside thega4_event_itemsview, preventing users from confusing item-grain metrics with event-grain metrics (total_event_revenue). - Once defined, the
ga4_event_itemsview can be joined into multiple Explores (events,user_conversions,order_fulfillment) without duplicating LookML code. - Defining a view in an Explore join costs nothing at query time. If a user runs a query selecting only top-level fields, Looker ignores the unnested view entirely and omits the
LEFT JOIN UNNEST(...)clause from the generated SQL. - You can apply custom
view_labeltitles, field-level security, or field groupings to the unnested array independently of the parent record.
Alternatives for downstream pre-flattening needs
If your architecture still requires flattened datasets or pre-aggregated tables for downstream tools, external reporting, or specialized analytics, avoid building separate, un-governed ETL pipelines. Instead, use Looker's native features so downstream systems can take full advantage of Looker's semantic modeling and security:
- Expose governed Looker Explores directly to third-party tools using Looker BI Connectors like the Looker Studio connector or Tableau connector. External applications query your LookML semantic layer directly without requiring pre-flattened tables in your warehouse.
- Define pre-joined or pre-flattened datasets inside LookML using native derived tables. Setting publish_as_db_view instructs Looker to publish the PDT as a stable, physical view in your underlying database scratch schema. External SQL scripts and ETL tools can then query
SELECT * FROM scratch_schema.pdt_viewdirectly in BigQuery while inheriting Looker's unnesting logic and datagroup build schedules. - Implement aggregate awareness roll-up tables (
aggregate_table). Aggregate awareness accelerates access to deeply nested and fanned-out data by pre-aggregating common query patterns into summary tables. Looker automatically routes downstream queries to these optimized roll-ups when available while preserving drill-down access to the underlying unnested arrays.
Public dataset blueprint: GA4 e-commerce events
This guide uses the public Google Analytics 4 dataset in BigQuery (bigquery-public-data.ga4_obfuscated_sample_ecommerce.events) to demonstrate these patterns.
This schema contains three distinct tiers of nesting:
- Single 1:1 nested structs like
geocontainingcontinent,country, andcity. - Repeated 1:N arrays of structs like
itemscontainingitem_id,item_name,price, andquantity. - Deeply nested 1:N:M arrays within arrays like
items.item_paramscontaining custom key-value pairs per item.
Single structs: instant access via dot notation
For non-repeated STRUCT fields, you do not need joins or un-nesting. Access subfields directly in LookML dimension definitions using SQL dot notation.
view: ga4_events {
sql_table_name: `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*` ;;
dimension: event_id {
primary_key: yes
type: string
sql: CONCAT(${TABLE}.event_date, '_', ${TABLE}.event_timestamp, '_', ${TABLE}.user_pseudo_id) ;;
}
dimension: event_name {
type: string
sql: ${TABLE}.event_name ;;
}
# Direct dot-notation for 1:1 STRUCT fields
dimension: country {
group_label: "Geography"
type: string
sql: ${TABLE}.geo.country ;;
}
dimension: city {
group_label: "Geography"
type: string
sql: ${TABLE}.geo.city ;;
}
}
When a user selects only country and event_name in Looker, BigQuery reads only those two specific leaf columns from disk. It never reads the rest of the geo record or any unreferenced fields.
Repeated arrays: dynamic unnesting in Explores
When dealing with repeated arrays (ARRAY<STRUCT>), create a separate LookML view for the nested entity and join it in your Explore using BigQuery's UNNEST syntax.
Create the nested item view
For symmetric aggregates to accurately compute measures on unnested arrays, every view must have a primary key. When nested array items lack a unique natural ID, use BigQuery's WITH OFFSET clause during the unnest join to expose the array row index (0, 1, 2...), then concatenate it with the parent ID to form a surrogate primary key.
view: ga4_event_items {
# No sql_table_name needed. This view represents the unnested array.
dimension: item_offset {
hidden: yes
type: number
sql: item_offset ;;
}
dimension: primary_key {
primary_key: yes
hidden: yes
type: string
sql: CONCAT(${ga4_events.event_id}, '_', CAST(${item_offset} AS STRING)) ;;
}
dimension: item_id {
type: string
sql: ${TABLE}.item_id ;;
}
dimension: item_name {
type: string
sql: ${TABLE}.item_name ;;
}
dimension: item_category {
type: string
sql: ${TABLE}.item_category ;;
}
dimension: price {
type: number
value_format_name: usd
sql: ${TABLE}.price ;;
}
dimension: quantity {
type: number
sql: ${TABLE}.quantity ;;
}
measure: total_quantity {
type: sum
sql: ${quantity} ;;
}
measure: total_item_revenue {
type: sum
sql: ${price} * ${quantity} ;;
value_format_name: usd
}
}
Join using UNNEST in the Explore
In your model file, join the nested view to the main Explore using LEFT JOIN UNNEST(...) with WITH OFFSET, and explicitly set relationship: one_to_many.
explore: ga4_events {
label: "GA4 E-Commerce Events"
join: items {
from: ga4_event_items
sql: LEFT JOIN UNNEST(${ga4_events.items}) AS items WITH OFFSET AS item_offset ;;
relationship: one_to_many
}
}
Unnest simple scalar arrays (ARRAY<STRING> or ARRAY<INT64>)
If your nested array contains primitive values like ARRAY<STRING> tags or SKUs rather than structs, unnest it using the same UNNEST join pattern. Reference the unnested scalar value in your dimension using ${TABLE}:
# Explore join for a simple string array: ARRAY<STRING>
explore: impressions {
join: unresolved_skus {
sql: LEFT JOIN UNNEST(${impressions.unresolved_skus}) AS unresolved_skus ;;
relationship: one_to_many
}
}
view: unresolved_skus {
dimension: sku {
type: string
sql: ${TABLE} ;; # Directly references the scalar array element
}
}
Alternative pattern: inline subqueries for key-value arrays
When working with key-value repeated structs (like GA4 event_params or event log properties), you may only want to extract 1 or 2 specific property values directly onto the parent event record without exposing a full unnested view in the Explore.
Instead of an Explore-level UNNEST join, use an inline scalar subquery directly inside a LookML dimension:
view: ga4_events {
# Extract a single key's value directly as a parent-level dimension
dimension: session_id {
group_label: "Session Attributes"
type: string
sql: (
SELECT value.string_value
FROM UNNEST(${TABLE}.event_params) AS param
WHERE param.key = 'ga_session_id'
LIMIT 1
) ;;
}
}
- Use inline subqueries when you need 1–2 specific key values as first-class parent attributes without exposing a
1:Nunnested view in the Explore UI or risking row fanout. - Use Explore UNNEST joins when users need to slice, filter, or aggregate across arbitrary keys and values dynamically in the Explore UI.
Filter nested arrays without row explosion (WHERE EXISTS in LookML)
Unnesting arrays in SQL expands parent rows into 1:N rows. If your goal is only to filter parent records based on nested array conditions (for example, finding events where the purchased items array contains a specific category), unnesting in the Explore join adds unnecessary processing.
Instead, create a LookML filter using a BigQuery EXISTS subquery combined with Liquid:
view: ga4_events {
filter: has_purchased_category {
group_label: "Array Filters"
type: string
sql: EXISTS (
SELECT 1
FROM UNNEST(${TABLE}.items) AS item
WHERE {% condition has_purchased_category %} item.item_category {% endcondition %}
) ;;
}
}
When a user applies has_purchased_category = "Apparel" in the Explore filter bar, BigQuery evaluates the condition natively inside EXISTS (...). The parent table row count remains 1:1, avoiding row multiplication while preserving single-table query speed.
Fix filter suggestions on unnested views (full_suggestions: yes)
When users filter on dimensions inside an unnested view (like item_name in ga4_event_items), Looker's default auto-suggestion engine queries the leaf view standalone:
-- Default Looker suggestion query (Fails for unnested views)
SELECT DISTINCT item_name
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.ga4_event_items`
LIMIT 1000
Because ga4_event_items is a virtual LookML wrapper around an unnested array rather than a physical database table, this standalone query fails, breaking auto-complete suggestions in the Explore filter bar.
To fix this, add full_suggestions: yes to any unnested dimension used as a filter:
view: ga4_event_items {
dimension: item_name {
type: string
sql: ${TABLE}.item_name ;;
full_suggestions: yes # Forces Looker to use the full Explore UNNEST query for suggestions
}
}
With full_suggestions: yes, Looker includes the entire Explore query structure—including FROM events LEFT JOIN UNNEST(events.items)—when populating drop-down suggestions.
If unnesting massive tables to scan for distinct filter values slows down UI loading or raises warehouse costs, consider these options:
- Hardcode static values with
suggestions: ["Value A", "Value B"]to bypass database queries entirely. - Route suggestion queries to a smaller lookup dimension in a pre-aggregated reference Explore using
suggest_dimensionandsuggest_explore. - Set
suggest_persist_for: "24 hours"to cache generated suggestions longer and cut warehouse query volume. - Use aggregate tables matching your filter dimension to serve suggestions from pre-aggregated rollups (see Accelerate Suggestions with Aggregate Awareness).
Fanout protection with symmetric aggregates
What happens when a business user selects country from the parent event view, item_name from the items view, and total_event_count from the parent view?
Looker's Symmetric Aggregates engine automatically detects the one_to_many relationship and primary key. Instead of generating a naive COUNT(*) or SUM(revenue) that would multiply parent rows, Looker rewrites the SQL using primary-key hashing:
-- Generated BigQuery SQL from Looker
SELECT
ga4_events.geo.country AS ga4_events_country,
items.item_name AS items_item_name,
-- Symmetric Aggregate prevents fanout double-counting:
COUNT(DISTINCT CASE WHEN ga4_events.event_id IS NOT NULL THEN ga4_events.event_id ELSE NULL END) AS ga4_events_event_count,
COALESCE(CAST(SUM(DISTINCT (CAST(FLOOR(COALESCE(items.price, 0) * (1000000*1.0)) AS NUMERIC) +
(CAST(FARM_FINGERPRINT(CAST(ga4_events.event_id AS STRING)) AS NUMERIC) / 4611686018427387904.0)) / (1000000*1.0))
- SUM(DISTINCT (CAST(FARM_FINGERPRINT(CAST(ga4_events.event_id AS STRING)) AS NUMERIC) / 4611686018427387904.0)) AS NUMERIC), 0) AS items_total_item_revenue
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*` AS ga4_events
LEFT JOIN UNNEST(ga4_events.items) AS items
GROUP BY 1, 2
Symmetric aggregates guarantee that parent measures remain accurate regardless of how many arrays you un-nest.
Under the hood: efficient SQL generation and on-demand joins
BigQuery physically co-locates nested records on disk. Looker complements this architecture through on-demand SQL generation: query-time unnesting logic is defined once in LookML, but executed only when needed.
If a dashboard tile queries only top-level fields (like event_name or country), Looker's SQL generator completely omits the LEFT JOIN UNNEST(...) clause.
-- Looker generates this clean SQL when no nested item fields are selected:
SELECT
ga4_events.geo.country AS ga4_events_country,
COUNT(*) AS ga4_events_event_count
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*` AS ga4_events
GROUP BY 1
Because Looker does not execute UNNEST unless a nested field is requested, you incur zero query performance penalty for modeling deeply nested arrays in your Explore.
Design for Explore viewability and readability
Raw semi-structured schemas can quickly overwhelm business users if exposed as unformatted JSON blobs. Follow these LookML UX principles to keep your Explores clean:
- Mark the raw parent array field as
hidden: yesin the main view so users interact only with unnested dimensions. - Organize 1:1 struct subfields under intuitive categories using
group_labelin the field picker. - Use
view_labelin the Explore join to give the unnested array a clean display name, such asview_label: "Purchased Items".
Handle deeply nested fields (arrays inside arrays)
What if your data contains an array inside an array? In GA4, each item in items can contain its own nested array of parameters (items.item_params).
You can handle arbitrary nesting depths by chaining UNNEST joins in LookML:
view: ga4_item_params {
dimension: param_key {
type: string
sql: ${TABLE}.key ;;
}
dimension: param_value {
type: string
sql: COALESCE(${TABLE}.value.string_value, CAST(${TABLE}.value.int_value AS STRING)) ;;
}
}
# In your Explore definition:
explore: ga4_events {
join: items {
from: ga4_event_items
sql: LEFT JOIN UNNEST(${ga4_events.items}) AS items ;;
relationship: one_to_many
}
join: item_params {
from: ga4_item_params
sql: LEFT JOIN UNNEST(${items.item_params}) AS item_params ;;
relationship: one_to_many
}
}
Avoid unnesting two independent top-level arrays (such as UNNEST(event_params) and UNNEST(items)) in the same query unless necessary. Parallel un-nesting produces a cross-join Cartesian product in BigQuery. If users need to query multiple independent arrays simultaneously, model them in separate Explores or use a Persistent Derived Table (PDT) to pre-aggregate.
Try it now: public BigQuery datasets with nested schemas
If you want to practice modeling nested and repeated data in LookML without setting up your own database, Google Cloud provides several free public datasets in BigQuery:
bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*features 3-tier nesting. Top-levelgeoanddevicestructs map 1:1, theitemsarray maps 1:N purchased products, anditems.item_paramscontains a nested array of key-value parameters inside each item.bigquery-public-data.github_repos.commitscontains 2-tier nesting for open-source code activity. Commitauthorandcommitterdetails exist as 1:1 structs, whiledifferencecontains an array of structs tracking file modifications (old_path,new_path,old_sha1,new_sha1).bigquery-public-data.fhir_synthea.patientrepresents healthcare HL7 FHIR records with deep clinical nesting. Thecommunicationarray contains alanguagestruct, which in turn contains acodingarray of code and display structs (communication.language.coding).
You can point a Looker connection to bigquery-public-data and generate LookML directly from these tables to inspect how Looker automatically builds dot-notation dimensions and unnested child views.
Governance and lineage in Knowledge Catalog
Modeling nested fields cleanly in LookML does more than produce fast SQL—it integrates directly with enterprise data governance tools like Knowledge Catalog.
Looker's native integration periodically ingests LookML metadata, view definitions, Explores, and dashboards into Knowledge Catalog asset records:
- Knowledge Catalog automatically maps downstream Looker dimensions (like
${TABLE}.geo.countryor${TABLE}.price) back to their exact BigQuery source columns, including nestedSTRUCTleaf nodes. - Descriptions added in LookML (
description: "The item unit price in USD") sync automatically to Knowledge Catalog, creating a single source of truth for both raw database schemas and semantic BI layers. - If a data engineer changes a nested field schema in BigQuery, Knowledge Catalog uses data lineage tracking to pinpoint every downstream dashboards and explores impacted by the change.
Auto-generating LookML for derived tables
Looker generates native LookML for physical BigQuery tables with STRUCT and ARRAY columns, but fails when prototyping custom queries: SQL Runner and Model Runner cannot parse or generate nested LookML from custom SQL queries or derived tables.
When you run a custom query projecting nested structs or arrays in SQL Runner, the derived table LookML flattens or ignores the record hierarchy. It omits dot-notation dimensions for leaf STRUCT attributes and skips generating dedicated child views and chained LEFT JOIN UNNEST(...) definitions for repeated ARRAY fields.
Workaround: scratch-schema view generator utility
To generate LookML from a complex query without creating permanent views in production BigQuery, automate the exact table workflow Looker expects.
Using lkr-dev-cli in Code Mode (lkr code-mode), run a script inside an isolated sandbox to handle the full lifecycle; the script will:
- Verify that the target connection configures a scratch database.
- Wrap your raw SQL query in a temporary view statement (
CREATE OR REPLACE VIEW <tmp_db_name>.<view_name> AS ...) and run it using Looker SQL Runner endpoints (create_sql_query+run_sql_query). - Call
generate_lookml_with_new_files()against the temporary view. Because BigQuery exposes table schema metadata for views, Looker generates dot-notation dimensions and parent-childUNNESTjoins automatically. - Drop the temporary view (
DROP VIEW IF EXISTS) from the scratch schema so no temporary objects persist in BigQuery.
The scratch schema for your connection must be set and accessible by the Looker service account or user who is performing the generation.
Script and sample query resources
Download the generator utility and multi-level sample query to run on your instance:
bq_view_lookml_generator.py— The Python script for Looker Code Mode (lkr code-mode) that orchestrates creating the scratch view, invokinggenerate_lookml_with_new_files(), and deleting the view.nested_structs_sample.sql— Sample BigQuery SQL constructing multi-levelSTRUCTandARRAYattributes (customer,metadata,items, and item-levelattributes).
Functional overview
The bq_view_lookml_generator.py script executes inside the isolated Python sandbox with Looker SDK primitives pre-authenticated:
- Query
all_connections()to locate the target database connection and verify that atmp_db_namescratch schema is configured. - Run a
CREATE OR REPLACE VIEW <tmp_db_name>.<custom_view_name>statement through Looker'screate_sql_queryandrun_sql_queryendpoints. - Call
generate_lookml_with_new_files()against the temporary view so Looker's table inspector builds child view files and cascading unnest joins. - Run
DROP VIEW IF EXISTSover the scratch view so no temporary database objects linger in BigQuery.
Running the generator script
Execute the script via uvx without installing manual SDK dependencies. Provide either --var connection=... or --var model=... to target your database connection or model:
uvx --from "lkr-dev-cli[codemode]" lkr \
--client-id YOUR_LOOKER_CLIENT_ID \
--client-secret YOUR_LOOKER_CLIENT_SECRET \
--base-url https://looker.company.com \
--dev code-mode sandbox \
--file=bq_view_lookml_generator.py \
--var project=my_looker_project \
--var connection=my_bigquery_connection \
--var view_name=order_nested_example \
--var sql_file=nested_structs_sample.sql
Generated LookML output
The script produces view definitions and a hidden base Explore with chained unnest joins:
explore: orders_nested_view {
hidden: yes
join: orders_nested_view__items {
view_label: "Orders Nested View: Items"
sql: LEFT JOIN UNNEST(${orders_nested_view.items}) as orders_nested_view__items ;;
relationship: one_to_many
}
join: orders_nested_view__items__attributes {
view_label: "Orders Nested View: Items Attributes"
sql: LEFT JOIN UNNEST(${orders_nested_view__items.attributes}) as orders_nested_view__items__attributes ;;
relationship: one_to_many
}
}
view: orders_nested_view {
# ... rest of view definition
}
view: orders_nested_view__items {
# ... rest of view definition
}
view: orders_nested_view__items__attributes {
# ... rest of view definition
}
Extending the hidden base Explore
To reuse and build upon the hidden base explore across models, refer to reusing code with extends:
explore: order_items {
extends: [orders_nested_view]
join: orders_nested_view {
sql_on: ${orders_nested_view.order_id} = ${orders_nested_view.order_id} ;;
}
# More joins...
}
When extending a base explore, all joins and unnests defined in the base explore (orders_nested_view) are automatically inherited and brought into any extending explore (order_items).
Summary
By using LookML's native support for STRUCT dot notation and UNNEST joins:
- Eliminate fragile ETL pipelines designed solely to flatten semi-structured data.
- Preserve BigQuery column-pruning for maximum performance and lower costs.
- Prevent metric double-counting via automatic Symmetric Aggregates.
- Maintain full end-to-end data lineage in Knowledge Catalog.