> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Geospatial filtering and distance sort

> Filter products by geographic radius, polygon, or bounding box, and sort by distance from an origin using PostGIS-backed geo attributes ingested from product metafields and metaobject references.

## Overview

Layers supports geographic filtering and sorting against any product attribute typed as **geo**. Once an attribute is geo-typed, products carrying that attribute can be:

* **Filtered** to only those within a radius, polygon, or bounding box of an origin.
* **Sorted** by distance from an origin (ascending or descending).

Use this to power experiences like "stores near me", "products available in this delivery zone", or "tours within the highlighted region on the map".

Geo data is stored in a dedicated PostGIS-backed sidecar table and queried via spatial indexes, so geo filters and sorts run at the same scale as the rest of the catalog.

## Configuring a geo attribute

A geo attribute is a [catalog attribute](/platform/attributes/catalog-attributes) whose `value_type` is set to `geo`. Geo attributes are sourced from product metafields or referenced metaobjects.

### Supported attribute code patterns

| Code pattern                                      | Source                                                                                             |
| :------------------------------------------------ | :------------------------------------------------------------------------------------------------- |
| `metafields.{namespace}.{key}`                    | Reads the geometry directly from a product metafield value                                         |
| `metafields.{namespace}.{key}.{metaobject_field}` | Reads the geometry from a named field on a metaobject referenced by the metafield (single or list) |

For metaobject-reference sources, every referenced metaobject's named field contributes one row — a product can have many geo points (e.g. all retail locations that carry it). Filters and sorts use `EXISTS` / `MIN()` semantics so a product matches if **any** of its geo rows match.

### Geometry types

| Geometry type  | Use for                                                                     |
| :------------- | :-------------------------------------------------------------------------- |
| `point`        | A single coordinate (store location, event venue, pickup point)             |
| `polygon`      | A bounded region (delivery zone, service area, neighborhood)                |
| `multipolygon` | A set of disjoint regions (multi-zone delivery, distributed coverage areas) |

### Accepted input shapes

The ingestion pipeline normalizes several common shapes into canonical GeoJSON before storing:

```json theme={null}
// Shorthand point
{ "lat": 37.7749, "lng": -122.4194 }
{ "latitude": 37.7749, "longitude": -122.4194 }

// GeoJSON Point
{ "type": "Point", "coordinates": [-122.4194, 37.7749] }

// GeoJSON Polygon (rings must be closed — first and last position equal)
{
  "type": "Polygon",
  "coordinates": [
    [
      [-122.43, 37.76],
      [-122.40, 37.76],
      [-122.40, 37.79],
      [-122.43, 37.79],
      [-122.43, 37.76]
    ]
  ]
}

// GeoJSON MultiPolygon
{
  "type": "MultiPolygon",
  "coordinates": [ /* array of polygons */ ]
}
```

<Note>
  GeoJSON coordinates are `[longitude, latitude]` — the reverse of conversational `lat, lng` order. The shorthand `{ lat, lng }` form is provided as a convenience.
</Note>

### Polygon match mode

When the source geometry on a product is itself a polygon (e.g. a delivery zone), you can configure whether the filter polygon must **contain** the product polygon or merely **intersect** it:

| `polygon_match`        | Behavior                                                                                                            |
| :--------------------- | :------------------------------------------------------------------------------------------------------------------ |
| `intersects` (default) | The filter polygon and the product geometry share any area — useful for "any zone that overlaps the search area"    |
| `contains`             | The filter polygon must fully contain the product geometry — useful for "zones entirely within the selected region" |

This setting lives on the attribute definition, not on the filter request, so the matching semantics are consistent across surfaces.

## Geo filter operators

Three new operators extend the [filter expression](/engine/filtering) language. Each takes a single payload object inside the `values` array.

| Operator         | Payload                                                  | Matches products whose geo attribute                                |
| :--------------- | :------------------------------------------------------- | :------------------------------------------------------------------ |
| `geoRadius`      | `{ lat, lng, radius_meters }`                            | Lies within `radius_meters` of `(lat, lng)`                         |
| `geoBoundingBox` | `{ north_east: { lat, lng }, south_west: { lat, lng } }` | Lies inside the bounding rectangle                                  |
| `geoPolygon`     | GeoJSON Polygon or MultiPolygon                          | Matches the filter polygon per the attribute's `polygon_match` mode |

All operators accept either `lat`/`lng` or `latitude`/`longitude` (and `lon`) on coordinate fields. Bounding boxes also accept `northEast`/`southWest` camelCase corners. Radius can be supplied as `radius_meters` or `radiusMeters`.

### Examples

**Products within 5 km of a shopper's location:**

```json theme={null}
{
  "filter_group": {
    "conditional": "AND",
    "expressions": [
      {
        "property": "metafields.locations.coordinates",
        "operator": "geoRadius",
        "values": [
          { "lat": 37.7749, "lng": -122.4194, "radius_meters": 5000 }
        ]
      }
    ]
  }
}
```

**Products inside the visible map viewport:**

```json theme={null}
{
  "property": "metafields.locations.coordinates",
  "operator": "geoBoundingBox",
  "values": [
    {
      "north_east": { "lat": 37.81, "lng": -122.36 },
      "south_west": { "lat": 37.72, "lng": -122.48 }
    }
  ]
}
```

**Products whose delivery zone covers a hand-drawn region:**

```json theme={null}
{
  "property": "metafields.fulfillment.delivery_zone.geometry",
  "operator": "geoPolygon",
  "values": [
    {
      "type": "Polygon",
      "coordinates": [
        [
          [-122.45, 37.74],
          [-122.39, 37.74],
          [-122.39, 37.80],
          [-122.45, 37.80],
          [-122.45, 37.74]
        ]
      ]
    }
  ]
}
```

### Validation

Geo operator payloads are validated before the query runs:

* `lat` must be between -90 and 90; `lng` between -180 and 180.
* `radius_meters` must be greater than 0.
* Polygon rings must be closed (first and last position equal) and contain at least 4 positions.
* Unsupported geometry types (e.g. `LineString`) are rejected.

A geo filter against a non-geo attribute, a missing attribute, or a malformed payload matches **zero products** rather than erroring on the whole request.

## Sort by distance

The `geo_distance` sort order ranks products by distance from a given origin against a geo-typed attribute.

```json theme={null}
{
  "sort_order": {
    "type": "geo_distance",
    "attribute": "metafields.locations.coordinates",
    "origin_lat": 37.7749,
    "origin_lng": -122.4194,
    "direction": "asc"
  }
}
```

| Field        | Required | Description                                               |
| :----------- | :------- | :-------------------------------------------------------- |
| `type`       | Yes      | Must be `geo_distance`                                    |
| `attribute`  | Yes      | Code of the geo attribute to measure against              |
| `origin_lat` | Yes      | Latitude of the origin point                              |
| `origin_lng` | Yes      | Longitude of the origin point                             |
| `direction`  | No       | `asc` (nearest first, default) or `desc` (farthest first) |

### Multi-point products

When a product has multiple geo rows (e.g. one per retail location), the distance used for sorting is the **minimum** distance across all rows. A product carried at three stores sorts by the closest of the three.

### Products without geo data

Products with no rows for the geo attribute are placed in a separate tier that always sorts **after** all products with a distance, regardless of `direction`. This prevents missing-data products from polluting the top of the result set.

## Pairing filters and sort

Filter and sort can target the same or different geo attributes. A common pattern is to filter by radius and sort by distance against the same attribute, so the result set contains every product within range, ordered from nearest to farthest:

```json theme={null}
{
  "filter_group": {
    "conditional": "AND",
    "expressions": [
      {
        "property": "metafields.locations.coordinates",
        "operator": "geoRadius",
        "values": [{ "lat": 37.7749, "lng": -122.4194, "radius_meters": 10000 }]
      }
    ]
  },
  "sort_order": {
    "type": "geo_distance",
    "attribute": "metafields.locations.coordinates",
    "origin_lat": 37.7749,
    "origin_lng": -122.4194,
    "direction": "asc"
  }
}
```

## Ingestion behavior

Geo values are extracted from products during the catalog sync that powers searchable data:

* For a `metafields.{ns}.{key}` attribute, the metafield value itself is normalized and stored as one row.
* For a `metafields.{ns}.{key}.{field}` attribute, every metaobject reference (single or list) contributes one row per metaobject whose named field holds a geometry.
* Malformed payloads are silently skipped — a single bad metaobject does not block ingestion for the rest.
* Each row records its source (`metafield` or `metaobject`) and a `source_ref` (the metaobject ID), making per-product geo data inspectable.

Shopify Locations also gain a generated geography column during the standard location sync, ready for future location-backed filtering features. No action is required to opt in.

## Troubleshooting

**A geo filter returns no products.** Confirm the attribute's `value_type` is `geo` and that the source metafield (or metaobject field) contains a recognized GeoJSON or `{ lat, lng }` payload. Check the most recent catalog sync ran after the geo data was added.

**Distance sort puts unexpected products at the top.** Products with no geo data sort last by design. If a clearly-far product appears first, verify the product's geo rows actually reflect the intended coordinates — multi-point products use `MIN(distance)` so a single bad row pulls the product up.

**Polygon filter matches too aggressively.** The attribute's `polygon_match` mode controls this. Switch from `intersects` (the default) to `contains` if you want only fully enclosed product geometries to match.

**Coordinate ordering looks reversed.** GeoJSON uses `[lng, lat]`, not `[lat, lng]`. If you're constructing GeoJSON by hand, double-check the order — using the shorthand `{ lat, lng }` form avoids the mismatch entirely.

## See also

* [Filtering language](/engine/filtering) — full filter expression grammar
* [Catalog attributes](/platform/attributes/catalog-attributes) — defining attributes from metafields and metaobjects
* [Metaobjects and reference metafields](/developers/product-schema/metafields/metaobjects-and-references) — modeling locations and zones in Shopify
* [Sort Orders](/platform/sorting) — configuring sort orders in the Layers dashboard
