Geospatial Indexes — Point vs Region

Deep Dive on Geospatial Index Architecture in MarkLogic

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2
databaseMinimum MarkLogic Version: 11

MarkLogic's geospatial capabilities fall into two distinct index families: point indexes and region indexes. They sound similar, but they model fundamentally different things. A point index says "this document has a location." A region index says "this document represents an area." Using the wrong one for your data produces queries that either fail silently or return incorrect results.

This is one of those topics where everything feels fine right up until a map dashboard goes very, very wrong.

The other key source of confusion is coordinate ordering. MarkLogic's default coordinate system (wgs84) expects (latitude, longitude). GeoJSON — the most common coordinate encoding in modern APIs — specifies (longitude, latitude). Mixing these two orderings is a silent bug: the query runs, returns results, and those results are geographically wrong. A location in the west of Ireland becomes a point in equatorial Africa, and nothing complains.

Understanding these two distinctions — point vs region, and coordinate ordering — covers the vast majority of geospatial bugs encountered in production MarkLogic systems.

In practice, teams get the best outcomes when they treat geospatial index choice as an architectural decision made before ingestion, not a post-load tuning step.

Preflight Decisions

Before creating any geospatial index, answer these three questions explicitly:

  1. Is each document a location, an area, or both?
  2. Are source coordinates lat-first or lon-first?
  3. Which query type matters most: near/within, or overlap/containment between areas?

MarkLogic geospatial design is one of the places where architecture choices determine whether your query layer stays fast and correct for years, or becomes a stream of silent bugs and expensive reindex work.

Most production failures come from two root causes:

  1. Index-model mismatch: point indexes used for region questions, or region indexes used for point workloads.
  2. Coordinate-system mismatch: lat/lon assumptions mixed with GeoJSON lon/lat reality.

Both failures can return valid-looking results that are geographically wrong.

An additional failure mode appears at scale: teams optimise for a single query pattern, then layer new product requirements on top without evolving index topology. The result is often a brittle query layer where every change requires risky index rewiring.

A practical production posture is to model geospatial intent in three buckets from day one:

  1. User-facing proximity queries (near, within radius).
  2. Policy or rules queries (inside zone, outside boundary).
  3. Analytical spatial relationships (intersects, overlaps, coverage drift over time).

When these three buckets are explicitly mapped to indexes and query paths, geospatial behaviour stays understandable even as dataset size and query diversity grow.

This deep dive focuses on getting those two decisions correct with concrete llamaverse examples, operational rollout guidance, and practical troubleshooting patterns.

The examples in this article assume the llamaverse (v2.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.

Why Geospatial Design Fails in Production

Teams often discover geospatial complexity too late because the first demo works:

  1. A single circle query over a small dataset returns plausible results.
  2. Source data grows and adds mixed coordinate formats.
  3. Region containment questions arrive later from product or analytics teams.
  4. Existing index choices become either incorrect or too expensive.

Treat geospatial index strategy as a first-class data-model decision, not a tuning step.

Llamaverse Geospatial Baseline

The llamaverse contains movement telemetry at:

/cleverllamas/llamaverse/content/llama-movement/llama_location_history.json

That document stores movement rows as GeoJSON-style coordinate pairs (lon, lat). Before designing indexes, profile what is actually in the dataset.

xquery version "1.0-ml";

let $uri := "/cleverllamas/llamaverse/content/llama-movement/llama_location_history.json"
let $records := fn:doc($uri)/*:envelope/*:instance/*:llama-movement
let $llama-ids := fn:distinct-values($records/*:llamaId/fn:string())
let $lons := for $r in $records return xs:double($r/*:coordinates[1]/fn:string())
let $lats := for $r in $records return xs:double($r/*:coordinates[2]/fn:string())
let $aaron-id := "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5"
let $aaron-count := fn:count($records[*:llamaId = $aaron-id])
return (
  "movement-uri: " || $uri,
  "total movement rows: " || fn:count($records),
  "distinct llama IDs: " || fn:count($llama-ids),
  "aaron rows: " || $aaron-count,
  "lon min/max: " || fn:min($lons) || " / " || fn:max($lons),
  "lat min/max: " || fn:min($lats) || " / " || fn:max($lats)
)
movement-uri: /cleverllamas/llamaverse/content/llama-movement/llama_location_history.json
total movement rows: 3360
distinct llama IDs: 20
aaron rows: 168
lon min/max: -9.9234931 / 4.329025
lat min/max: 52.073568 / 53.5144999

This gives you concrete volume and spread information to drive index choices.

Geospatial Index Families (What They Actually Mean)

Index familyModelsBest-fit questionsTypical risk if misused
Point indexesSingle location per logical recordNear, within radius, point-in-region filtersCannot correctly answer area overlap/containment between boundaries
Region indexesSpatial extent (polygon/line/box/circle)Contains, intersects, overlaps, covers, covered-byHigher cost for simple nearest/within point workloads

A fast mental model:

  1. "Where is it?" usually means point index.
  2. "What area does it cover?" usually means region index.
  3. If you need both, configure both deliberately.

Query Cost Characteristics

Index selection is not just about correctness. It also determines how quickly you can answer the question under load.

Query styleTypical geometry costTypical index fit
Point to circle / point to polygon filterLow to mediumPoint index
Region contains pointMediumRegion index (or point + region query, depending on source model)
Region overlaps regionHighRegion index
Nearest N pointsLowPoint index

If your p95 response time degrades rapidly as polygon complexity increases, that is usually a sign that region semantics are being applied too broadly in paths that should remain point-first.

Data shapeExample sourceRecommended indexCoordinate systemWhy
Point-like location docs (lat + lon)Derived movement point documents, farm location docsGeospatial JSON Property Pair Index (lat, lon)wgs84Fast near/within queries and simple operational tuning
GeoJSON-like [lon, lat] coordinate arrays/cleverllamas/llamaverse/content/llama-movement/llama_location_history.jsonGeospatial Path Index on coordinate pathwgs84/long-lat-pointUses source ordering directly with no transform
Polygon boundariesGrazing zones, farm boundaries, service territoriesGeospatial Region Path Index on boundary pathwgs84/long-lat-point for GeoJSONRequired for contains/intersects/overlaps area logic
Mixed workloads (point + region)Point telemetry + region geofencesBoth point and region indexesMatch each sourceKeeps near queries and containment queries both efficient

The key design idea is to match each query family to the cheapest correct index, not force one index type to do all jobs.

Point Index Deep Dive

Point indexes are ideal when each document has one location and queries are mostly "near/within".

The examples below use a point-like farm model with lat/lon properties.

{
  "name": "Sunny Llama Ranch",
  "region": "Connaught",
  "lat": 53.7946,
  "lon": -9.0143
}
xquery version "1.0-ml";

(: Demonstrates point-in-circle logic equivalent to indexed geospatial query  :)
(: semantics, but using a compact in-memory sample to stay runnable in any    :)
(: environment.                                                                :)
let $farms := (
  <farm name="Sunny Llama Ranch" lat="53.7946" lon="-9.0143"/>,
  <farm name="Misty Valley Farm" lat="53.9500" lon="-8.6500"/>,
  <farm name="Northern Plains Farm" lat="54.6000" lon="-7.9000"/>
)
let $centre := cts:point(53.7500, -9.0500)
let $radius-miles := 20
for $farm in $farms
let $point := cts:point(
  xs:double($farm/@lat/fn:string()),
  xs:double($farm/@lon/fn:string())
)
let $miles := cts:distance($point, $centre)
where $miles le $radius-miles
return fn:string($farm/@name)
Sunny Llama Ranch

Use point indexes for:

  1. Search-result filtering by distance.
  2. Geofencing against circles/boxes/polygons.
  3. High-throughput telemetry lookups.

Avoid point-only modelling when business logic needs area-to-area relationships.

Rich Example: Two-Stage Geofence Query

A robust production pattern combines both index families:

  1. Use point logic to shortlist nearby candidates cheaply.
  2. Use region logic to confirm exact zone membership.
xquery version "1.0-ml";

import module namespace geo = "http://marklogic.com/geospatial"
  at "/MarkLogic/geospatial/geospatial.xqy";

(: Stage 1: point-style shortlist by distance.                               :)
(: Stage 2: region-style confirmation against grazing zone boundaries.       :)
let $query-point := cts:point(53.80, -9.00)
let $radius-miles := 20

let $farms := (
  <farm name="Sunny Llama Ranch" lat="53.7946" lon="-9.0143"/>,
  <farm name="Misty Valley Farm" lat="53.95" lon="-8.65"/>,
  <farm name="Northern Plains Farm" lat="54.60" lon="-7.90"/>
)

let $zones := (
  <zone name="West Grazing Zone" points="53.77,-9.10 53.77,-8.90 53.86,-8.90 53.86,-9.10 53.77,-9.10"/>,
  <zone name="North-East Grazing Zone" points="54.30,-8.30 54.30,-7.70 54.75,-7.70 54.75,-8.30 54.30,-8.30"/>
)

let $nearby :=
  for $farm in $farms
  let $pt := cts:point(xs:double($farm/@lat), xs:double($farm/@lon))
  let $dist := cts:distance($query-point, $pt)
  where $dist le $radius-miles
  return <candidate name="{fn:string($farm/@name)}" miles="{fn:round($dist * 100) div 100}"/>

let $containing-zones :=
  for $zone in $zones
  let $vertices :=
    for $pair in fn:tokenize(fn:string($zone/@points), "\s+")
    let $parts := fn:tokenize($pair, ",")
    return cts:point(xs:double($parts[1]), xs:double($parts[2]))
  let $polygon := cts:polygon($vertices)
  where geo:contains($polygon, $query-point)
  return fn:string($zone/@name)

return
  <result>
    <query-point lat="53.80" lon="-9.00"/>
    <nearby-farms>{ $nearby }</nearby-farms>
    <containing-zones>{ for $z in $containing-zones return <zone>{ $z }</zone> }</containing-zones>
  </result>
<result>
  <query-point lat="53.80" lon="-9.00"/>
  <nearby-farms>
    <candidate name="Sunny Llama Ranch" miles="0.69"/>
    <candidate name="Misty Valley Farm" miles="17.67"/>
  </nearby-farms>
  <containing-zones>
    <zone>West Grazing Zone</zone>
  </containing-zones>
</result>

This keeps latency predictable while preserving geometry correctness.

Region Index Deep Dive

Region indexes are for boundary logic: zone containment, overlap, and intersection semantics.

{
  "name": "Western Grazing Zone",
  "managed_by": "Connaught Llama Authority",
  "boundary": {
    "type": "Polygon",
    "coordinates": [[
      [-9.10, 53.75],
      [-8.90, 53.75],
      [-8.90, 53.85],
      [-9.10, 53.85],
      [-9.10, 53.75]
    ]]
  }
}
xquery version "1.0-ml";

import module namespace geo = "http://marklogic.com/geospatial"
  at "/MarkLogic/geospatial/geospatial.xqy";

(: Demonstrates region containment semantics with a representative boundary.   :)
let $western-zone := cts:polygon((
  cts:point(53.77, -9.10),
  cts:point(53.77, -8.90),
  cts:point(53.86, -8.90),
  cts:point(53.86, -9.10),
  cts:point(53.77, -9.10)
))
let $query-point := cts:point(53.80, -9.00)
return
  if (geo:contains($western-zone, $query-point))
  then "Western Grazing Zone"
  else "No containing zone"
Western Grazing Zone

Core region relationship operators:

OperatorMeaning
containsIndexed region contains query geometry
covered-byIndexed region is fully covered by query geometry
coversIndexed region fully covers query geometry
intersectsIndexed region shares any area with query geometry
overlapsIndexed region overlaps without full containment
withinIndexed region lies entirely within query geometry

Region indexes cost more than point indexes because polygon math is heavier. Use them where the question requires geometry semantics, not where distance-to-point logic is enough.

Rich Example: Operator Fixtures You Can Trust

When teams debate geospatial operators, ambiguity causes regressions. Keep a tiny fixture set with known expected outcomes.

xquery version "1.0-ml";

import module namespace geo = "http://marklogic.com/geospatial"
  at "/MarkLogic/geospatial/geospatial.xqy";

let $zone-a := cts:polygon((
  cts:point(53.70, -9.20),
  cts:point(53.70, -8.90),
  cts:point(53.92, -8.90),
  cts:point(53.92, -9.20),
  cts:point(53.70, -9.20)
))

let $zone-b := cts:polygon((
  cts:point(53.80, -9.05),
  cts:point(53.80, -8.70),
  cts:point(54.02, -8.70),
  cts:point(54.02, -9.05),
  cts:point(53.80, -9.05)
))

let $query-point := cts:point(53.84, -8.98)

let $a-contains-point := geo:contains($zone-a, $query-point)
let $b-contains-point := geo:contains($zone-b, $query-point)
let $zones-intersect := geo:intersects($zone-a, $zone-b)
let $a-contains-b := geo:contains($zone-a, $zone-b)
let $b-contains-a := geo:contains($zone-b, $zone-a)

return (
  fn:concat("A contains query point: ", $a-contains-point),
  fn:concat("B contains query point: ", $b-contains-point),
  fn:concat("A intersects B: ", $zones-intersect),
  fn:concat("A contains B: ", $a-contains-b),
  fn:concat("B contains A: ", $b-contains-a)
)
A contains query point: true
B contains query point: true
A intersects B: true
A contains B: false
B contains A: false

This gives you a deterministic correctness harness before rolling operator changes into production.

Coordinate Systems and the Silent GeoJSON Bug

MarkLogic default wgs84 expects (lat, lon). GeoJSON stores (lon, lat). Mixing these two is the single most common geospatial production bug.

xquery version "1.0-ml";

(: The coordinate ordering gotcha: wgs84 expects (lat, lon), but GeoJSON      :)
(: stores coordinates as [lon, lat].                                          :)
(:                                                                            :)
(: With wgs84 (lat-first), this point is in western Ireland:                 :)
let $correct := cts:point(53.7946, -9.0143)

(: With wgs84/long-lat-point (lon-first), the same numbers become a point    :)
(: in Russia — a completely different location.                               :)
let $wrong := cts:point(-9.0143, 53.7946)   (: This is (lat=-9, lon=53) :)

return (
  "lat-first (wgs84):        " || fn:string($correct),
  "lon-first (long-lat):     " || fn:string($wrong)
)
lat-first (wgs84):        53.7946,-9.0143
lon-first (long-lat):     -9.0143,53.7946

Now with live llamaverse movement coordinates:

xquery version "1.0-ml";

let $first :=
  fn:doc('/cleverllamas/llamaverse/content/llama-movement/llama_location_history.json')
  /*:envelope/*:instance/*:llama-movement[1]
let $lon := xs:double($first/*:coordinates[1]/fn:string())
let $lat := xs:double($first/*:coordinates[2]/fn:string())
let $centre := cts:point(53.5138, -9.9228)
let $correct := cts:point($lat, $lon)
let $swapped := cts:point($lon, $lat)
return (
  "source lon,lat: " || $lon || "," || $lat,
  "distance with correct order (miles): " || cts:distance($correct, $centre),
  "distance with swapped order (miles): " || cts:distance($swapped, $centre)
)
source lon,lat: -9.9234084,53.5137279
distance with correct order (miles): 0.0255778956432181
distance with swapped order (miles): 5720.9905072203

Swapping coordinate order turns a local movement point into a location thousands of miles away. No syntax error warns you.

Coordinate-system mapping:

Stored orderingCoordinate system
(lat, lon)wgs84
(lon, lat) GeoJSONwgs84/long-lat-point
(lat, lon) with double precisionwgs84/double
(lon, lat) with double precisionwgs84/double/long-lat-point

Operational guardrails that prevent this class of bug:

  1. Add ingestion-time assertions for coordinate ranges and ordering expectations.
  2. Keep one canary query in CI that checks a known point and known expected distance.
  3. Include coordinate-order metadata in source contracts so feed changes are not silent.

Llamaverse Movement Analysis Example

Before introducing indexes, this analytical pass validates spatial coherence for one tracked llama.

xquery version "1.0-ml";

let $uri := "/cleverllamas/llamaverse/content/llama-movement/llama_location_history.json"
let $records :=
  fn:doc($uri)/*:envelope/*:instance/*:llama-movement[*:llamaId = "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5"]
let $centre := cts:point(53.5138, -9.9228)
let $hits :=
  for $r in $records
  let $lon := xs:double($r/*:coordinates[1]/fn:string())
  let $lat := xs:double($r/*:coordinates[2]/fn:string())
  let $point := cts:point($lat, $lon)
  let $miles := cts:distance($point, $centre)
  where $miles le 0.25
  return $miles
return (
  "aaron total rows: " || fn:count($records),
  "rows within 0.25 miles of centre: " || fn:count($hits),
  "closest miles: " || fn:min($hits),
  "furthest miles within window: " || fn:max($hits)
)
aaron total rows: 168
rows within 0.25 miles of centre: 168
closest miles: 0.00395853094506708
furthest miles within window: 0.0637264530896653

This kind of profile helps choose sensible geofence radius values and detect outlier points early.

Visual Query Walkthroughs

Spatial bugs are often easier to detect visually than by reading query text alone. Two lightweight graphics usually provide the highest value:

  1. Point-versus-region query flow diagram for architecture conversations.
  2. Coordinate-order impact diagram for onboarding and incident reviews.
  3. Region operator fixture map for query correctness discussions.
  4. Rollout workflow diagram for delivery and operations planning.

Point versus region query flow

Coordinate-order impact

Region operator fixture map

Geospatial rollout workflow

For production environments, pair each graphic with one runnable query and one expected result file. That keeps diagrams tied to executable truth rather than becoming static documentation drift.

End-to-End Design Decision Matrix

Query intentCorrect index strategyCoordinate guidance
Find llama events near a pointPoint index (pair or path geospatial point)Use source ordering consistently
Find farms inside a polygonPoint index + polygon region filterMatch index coordinate system to source order
Find which zones cover this eventRegion index on zone boundaryGeoJSON zones usually need wgs84/long-lat-point
Find overlapping service territoriesRegion indexValidate overlap semantics with known fixtures
Mix telemetry + geofencesBoth point and region indexesKeep both pipelines explicit

Design Checklist Before Production Deployment

  1. Do all coordinate sources have explicit ordering documentation?
  2. Is each query path mapped to a known index family?
  3. Do you have fixture-based operator tests for region semantics?
  4. Is there one visual artefact that makes the query path obvious to non-specialists?
  5. Can on-call engineers run one fast sanity query during incidents?

Configuration and Rollout Strategy

Geospatial indexes are configured through Admin UI or the Admin XQuery module. The Management REST API does not configure geospatial indexes directly.

Admin UI paths:

Index typeAdmin UI path
Geospatial JSON Property PairAdmin -> Databases -> db -> Geospatial JSON Property Pair Indexes
Geospatial Region PathAdmin -> Databases -> db -> Geospatial Region Path Indexes
Geospatial Path IndexesAdmin -> Databases -> db -> Geospatial Path Indexes
Geospatial Element PairAdmin -> Databases -> db -> Geospatial Element Pair Indexes

Rollout guidance:

  1. Add indexes in low-traffic windows.
  2. Monitor reindex completion before benchmarking.
  3. Validate with known-good fixtures before releasing query features.
  4. Document coordinate assumptions next to ingestion logic.

Performance Deep Dive

WorkloadTypical winnerWhy
High-volume near queriesPoint indexCheaper geometry checks
Region containment/overlap analyticsRegion indexCorrect geometry semantics
Mixed dashboard + boundary checksDual index strategyAvoids overloading one index family

Performance anti-patterns:

  1. Running region overlap logic against centroid points.
  2. Overusing region indexes for simple nearest-point lookups.
  3. Deferring coordinate-system validation until after production rollout.

Practical benchmarking pattern:

  1. Benchmark point-only path with fixed radius and growing candidate count.
  2. Benchmark region-only path with growing polygon complexity.
  3. Benchmark combined two-stage path and track shortlist size sensitivity.

This usually reveals that combined paths produce the best correctness-to-latency ratio for mixed workloads.

Troubleshooting Matrix

SymptomLikely causeFast checkFix
Empty results for known nearby pointsCoordinate-system mismatchCompare one known point with both orderingsSwitch to wgs84/long-lat-point for GeoJSON
Results in wrong geographySwapped lon/latRun a distance sanity query against known centreCorrect ingestion transform or index config
Region query errors about missing indexRegion path index absentCheck database index configAdd required region index and reindex
Point queries slow under loadRegion index used for point-only workloadReview query/index pairingAdd point index and route query accordingly
Correctness drifts after data feed changeUpstream coordinate format changedProfile incoming sample payloadsVersion and validate ingestion mapping

Incident Triage Sequence

When geospatial incidents occur, use this order:

  1. Validate coordinate ordering on one known record.
  2. Validate index presence and coordinate-system settings.
  3. Re-run fixture queries with known expected answers.
  4. Compare point-only and region-only paths to isolate the failing stage.

Final Takeaways

High-quality MarkLogic geospatial systems come from three explicit decisions:

  1. Pick the correct index family for the question (point vs region).
  2. Lock coordinate-ordering assumptions early and enforce them at ingestion.
  3. Validate with real dataset fixtures (like llamaverse movement history) before deploying to production.

Get these right and geospatial search becomes a reliable strength, not an intermittent production incident source.

Get them wrong and your incident channel learns far more about coordinate systems than anyone wanted.

Need Some Help?


Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!

Scope Note: Reverse Geospatial Queries

This article focuses on forward geospatial queries — finding points within a region or regions containing a point. Reverse geospatial queries (also called "inverse" or "containment queries") are implemented at varying levels of completeness across MarkLogic versions and depend heavily on your spatial data structures and use cases.

If you need to run reverse geospatial queries, evaluate them carefully with your MarkLogic version and dataset before committing to production. Different approaches work best for different query patterns and data distributions.

Working with Reverse Geospatial Queries

If you're evaluating reverse geospatial approaches or running into unexpected behaviour with containment queries, reach out to us at info@cleverllamas.com — we can help you architect a solution tailored to your data and query patterns.