# Geo points and queries

A `geo_point` field holds one location, or several, per document. This page covers defining the
field, the point formats you can send, and the queries that match points: distance, bounding box,
polygon, shape and grid cell. The examples use the `places` collection from the
[Geo search quickstart](geo-search.md#quickstart). For areas and lines rather than points, see
[Geo shapes](geo-shapes.md).

## Define a geo_point field

A field whose name ends in `_p` (one point) or `_ps` (several points) is a `geo_point` field with
a points index, with no definition needed: `location_p` just works. Any other name needs a
definition:

::: tabs API
=== WarpSearch
```json
{
  "fields": {
    "location":  {"type": "geo_point", "index": "range"},
    "branches":  {"type": "geo_point", "index": "range", "multi": true}
  }
}
```

Send it as the `schema` when you create the collection, or later with
`PATCH /v1/collections/{collection}/schema`. See [Schemas and field types](schemas-and-fields.md).

=== Elasticsearch
```bash
curl -X PUT https://api.warpsearch.io/compat/es/places \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mappings": {"properties": {
    "name": {"type": "text"},
    "category": {"type": "keyword"},
    "location": {"type": "geo_point"}
  }}}'
```
:::

| Option | Effect |
|---|---|
| `index: "range"` | Builds a points index, so geo queries read only the matching area. Without it, the same queries scan the field's column and return the same results, more slowly on large collections. |
| `multi: true` | Several points per document, such as a chain's branches. A document matches when any of its points does, and is returned once. |
| `ignore_malformed` | Default `false`. When `true`, a point that cannot be read is skipped and the rest of the document is indexed; in a multi-valued field only the bad points are skipped. |
| `ignore_z_value` | Default `true`: a third coordinate (altitude) is accepted and dropped. When `false`, a point with a third coordinate is malformed. |

## Send points

A point can be written in any of these forms:

| Form | Example | Order |
|---|---|---|
| Array | `[-73.9851, 40.7589]` | longitude, latitude |
| Object | `{"lat": 40.7589, "lon": -73.9851}` | named |
| GeoJSON | `{"type": "Point", "coordinates": [-73.9851, 40.7589]}` | longitude, latitude |
| String | `"40.7589,-73.9851"` | **latitude, longitude**, as in Elasticsearch |
| WKT | `"POINT (-73.9851 40.7589)"` | longitude, latitude |
| Geohash | `"dr5ru7"` | 1 to 12 characters, read as the center of the cell |

```json
{"id": "p1", "name": "Harbor Coffee", "location": [-73.9851, 40.7589]}
{"id": "p1", "name": "Harbor Coffee", "location": "40.7589,-73.9851"}
```

Keep the order straight: arrays, GeoJSON and WKT put longitude first, while the `"lat,lon"` string
puts latitude first.

A multi-valued field takes an array of points, in any mix of forms:

```json
{"id": "c1", "name": "Harbor Coffee (all branches)", "branches": [[-73.9851, 40.7589], "40.7440,-74.0324"]}
```

- An array of numbers is always one point, so `[-73.9851, 40.7589]` sent to a multi-valued field
  is one point, not two.
- A string without a comma that is not WKT is read as a geohash, so a stray `"40"` becomes a coarse
  geohash cell, not a number.
- Longitude must be within -180 to 180 and latitude within -90 to 90. A point that is out of range
  or cannot be read fails that document (unless `ignore_malformed` is set), and the rest of the
  batch is still written; see [Partial failures](ingesting-documents.md#partial-failures).
- A third coordinate (`[lon, lat, altitude]`, `POINT Z (...)`) is accepted and the altitude is
  dropped, unless `ignore_z_value` is `false`. Altitude is never indexed.
- Points are stored on a fine grid, at roughly centimeter resolution.
- Swapped coordinates cannot be detected when both are in range. `[40.7589, -73.9851]` is a valid
  point in Antarctica.

These forms are for documents. Query centers and corners take `"lat,lon"`, `[lon, lat]`,
`{"lat": ..., "lon": ...}` or `"POINT (lon lat)"`, but not geohashes.

### Return points

Name a geo field in `fields` (or `_source`) and each hit returns its points as `[lon, lat]`,
whatever form you sent. The values are the stored grid points, so they can differ from the input
in about the eighth decimal place.

## Distance queries

A distance query matches points within a radius of a center, edges included. Distances are
great-circle distances over the Earth's mean radius.

::: tabs API
=== WarpSearch
Cafes within 1 km of Times Square:

```json
{
  "language": "es",
  "query": {"term": {"category": "cafe"}},
  "filters": [{"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}}],
  "fields": ["name"]
}
```

=== Elasticsearch
```json
{
  "query": {"bool": {
    "must": [{"term": {"category": "cafe"}}],
    "filter": [{"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}}]
  }},
  "_source": ["name"]
}
```

=== Solr
`d` is in kilometers:

```
q=category:cafe&fq={!geofilt sfield=location pt=40.7580,-73.9855 d=1}&fl=id,name
```
:::

- `distance` is a number of meters or a string with a unit: `m`, `km`, `mi`, `yd`, `ft`, `in`,
  `cm`, `mm` or `nmi` (for example `"500m"`, `"2mi"`).
- `distance_type: plane` is accepted with a warning; the distance is always the great-circle one.
- Solr's `{!geofilt}` takes `sfield`, `pt` and `d`. `score=` is rejected; sort by `geodist()`
  instead (see [Sort by distance](geo-distance-and-nearest.md#sort-by-distance)).

## Bounding boxes

A box matches points between two latitudes and two longitudes, edges included:

::: tabs API
=== WarpSearch
Everything in a box around Midtown Manhattan:

```json
{
  "language": "es",
  "query": {"geo_bounding_box": {"location": {
    "top_left": {"lat": 40.77, "lon": -74.00},
    "bottom_right": {"lat": 40.74, "lon": -73.97}
  }}},
  "fields": ["name"]
}
```

This matches Harbor Coffee (`p1`) and Midtown Market (`p3`).

=== Elasticsearch
```json
{
  "query": {"bool": {"filter": [{"geo_bounding_box": {"location": {
    "top_left": {"lat": 40.77, "lon": -74.00},
    "bottom_right": {"lat": 40.74, "lon": -73.97}
  }}}]}}
}
```

=== Solr
`{!bbox}` matches the box around a circle of `d` kilometers:

```
q=*:*&fq={!bbox sfield=location pt=40.7580,-73.9855 d=2}
```
:::

The corners can be given as `top_left` and `bottom_right`, as `top_right` and `bottom_left`, as the
numbers `top`, `left`, `bottom` and `right`, or as `{"wkt": "BBOX (minLon, maxLon, maxLat, minLat)"}`.
The top must not be below the bottom. A box whose left edge is east of its right edge crosses the
date line: `left: 170, right: -170` covers 170 degrees east through 180 to 170 degrees west.

## Combine with text and other filters

A geo query used as a query gives every match the same score, and in `filters` it does not affect
scores at all. Put the geo condition in `filters` and the words in `query`, and text relevance
orders the nearby results:

```json
{
  "language": "es",
  "query": {"match": {"description": "coffee"}},
  "filters": [
    {"geo_distance": {"distance": "6km", "location": "40.7580,-73.9855"}},
    {"range": {"rating": {"gte": 4.0}}}
  ],
  "fields": ["name", "description"]
}
```

Geo queries combine with others under `bool` (`must`, `filter`, `should`, `must_not`), so
"in this box but not within 500 m of this point" is a `bool` with a `geo_bounding_box` filter and
a `geo_distance` in `must_not`. To rank closer results higher instead of cutting them off, see
[Boost nearby results](geo-distance-and-nearest.md#boost-nearby-results).

## Polygons and shapes

`geo_polygon` matches points inside a polygon given as a list of points, at least three:

```json
{
  "language": "es",
  "query": {"match": {"description": "coffee"}},
  "filters": [{"geo_polygon": {"location": {"points": [
    {"lat": 40.69, "lon": -74.02}, {"lat": 40.69, "lon": -73.97},
    {"lat": 40.74, "lon": -73.97}, {"lat": 40.74, "lon": -74.02}
  ]}}}],
  "fields": ["name"]
}
```

This matches Bridge Roasters (`p2`), the only place that mentions coffee inside the area around
lower Manhattan and Dumbo. Points can be objects, `[lon, lat]` arrays or `"lat,lon"` strings. A
`geo_polygon` has no holes and cannot cross the date line; use `geo_shape` for those.

`geo_shape` takes any shape, as a GeoJSON object or a WKT string, and a `relation`. The same area
as WKT, keeping the cafes outside it:

::: tabs API
=== WarpSearch
```json
{
  "language": "es",
  "query": {"term": {"category": "cafe"}},
  "filters": [{"geo_shape": {"location": {
    "shape": "POLYGON ((-74.02 40.69, -73.97 40.69, -73.97 40.74, -74.02 40.74, -74.02 40.69))",
    "relation": "disjoint"
  }}}],
  "fields": ["name"]
}
```

This matches Harbor Coffee (`p1`) and Hudson Espresso (`p5`).

=== Elasticsearch
```json
{
  "query": {"bool": {
    "must": [{"term": {"category": "cafe"}}],
    "filter": [{"geo_shape": {"location": {
      "shape": {"type": "Polygon", "coordinates": [[
        [-74.02, 40.69], [-73.97, 40.69], [-73.97, 40.74], [-74.02, 40.74], [-74.02, 40.69]
      ]]},
      "relation": "disjoint"
    }}}]
  }}
}
```
:::

| Relation | On a `geo_point` field |
|---|---|
| `intersects` (default) | Some point of the document is in the shape. |
| `within` | Every point of the document is in the shape. For a single-valued field this is the same as `intersects`. |
| `disjoint` | No point of the document is in the shape. Documents without the field do not match. |
| `contains` | Needs a [`geo_shape` field](geo-shapes.md#query-shape-fields); rejected on points. |

| Shape | WKT | GeoJSON `type` |
|---|---|---|
| Point, several points | `POINT`, `MULTIPOINT` | `Point`, `MultiPoint` |
| Line, several lines | `LINESTRING`, `MULTILINESTRING` | `LineString`, `MultiLineString` |
| Polygon with holes, several polygons | `POLYGON`, `MULTIPOLYGON` | `Polygon`, `MultiPolygon` |
| Box | `BBOX (minLon, maxLon, maxLat, minLat)` | `envelope`, `"coordinates": [[minLon, maxLat], [maxLon, minLat]]` |
| Circle | `CIRCLE (lon lat radiusMeters)` | `circle`, `"coordinates": [lon, lat], "radius": "10km"` |
| Mixed | `GEOMETRYCOLLECTION` | `GeometryCollection` |

- Positions are longitude first. Rings must be closed (the last position repeats the first) and
  have a non-zero area. Out-of-range coordinates are rejected.
- A query shape has at most 10000 vertices; more answers `422 limit_exceeded`.
- A point inside a hole does not match. A point exactly on an edge can fall either way, so do not
  rely on edges to separate adjacent areas.
- A polygon that spans more than 180 degrees of longitude and is wound clockwise is read as
  crossing the date line, as Elasticsearch does.
- A box or circle matches exactly what a bounding box or distance query with the same bounds
  matches. Shape queries use the points index when the field has one.
- `indexed_shape` (a shape stored in another document) is not supported; send the shape inline.

## Grid cells

`geo_grid` matches the documents with a point in one grid cell, named by exactly one of:

| Key | Cell | Example |
|---|---|---|
| `geohash` | A geohash, precision 1 to 12 | `{"geohash": "dr5ru"}` |
| `geotile` | A web map tile, `zoom/x/y`, zoom 0 to 29 | `{"geotile": "12/1206/1539"}` |
| `geohex` | An H3 cell, as its index in hex | `{"geohex": "<H3 index>"}` |

```json
{
  "language": "es",
  "query": {"term": {"category": "cafe"}},
  "filters": [{"geo_grid": {"location": {"geohash": "dr5ru"}}}],
  "fields": ["name"]
}
```

The geohash `dr5ru` covers Midtown, so this matches Harbor Coffee (`p1`). Invalid keys are
rejected. A cell matches exactly the documents that a
[grid aggregation](geo-aggregations.md#grid-aggregations) bucket with the same key counts, so a
click on a map cell can fetch exactly the documents that bucket counted.

On a [`geo_shape` field](geo-shapes.md#query-shape-fields), a `geohash` or `geotile` cell matches the
shapes that meet the cell, edges included, so a shape on a cell edge matches both cells. `geohex`
cells work on `geo_point` fields only and answer `400` on shape fields.
