+1 (726) 227-2971

Geospatial Analytics in LookML: Location Types, Map Layers, and Custom TopoJSON

Almost every Looker project eventually gets the same request: "can we see this on a map?" Stores, warehouses, delivery routes, service territories, customers by postcode — the data is already in the warehouse, and Looker ships map visualisations out of the box. And yet the first attempt usually produces one of three results: a blank map, a map with a single dot in the Gulf of Guinea (hello, 0,0), or a map that takes ninety seconds and melts the warehouse.

Geospatial is one of the few areas of Looker where the visualisation layer depends almost entirely on getting the LookML types right. This tutorial walks through the modelling side properly: which dimension types produce which maps, how map_layer and custom TopoJSON work, how to model warehouse-native geography columns such as BigQuery GEOGRAPHY, and how to keep maps fast.

The four map visualisations, and what each one needs

Looker has four native map types, and each one requires a specific shape of data. Choosing the visualisation first and the model second is the usual cause of a blank map.

VisualisationNeedsTypical use
Map (points)A dimension of type: locationIndividual stores, assets, events
Map (regions / choropleth)A dimension with a map_layer_nameSales by state, country, custom territory
Google Mapstype: location, or a geocodable stringPoint maps with Google base tiles and drilling
Static map (single value)A single type: location rowA dashboard tile pinned to one site

The interactive point map and the choropleth are the two you will build most, and they are driven by different LookML.

Point maps: type: location

A location dimension is not a single column. It is a pair of columns declared together:

dimension: store_location {
  type: location
  sql_latitude: ${TABLE}.latitude ;;
  sql_longitude: ${TABLE}.longitude ;;
  label: "Store Location"
}

Notes that save hours:

  • There is no sql: parameter on a location dimension. Use sql_latitude and sql_longitude. A copied sql: line is silently useless.
  • Latitude and longitude must be numeric. Strings from a CSV load will render nothing. Cast in the view, not in the visualisation: sql_latitude: CAST(${TABLE}.lat AS FLOAT64) ;;.
  • Looker plots the dimension as a lat,lon string in the data table. If you need the parts separately for a tooltip or export, add plain type: number dimensions alongside it.
  • Guard against junk coordinates in the model rather than teaching every analyst to filter them:
dimension: store_location {
  type: location
  sql_latitude:
    CASE WHEN ${TABLE}.latitude BETWEEN -90 AND 90
         AND  ${TABLE}.longitude BETWEEN -180 AND 180
         AND NOT (${TABLE}.latitude = 0 AND ${TABLE}.longitude = 0)
         THEN ${TABLE}.latitude END ;;
  sql_longitude:
    CASE WHEN ${TABLE}.latitude BETWEEN -90 AND 90
         AND  ${TABLE}.longitude BETWEEN -180 AND 180
         AND NOT (${TABLE}.latitude = 0 AND ${TABLE}.longitude = 0)
         THEN ${TABLE}.longitude END ;;
}

The 0,0 exclusion matters more than it looks: nulls and failed geocodes land there, and a cluster of them in the Atlantic is the single most common "the map is wrong" ticket.

Point maps are row-level maps

A point map plots one marker per row of the result set. Plot 200,000 rows and the browser, not the warehouse, is what falls over. Aggregate before you map:

  • Group to a site, hex, or geohash dimension and plot one point per group with a sized measure.
  • Or add an explore-level limit and a required filter so nobody runs the unbounded version.

A cheap grid rollup in BigQuery:

dimension: geohash_5 {
  type: string
  sql: ST_GEOHASH(ST_GEOGPOINT(${TABLE}.longitude, ${TABLE}.latitude), 5) ;;
}

dimension: geohash_centroid {
  type: location
  sql_latitude:  ST_Y(ST_GEOGPOINTFROMGEOHASH(${geohash_5})) ;;
  sql_longitude: ST_X(ST_GEOGPOINTFROMGEOHASH(${geohash_5})) ;;
}

Now "events by area" is a few hundred points instead of a few hundred thousand.

Choropleths: built-in map layers

For region maps, Looker does not want coordinates. It wants a key that matches a feature in a map layer. The built-in types are the fast path:

dimension: country {
  type: string
  map_layer_name: countries
  sql: ${TABLE}.country_iso ;;
}

dimension: state {
  type: string
  map_layer_name: us_states
  sql: ${TABLE}.state_name ;;
}

dimension: postcode {
  type: zipcode
  sql: ${TABLE}.zip5 ;;
}

Looker ships layers including countries, uk_postcode_areas, us_states, us_counties, us_zipcode_tabulation_areas and us_congressional_districts, plus the shorthand dimension types type: zipcode and type: region.

The entire game here is key matching. Built-in layers match on specific property values — ISO codes for countries, full names or FIPS codes for US geographies — and a mismatch renders a grey, empty map with no error. Practical rules:

  • Normalise in LookML, not in the warehouse export: sql: UPPER(TRIM(${TABLE}.country_iso)) ;;.
  • Two-letter versus three-letter ISO codes are the classic failure. Pick one and match the layer.
  • Keep a "was this mapped?" measure so blank regions are visible as data quality, not mystery:
measure: unmapped_rows {
  type: count
  filters: [country: "-NOT NULL"]
  hidden: no
}

Custom map layers: your own TopoJSON

Business geography rarely matches government geography. Sales territories, delivery zones, franchise areas and service regions all need a custom layer, declared once in the model file and referenced from any dimension.

map_layer: sales_territories {
  file: "/maps/sales_territories.topojson"
  property_key: "territory_code"
  property_label_key: "territory_name"
  projection: "mercator"
  extents_json_url: ""
}

dimension: territory {
  type: string
  map_layer_name: sales_territories
  sql: ${TABLE}.territory_code ;;
}

What matters in practice:

  • TopoJSON, not GeoJSON. Convert with geo2topo from the topojson toolchain, and simplify aggressively (toposimplify) — a 40 MB boundary file will make every dashboard load feel broken. Aim for well under a few megabytes.
  • property_key is the join key. Its values must match your dimension's SQL output exactly, including case and leading zeros. Zero-padded codes stored as integers in the warehouse are a frequent break: sql: LPAD(CAST(${TABLE}.zone AS STRING), 3, '0') ;;.
  • The file lives in the LookML project (commit it under a maps/ folder) or can be served from a URL with file: replaced by a hosted reference. Committing it means territory boundary changes go through Git review like any other model change — which is exactly what you want when the boundaries decide who gets commission.
  • Set projection deliberately. mercator is the usual choice; equirectangular or albersUsa suit specific extents.

Warehouse-native geography: BigQuery GEOGRAPHY and friends

Modern warehouses have real spatial types, and Looker can lean on them instead of reimplementing geometry in Liquid.

A GEOGRAPHY column cannot be shown directly, so expose the pieces Looker understands and push the spatial work down to SQL:

dimension: customer_point {
  type: location
  sql_latitude:  ST_Y(${TABLE}.geo) ;;
  sql_longitude: ST_X(${TABLE}.geo) ;;
}

dimension: distance_to_store_km {
  type: number
  sql: ST_DISTANCE(${TABLE}.geo, ${stores.geo}) / 1000 ;;
  value_format_name: decimal_1
}

dimension: within_service_area {
  type: yesno
  sql: ST_CONTAINS(${territories.boundary}, ${TABLE}.geo) ;;
}

A spatial join in an explore is legitimate but expensive; it will not use ordinary indexes and in BigQuery it scans. Two mitigations that work:

  1. Materialise the expensive assignment (customer to territory, event to zone) in a persistent derived table refreshed on a datagroup, and join to the resulting key rather than recomputing ST_CONTAINS on every query.
  2. Pre-filter with a cheap bounding box before the exact predicate, so the expensive function runs on far fewer rows.

Snowflake (ST_DISTANCE, ST_WITHIN), Databricks (h3_* functions) and Postgres/PostGIS all map onto the same pattern: the geometry lives in SQL, LookML exposes typed, cheap-to-render outputs.

A parameterised radius filter

Users almost always want "within X km of Y". That is a filtered measure driven by templated filters:

filter: center_latitude {
  type: number
  suggest_dimension: store.latitude
}

filter: center_longitude {
  type: number
}

dimension: distance_from_center_km {
  type: number
  sql: ST_DISTANCE(
         ${TABLE}.geo,
         ST_GEOGPOINT({% parameter center_longitude %}, {% parameter center_latitude %})
       ) / 1000 ;;
}

Wrap the parameters in a required filter on the explore so an empty value never produces a full-table distance calculation.

Making maps useful, not just pretty

  • Drills. Point maps honour drill_fields; a marker click that returns the site record, last order date and owner turns a map into an operational tool instead of decoration.
  • Value formatting and labels. Choropleth tooltips use the dimension label and the measure's value_format; set both, or users see raw codes and unrounded floats.
  • Region permissions. Maps expose geography, and geography is often the unit of access control. Apply the same access_filter / user-attribute pattern you use elsewhere so a regional manager's map shows only their territory — otherwise a map tile becomes the one place your row-level security leaks.
  • Caching. Map queries are ordinary queries: attach them to a datagroup so a dashboard of six map tiles hits cache rather than six fresh spatial scans.
  • Mobile and embeds. Custom TopoJSON downloads on every render. In an embedded dashboard on a slow connection, layer size is the user experience.

A checklist before you ship a map

  1. Coordinates are numeric, bounds-checked, and 0,0 is excluded.
  2. Point maps are aggregated or hard-limited — no unbounded row-level plots.
  3. Choropleth keys are normalised and verified to match the layer's property_key; unmatched rows are measurable.
  4. Custom TopoJSON is simplified, committed to the project, and reviewed like code.
  5. Spatial joins are materialised in a PDT with a refresh policy, not recomputed per query.
  6. Drill fields, labels and value formats are set on every mapped dimension and measure.
  7. Access filters apply to the geographic dimension.

Get those seven right and maps become one of the highest-engagement things in a Looker instance — the tiles executives actually open. Skip them and you get the blank grey map that quietly convinces a business that Looker "cannot do maps".

If you are modelling territories, service areas, or warehouse-native geography and want the spatial layer designed once, properly, our Looker developers do exactly this kind of work — get in touch.