<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Craig Taverner - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Craig Taverner - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/craig-taverner</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/craig-taverner</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/craig-taverner.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sat, 26 Sep 2026 07:24:28 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elasticsearch geospatial search with ES|QL]]></title>
    <description><![CDATA[Geospatial search in Elasticsearch Query Language (ES|QL). Elasticsearch has powerful geospatial search features, which are now coming to ES|QL for dramatically improved ease of use and OGC familiarity.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch has had powerful <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/geospatial-analysis.html">geospatial search and analytics capabilities</a> for many years, but the API was quite different from what typical GIS users were used to. In the past year we've <a href="https://www.elastic.co/search-labs/blog/esql-piped-query-language-goes-ga">added the ES|QL query language</a>, a piped query language as easy, or even easier, than SQL. It's particularly suited to the search, security, and observability use cases Elastic excels at. We're also adding support for geospatial search and analytics within ES|QL, making it far easier to use, especially for users coming from SQL or <a href="https://en.wikipedia.org/wiki/Geographic_information_system">GIS</a> communities.</p><p>Elasticsearch 8.12 and 8.13 brought basic support for geospatial types to ES|QL. This was dramatically enhanced with the addition of geospatial search capabilities in 8.14. More importantly, this support was designed to conform closely to the <a href="https://en.wikipedia.org/wiki/Simple_Features">Simple Feature Access</a> standard from the <a href="https://en.wikipedia.org/wiki/Open_Geospatial_Consortium">Open Geospatial Consortium (OGC)</a> used by other spatial databases like PostGIS, making it much easier to use for GIS experts familiar with these standards.</p><p>In this blog, we'll show you how to use ES|QL to perform geospatial searches, and how it compares to the SQL and Query DSL equivalents. We'll also show you how to use ES|QL to perform spatial joins, and how to visualize the results in Kibana Maps. Note that all the features described here are in "technical preview", and we'd love to hear your feedback on how we can improve them.</p><h2>Searching for geospatial data</h2><p>Let's start with an example query:</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>This performs a search for any city boundary polygons that intersect with a rectangular search polygon around the Sanya Phoenix International Airport (SYX).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3897df6bed5d6061/6a17d7c2abe0f29eccdfe861/e48bac8f246c8842f2ea97ddd54910045262aeb1-1440x808.png" alt="ESQL Geospatial Search" /><p>In a sample dataset of airports, cities and city boundaries, this search finds the intersecting polygon and returns the desired fields from the matching document:</p><p>abbrev</p><p>airport</p><p>region</p><p>city</p><p>city_location</p><p>SYX</p><p>Sanya Phoenix Int'l</p><p>天涯区</p><p>Sanya</p><p>POINT(109.5036 18.2533)</p><p>That was easy! Now compare this to the classic Elasticsearch Query DSL for the same query:</p>GET /airport_city_boundaries/_search
{
  "_source": ["abbrev", "airport", "region", "city", "city_location"],
  "query": {
    "geo_shape": {
      "city_boundary": {
        "shape": {
          "type": "polygon",
          "coordinates" : [[
            [109.4, 18.1],
            [109.6, 18.1],
            [109.6, 18.3],
            [109.4, 18.3],
            [109.4, 18.1]
          ]]
        }
      }
    }
  }
}
<p>Both queries are reasonably clear in their intent, but the ES|QL query closely resembles SQL. The same query in PostGIS looks like this:</p>SELECT abbrev, airport, region, city, city_location
FROM airport_city_boundaries
WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
);
<p>Look back at the ES|QL example. So similar, right?</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>We've found that existing users of the Elasticsearch API find ES|QL much easier to use. We now expect that existing SQL users, particularly Spatial SQL users, will find that ES|QL feels very familiar to what they are used to seeing.</p><h4>Why not SQL?</h4><p>What about Elasticsearch SQL? It has been around for a while and has some geospatial features. However, Elasticsearch SQL was written as a wrapper on top of the original Query API, which meant only queries that could be transpiled down to the original API were supported. ES|QL does not have this limitation. Being a completely new stack allows for many optimizations that were not possible in SQL. Our benchmarks show ES|QL is <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/esql/nightly/default/6M">very often faster than the Query API</a>, particularly with aggregations!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18bda964c8b24e36/6a17d7c3e3179155d22d568a/b8b6c2b2e45850d832805ed1e71e522f4955f53c-1440x813.png" alt="polygon-intersection-benchmark" /><h2>Differences to SQL</h2><p>Clearly, from the previous example, ES|QL is somewhat similar to SQL, but there are some important differences. For example, ES|QL is a piped query language, starting with a source command like FROM and then chaining all subsequent commands together with the pipe | character. This makes it very easy to understand how each command receives a table of data and performs some action on that table, such as filtering with <code>WHERE</code>, adding columns with <code>EVAL</code>, or performing aggregations with <code>STATS</code>. Rather than starting with <code>SELECT</code> to define the final output columns, there can be one or more <code>KEEP</code> commands, with the last one specifying the final output results. This structure simplifies reasoning about the query.</p><p>Focusing in on the <code>WHERE</code> command in the above example, we can see it looks quite similar to the PostGIS example:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
)
<p>Aside from the difference in string quotation characters, the biggest difference is in how we type-cast the string to a spatial type. In PostGIS, we use the <code>::geometry</code> suffix, while in ES|QL, we use the <code>::geo_shape</code> suffix. This is because ES|QL runs within Elasticsearch, and the type-casting operator <code>::</code> can be used to convert a string to any of the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-limitations.html#_supported_types">supported ES|QL types</a>, in this case, a <code>geo_shape</code>. Additionally, the <code>geo_shape</code> and <code>geo_point</code> types in Elasticsearch imply the spatial coordinate system known as WGS84, more commonly referred to using the SRID number 4326. In PostGIS, this needs to be explicit, hence the use of the <code>SRID=4326;</code> prefix to the WKT string. If that prefix is removed, the SRID will be set to 0, which is more like the Elasticsearch types <code>cartesian_point</code> and <code>cartesian_shape</code>, which are not tied to any specific coordinate system.</p><p>Both ES|QL and PostGIS provide type conversion function syntax as well:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    TO_GEOSHAPE("POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))")
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    ST_SetSRID(
      ST_GeomFromText('POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'),
      4326
    )
)
<h2>OGC functions</h2><p>Elasticsearch 8.14 introduces the following four OGC spatial search functions:</p><p>ES|QL</p><p>PostGIS</p><p>Description</p><p>ST_INTERSECTS</p><p>ST_Intersects</p><p>Returns true if two geometries intersect, and false otherwise.</p><p>ST_DISJOINT</p><p>ST_Disjoint</p><p>Returns true if two geometries do not intersect, and false otherwise. The inverse of ST_INTERSECTS.</p><p>ST_CONTAINS</p><p>ST_Contains</p><p>Returns true if one geometry contains another, and false otherwise.</p><p>ST_WITHIN</p><p>ST_Within</p><p>Returns true if one geometry is within another, and false otherwise. The inverse of ST_CONTAINS.</p><p>These function behave similarly to their PostGIS counterparts, and are used in the same way. For example, <code>ST_INTERSECTS</code> returns true if two geometries intersect and false otherwise. If you follow the documentation links in the above table, you might notice that all the ES|QL examples are within a <code>WHERE</code> clause after a <code>FROM</code> clause, while all the PostGIS examples are using literal geometries. In fact, both platforms support using the functions in any part of the query where they make sense.</p><p>The first example in the PostGIS documentation for <code>ST_INTERSECTS</code> is:</p>SELECT ST_Intersects(
    'POINT(0 0)'::geometry,
    'LINESTRING ( 2 0, 0 2 )'::geometry
);
<p>The ES|QL equivalent of this would be:</p>ROW ST_INTERSECTS(
    "POINT(0 0)"::geo_point,
    "LINESTRING ( 2 0, 0 2 )"::geo_shape
)
<p>Note how we did not specify the SRID in the PostGIS example. This is because in PostGIS when using the <code>geometry</code> type, all calculations are done on a planar coordinate system, and so if both geometries have the same SRID, it does not matter what the SRID is. In Elasticsearch, this is also true for most functions, however, there are exceptions where <code>geo_shape</code> and <code>geo_point</code> use spherical calculations, as we'll see in the next blog about spatial distance search.</p><h2>ES|QL versatility</h2><p>So, we've seen examples above for using spatial functions in <code>WHERE</code> clauses, and in <code>ROW</code> commands. Where else would they make sense? One very useful place is in the <code>EVAL</code> command. This command allows you to evaluate an expression and return the result. For example, let's determine if the centroids of all airports grouped by their country names are within a boundary outlining the country:</p>FROM airports
| EVAL in_uk = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL in_iceland = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| EVAL within_uk = ST_WITHIN(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL within_iceland = ST_WITHIN(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| STATS centroid = ST_CENTROID_AGG(location), count=COUNT() BY in_uk, in_iceland, within_uk, within_iceland
| SORT count ASC
<p>The results are expected, the centroid of UK airports are within the UK boundary, and not within the Iceland boundary, and vice versa:</p><p>centroid</p><p>count</p><p>in_uk</p><p>in_iceland</p><p>within_uk</p><p>within_iceland</p><p>POINT (-21.946634463965893 64.13187285885215)</p><p>1</p><p>false</p><p>true</p><p>false</p><p>true</p><p>POINT (-2.597342072712148 54.33551226578214)</p><p>17</p><p>true</p><p>false</p><p>true</p><p>false</p><p>POINT (0.04453958108176276 23.74658354606057)</p><p>873</p><p>false</p><p>false</p><p>false</p><p>false</p><p>In fact, these functions can be used in any part of the query where their signature makes sense. They all take two arguments, which are either a literal spatial object or a field of a spatial type, and they all return a boolean value. One important consideration is that the coordinate reference system (CRS) of the geometries must match, or an error will be returned. This means you cannot mix <code>geo_shape</code> and <code>cartesian_shape</code> types in the same function call. You can, however, mix <code>geo_point</code> and <code>geo_shape</code> types, as the <code>geo_point</code> type is a special case of the <code>geo_shape</code> type, and both share the same coordinate reference system. The documentation for each of the functions defined above lists the supported type combinations.</p><p>Additionally, either argument can be a spatial literal or a field, in either order. You can even specify two fields, two literals, a field and a literal, or a literal and a field. The only requirement is that the types are compatible. For example, this query compares two fields in the same index:</p>FROM airport_city_boundaries
| EVAL in_city = ST_INTERSECTS(city_location, city_boundary)
| STATS count=COUNT(*) BY in_city
| SORT count ASC
| EVAL cardinality = CASE(count &lt; 10, "very few", count &lt; 100, "few", "many")
| KEEP cardinality, count, in_city
<p>The query basically asks if the city location is within the city boundary, which should generally be true, but there are always exceptions:</p><p>cardinality</p><p>count</p><p>in_city</p><p>few</p><p>29</p><p>false</p><p>many</p><p>740</p><p>true</p><p>A far more interesting question would be whether the airport location is within the boundary of the city that the airport serves. However, the airport location resides in a different index than the one containing the city boundaries. This requires a method to effectively query and correlate data from these two separate indexes.</p><h2>Spatial joins</h2><p>ES|QL does not support <code>JOIN</code> commands, but you can achieve a special case of a join using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"><code>ENRICH</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"> command</a>, which behaves similarly to a 'left join' in SQL. This command operates akin to a 'left join' in SQL, allowing you to enrich results from one index with data from another index based on a spatial relationship between the two datasets.</p><p>For example, let's enrich the results from a table of airports with additional information about the city they serve by finding the city boundary that contains the airport location, and then perform some statistics on the results:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
| MV_EXPAND city_boundary
| EVAL boundary_wkt_length = LENGTH(TO_STRING(city_boundary))
| STATS centroid = ST_CENTROID_AGG(location), count = COUNT(city_location), min_wkt = MIN(boundary_wkt_length), max_wkt = MAX(boundary_wkt_length) BY region
| SORT count DESC
| LIMIT 5
<p>This returns the top 5 regions with the most airports, along with the centroid of all the airports that have matching regions, and the range in length of the WKT representation of the city boundaries within those regions:</p><p>centroid</p><p>count</p><p>min_wkt</p><p>max_wkt</p><p>region</p><p>POINT (-32.56093470960719 32.598117914802714)</p><p>90</p><p>207</p><p>207</p><p>null</p><p>POINT (-73.94515332765877 40.70366442203522)</p><p>9</p><p>438</p><p>438</p><p>City of New York</p><p>POINT (-83.10398317873478 42.300230911932886)</p><p>9</p><p>473</p><p>473</p><p>Detroit</p><p>POINT (-156.3020245861262 20.176383580081165)</p><p>5</p><p>307</p><p>803</p><p>Hawaii</p><p>POINT (-73.88902732171118 45.57078813901171)</p><p>4</p><p>837</p><p>837</p><p>Montréal</p><p>So, what really happened here? Where did the supposed <code>JOIN</code> occur? The crux of the query lies in the <code>ENRICH</code> command:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
<p>This command instructs Elasticsearch to enrich the results retrieved from the <code>airports</code> index, and perform an <code>intersects</code> join between the <code>city_location</code> field of the original index, and the <code>city_boundary</code> field of the <code>airport_city_boundaries</code> index, which we used in a few examples earlier. But some of this information is not clearly visible in this query. What we do see is the name of an enrich policy <code>city_boundaries</code>, and the missing information is encapsulated within that policy definition.</p>{
  "geo_match": {
    "indices": "airport_city_boundaries",
    "match_field": "city_boundary",
    "enrich_fields": ["city", "airport", "region", "city_boundary"]
  }
}
<p>Here we can see that it will perform a <code>geo_match</code> query (<code>intersects</code> is the default), the field to match against is <code>city_boundary</code>, and the <code>enrich_fields</code> are the fields we want to add to the original document. One of those fields, the <code>region</code> was actually used as the grouping key for the <code>STATS</code> command, something we could not have done without this 'left join' capability. For more information on enrich policies, see the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/enrich-setup.html">enrich documentation</a>. While reading those documents, you will notice that they describe using the enrich indexes for enriching data at index time, by configuring ingest pipelines. This is not required for ES|QL, as the <code>ENRICH</code> command works at query time. It is sufficient to prepare the enrich index with the necessary data and enrich policy, and then use the <code>ENRICH</code> command in your ES|QL queries.</p><p>You may also notice that the most commonly found region was <code>null</code>. What could this imply? Recall that I likened this command to a 'left join' in SQL, meaning if no matching city boundary is found for an airport, the airport is still returned but with <code>null</code> values for the fields from the <code>airport_city_boundaries</code> index. It turns out there were 89 airports that found no matching <code>city_boundary</code>, and one airport with a match where the <code>region</code> field was <code>null</code>. This lead to a count of 90 airports with no <code>region</code> in the results. Another interesting detail is the need for the <code>MV_EXPAND</code> command. This is necessary because the <code>ENRICH</code> command may return multiple results for each input row, and <code>MV_EXPAND</code> helps to separate these results into multiple rows, one for each outcome. This also clarifies why "Hawaii" shows different <code>min_wkt</code> and <code>max_wkt</code> results: there were multiple regions with the same name but different boundaries.</p><h2>Kibana Maps</h2><p>Kibana has added support for Spatial ES|QL in the Maps application. This means that you can now use ES|QL to search for geospatial data in Elasticsearch, and visualize the results on a map.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt91922eb4ef43d290/6a16f7161949f737bfe7a7b5/bd78470bd8a4bc60f0db7006bd804b8fe87e2fea-1440x683.png" alt="Kibana Layers ES|QL" /><p>There is a new layer option in the add layers menu, called "ES|QL". Like all of the geospatial features described so far, this is in "technical preview". Selecting this option allows you to add a layer to the map based on the results of an ES|QL query. For example, you could add a layer to the map that shows all the airports in the world.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5185ef7461d7e81d/6a16f718839dfa1559dcfcb1/1dd28d3d0509f92d26b0bb5320a2925f7a54c5d9-1440x736.png" alt="Kibana ES|QL - Airports" /><p>Or you could add a layer that shows the polygons from the <code>airport_city_boundaries</code> index, or even better, how about that complex <code>ENRICH</code> query above that generates statistics for how many airports are in each region?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51ee3543bb811d98/6a16f71a8b73cbbe63189df1/679a0a401faa613c7cedddd07c64f614ac2b7144-1440x727.png" alt="Kibana ES|QL - Region Statistics" /><h2>What's next</h2><p>You might have noticed in two of the examples above we squeezed in yet another spatial function <code>ST_CENTROID_AGG</code>. This is an aggregating function used in the <code>STATS</code> command, and the first of many spatial analytics features we plan to add to ES|QL. We'll blog about it when we've got more to show!</p><p>Before that, we want to tell you more about a particularly exciting feature we've worked on: the ability to perform spatial distance searches, one of the most used spatial search features of Elasticsearch. Can you imagine what the syntax for distance searches might look like? Perhaps similar to an OGC function? Stay tuned for the next blog in this series to find out!</p><p>Spoiler alert: Elasticsearch 8.15 has just been released, and spatial distance search with ES|QL is included!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Craig Taverner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd05627be20e89dfb/6a17d7c6414c640256944fdb/de886289dcb56494920875303b622b030b9b810f-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 12 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>