Skip to content

Geo

Try the interactive example ↓

A spatial index for "where is everything right now": you push entity positions into an index, then ask radius, bounding-box, and distance-between questions over the current snapshot. Geofences - circle and polygon shapes with an on-demand containment check - live on the same index.

When to reach for it: fleet and courier tracking, "show me drivers within 2 km", delivery and service-area checks, proximity matching, any feature that answers a question about current positions.

When not to: whether an entity is still alive and reporting belongs to Liveness; a durable history of where something has been belongs to Timeline - geo keeps only the latest position per entity and overwrites the previous one.

Concepts

  • The index is the unit of ownership. Entities, fences, and queries hang off one index id, and only its creator can touch it - everyone else, platform admin tokens included, gets 403 unauthorized. There is no sharing mechanism, and the owner reads every position in the index, so an index of end users' locations belongs behind your own backend rather than in front of per-user tokens.
  • Positions are upserts under an id you choose. update-location creates or replaces; there is no separate create call.
  • Longitude comes first, and storage is lossy. Coordinates are stored as a geohash, so what you read back differs from what you wrote by well under a metre.
  • unit governs the whole query, input and output. Despite the names, radius_meters is read in the unit you asked for and distance_meters is reported in it.
  • Deactivating an index locks it. With active: false every operation except listing, PATCH, and DELETE returns 400 index_inactive.

Create an index

snug geo create --name fleet --description "Fleet tracking index"
snug geo create --name fleet \
  --max-radius-meters 50000 --max-limit 1000 \
  --max-entities 100000 --entity-ttl-seconds 86400

snug --output json geo list --limit 5
snug geo update --id <index-id> --max-radius-meters 25000
snug geo delete-index --id <index-id> --force

Those four knobs are the only per-index tuning and all are optional. max_radius_meters (50000) bounds a radius query, max_limit (1000) is the deepest position a spatial query pages down to, and max_entities / entity_ttl_seconds are unset - unlimited and forever. delete-index drops the index and all its positions, but not its geofences, which survive as orphans; delete those first. list speaks the standard list contract, scoped to indexes you own: q over the name, filters on name, active, entity_count, created_at, and updated_at (epoch milliseconds), and sorts on created_at, updated_at, and entity_count. list-fences is the same within one index, filtering on name, shape, active, created_at, and updated_at.

Record positions

snug geo update-location --id <index-id> -e driver1 \
  -x -122.4194 -y 37.7749 -m '{"status":"available","vehicle":"van"}'

snug geo batch-update-location --id <index-id> --file positions.json
snug geo get-entity --id <index-id> -e driver1
snug geo list-entities --id <index-id> --limit 100
snug geo delete-entity --id <index-id> -e driver1

list-entities pages through the index in geohash order on the standard list contract (--limit up to 100, --cursor, --all, --total); the index's max_limit bounds radius and box queries, not it.

The CLI's --file takes a bare JSON array of {entity_id, longitude, latitude, metadata?}; the HTTP endpoint wraps the same array in {"entities": [...]}. A batch over 1000 entries is rejected by request validation, and one over the index's max_limit with 400 batch_too_large.

An upsert replaces the whole record, so omitting metadata on a later write clears what was there. Resend it on every position update, or keep it somewhere movement does not overwrite:

snug geo update-location --id <index-id> -e driver1 -x -122.42 -y 37.775
snug --output json geo get-entity --id <index-id> -e driver1
# -> "metadata": {}

Query

snug geo query-radius --id <index-id> -x -122.4194 -y 37.7749 -r 2000 -d -c
snug geo query-bbox --id <index-id> \
  --min-lon -122.45 --min-lat 37.70 --max-lon -122.35 --max-lat 37.80 -c
snug geo distance --id <index-id> --from driver1 --to driver4 -u km
snug geo stats --id <index-id>

Radius results come back nearest-first:

curl -s -H "Authorization: Bearer $SNUG_API_TOKEN" \
  "http://localhost:4000/api/v1/geo/$INDEX/query/radius?longitude=-122.4194&latitude=37.7749&radius_meters=2000&limit=2"
{
  "status": 200,
  "msg": "OK",
  "data": {
    "entities": [
      {
        "entity_id": "docs4-driver1",
        "longitude": -122.41940170526505,
        "latitude": 37.77490001056578,
        "distance_meters": 0.1499,
        "metadata": { "status": "available", "vehicle": "van" },
        "last_updated": 1788007921
      },
      {
        "entity_id": "docs4-driver2",
        "longitude": -122.40889817476273,
        "latitude": 37.78370056243101,
        "distance_meters": 1345.5461,
        "metadata": { "status": "busy" },
        "last_updated": 1788007622
      }
    ],
    "query_center": { "longitude": -122.4194, "latitude": 37.7749 },
    "query_bounds": null,
    "page": { "has_more": false },
    "timestamp": 1788007921
  }
}

query_center is populated for radius queries and query_bounds for bounding-box queries; the other is null. Both queries speak the standard list contract as positional pages: nearest-first (a box orders by distance from its centre), limit rows at a time (default 25, max 100), with page.next_cursor or page=N reaching the next page. There are no filters, sorts, or totals - distance is the order. Note driver1: written at exactly -122.4194, 37.7749, it reports 0.1499 metres from a query centred on itself. That drift is the geohash resolution - never test a position you just wrote for exact equality. distance between two stored entities is the one call that names its unit honestly, returning {"distance": 13.4419, "unit": "km"}.

Behaviors and gotchas

  • The CLI hides coordinates unless you ask. Without -c/-d, query-radius and query-bbox send with_coordinates=false and with_distance=false, and the fields come back as longitude: 0.0, latitude: 0.0, distance_meters: null - zero being a real coordinate, this reads as the Gulf of Guinea, not as missing data. The HTTP API defaults both to true; only the CLI opts out. Pass -d -c whenever you want positions back.
  • Paging stops at max_limit, radius_meters does not clamp. An index whose max_limit is 1000 pages through at most the nearest 1000 matches (the last page reports has_more: false), but a radius over the index cap is 400 invalid_radius.
  • Timestamps use two scales. Entity last_updated and the query timestamp are epoch seconds; index created_at/updated_at and the stats last_update are epoch milliseconds.
  • stats never computes bounds - always null - and last_update is the index manifest's timestamp, which entity writes do not move. Only its entity_count is counted live.
  • TTL and caps are enforced on write. With entity_ttl_seconds: 3 an entity was gone from list-entities and 404 entity_not_found five seconds later; with max_entities: 2, the third entity failed with 400 max_entities_reached.
  • A deactivated index is not readable. After PATCH {"active": false}, even GET /api/v1/geo/{id} returns 400 index_inactive. Only list (which still shows it), another PATCH, and DELETE work, so PATCH {"active": true} is the way back.
  • Bad geometry is rejected in the envelope: latitude outside [-90, 90] or longitude outside [-180, 180] is 400 invalid_coordinates, an unknown unit is 400 invalid_unit, and a box whose min is not strictly below its max is 400 invalid_bounding_box.

Geofences

Circle and polygon shapes and the containment check have their own page: Geofences.

Limits and configuration

Every limit is per-index, set through config at create or update time. The service reads no GEO_* environment variables at all.

Reference

  • Geo API - every endpoint, callable
  • Related: Liveness for whether a tracked entity is still reporting, Timeline for position history, KV Store for their non-spatial attributes

Interactive example

Store locations, find nearby places, and check whether a point is inside a boundary. Use these queries for delivery zones, proximity features, or maps.

An illustrative example that runs in your browser. No account or API calls required.