Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

Geographic search

Articles that mention a city near a point, or inside a box. Each hit says which of its cities matched and, for a point, how far away they are.

/search, /stories and /top-headlines take the area, and so do their tools, search_news, list_stories and top_headlines. /aggregate does not. A watch can save one as part of its query.

Parameters

Name Type Description
near string A point, `latitude,longitude` in decimal degrees, e.g. 48.8566,2.3522. With radius_km: articles mentioning a city within that distance. On article results (/search, /top-headlines) each hit reports the cities that matched as matched_locations.
radius_km number With near: the distance in kilometres, 0.1-2000.
bbox string A box, `south,west,north,east` in decimal degrees: articles mentioning a city inside it. West greater than east crosses the antimeridian. Not with near.
  • A circle. near is latitude,longitude in decimal degrees, and it needs radius_km, from 0.1 to 2,000 km. Either one without the other is refused.
  • A box. bbox is south,west,north,east in decimal degrees. South must be below north. A west greater than east crosses the antimeridian.
  • One area per request. near and bbox together are refused.
  • Real coordinates. Latitudes run from -90 to 90 and longitudes from -180 to 180. A value that is not a number, or the wrong count of them, is refused.

Every refusal is 400 with code: invalid_parameter and a detail naming the rule, before any index work. Over MCP it is JSON-RPC -32602.

How matching works

Two steps: the index narrows, then every candidate is checked exactly.

  1. Cells narrow the candidates. Each city an article mentions is indexed as the geohash cells that contain it, at five precisions: two characters (about 1,250 by 625 km at the equator) down to six (about 1.2 by 0.6 km). A request covers its area with cells of the finest precision that needs at most 256 of them. Only articles holding one of those cells are candidates.
  2. Each city is checked. A cell is a rectangle, and its corners reach past a circle's edge or a box's side. So every city a candidate mentions is tested against the area itself: by great-circle (haversine) distance from the point for near, and by its latitude and longitude bounds for bbox. An article matches when at least one of its cities passes.

The cells decide how much the index reads, not what matches. A circle is covered as the box around it, and where it reaches a pole, as a band of every longitude. Neither changes the answer, because the distance check that follows is exact.

A mention is a mention. An article that names Rotterdam in passing matches a circle around Rotterdam, just as a city filter would. Combine the area with q, or with an event class, when the place has to be where the story happens.

matched_locations

Every article a geographic request returns carries matched_locations: the cities that passed. Under near they come nearest first, each with distance_km to one decimal. A box has no centre, so under bbox there is no distance, and the cities keep the order the article's location records list them in.

Field Type Description
country string Its canonical two-character country identifier.
distance_km number Kilometres from `near`, to one decimal; absent for a bbox.
latitude number Decimal degrees.
longitude number Decimal degrees.
name string The place as the article's location record names it.

Only the cities that matched are listed. The article's other places are still in cities and locations, and /doc/{id} has each one's coordinates under location_details. A story row on /stories is not an article and has no matched_locations. To see which cities matched within one story, pass its story_id and the same area to /search.

Cities only

The area is matched against the cities an article mentions: GDELT's location records of kinds 3 and 4, US and world cities. Regions and countries are left out, because their coordinates are centroids. A country's centroid is a point somewhere in its interior, not where anything in the article happened, so a box drawn around central France would otherwise match every article that mentions France. A city record without coordinates, or placed at 0,0, is not a place and is skipped too.

To filter by country or region, use the country and region filters, as before. An article that names only a country matches no area.

Across the antimeridian, and how large an area can be

A box whose west is greater than its east crosses the 180th meridian. bbox=-21,176,-15,-178 runs from 176°E eastward across 180° to 178°W, which takes in Fiji. The same box written with west below east would span 354 degrees of longitude the other way round the globe. A circle needs no special spelling: one centred at 179.9°E covers both sides of the line.

The covering may use at most 256 cells, at the coarsest precision if need be, where a cell is 11.25 degrees of longitude by 5.625 of latitude. Every radius up to 2,000 km fits. A large box may not: one that spans every longitude can be only about 40 degrees of latitude tall. An area that needs more is refused with invalid_parameter and "that area is too large to search: narrow the box or the radius". Split it into several requests, or filter by country instead.

Exactness and cost

Checking candidates one by one changes how the counts read, and what a call costs. How it happens depends on the query.

  • Keyword ranking, or no q, on /search and /top-headlines. The index reads candidates in batches of 500, up to 2,000, and checks each one against its document. total_relation is exact when the candidates ran out within the scan. Otherwise total is estimated from the share of scanned candidates that passed, and total_relation is approximate. Pages are cut from the matches the scan found, and has_more turns false once those run out, even when the estimated total is larger: narrow the window or the area to reach the rest.
  • Facets on those requests count candidates, which are cells rather than checked cities. Every facet is marked approximate: true.
  • credits_charged counts the batches: 1 credit for each batch of 500 candidates read, so 1 to 4 for the scan, plus whatever else the call needed, such as an entity_id. Across indexes a call is charged the most any one index needed.
  • Semantic and hybrid ranking check the ranked candidate set the same way. Their totals are approximate, as for any ranked query, and a geographic, event-class or amount filter adds 1 credit: the query that checks whether the index holds the field.
  • /stories checks the candidates it ranks and collapses, as it does for every filter. Its total is approximate, as always. A geographic, event-class or amount filter adds 1 credit: the query that checks whether the index holds the field.
  • Cursor paging cannot be combined with a geographic or amount filter, whose candidates are checked one at a time. cursor with near, bbox, amount_min or amount_max is refused with 400 invalid_parameter; page with offset.

Amount bounds are checked the same way. An event class is not: the index applies it directly, and it needs no scan. Pagination and facets covers totals and facets in general, and Credits and overage lists what every call costs.

While the index is rebuilt: field_unavailable

The cells are derived from location records every article already carries, so no article has to be fetched or ingested again. Each index gains them when its splits are next rebuilt. Until then, the part of the index built without them cannot apply a geographic filter. The same goes for event classes and amount bounds.

That part answers without the filter's results, and the response says so: partial: true and coverage: field_unavailable. The results you got are right. They cover only the rebuilt part of the window, so there may be more in the rest. Retrying will not help until the rebuild reaches it. Narrow the window to months that answer completely, or report the answer as covering part of the index. When several indexes answer, the value survives the merge even if every one of them responded.

Example

# Port strike coverage that mentions a city within 50 km of Rotterdam.
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
  --data-urlencode "q=port strike" \
  --data-urlencode "mode=keyword" \
  --data-urlencode "near=51.9244,4.4777" \
  --data-urlencode "radius_km=50" \
  --data-urlencode "from=2026-09-01" \
  --data-urlencode "limit=5" \
  | jq '{total, total_relation, credits_charged, partial, coverage,
         hits: [.results[] | {title, matched_locations}]}'
{
  "total": 38,
  "total_relation": "exact",
  "credits_charged": 1,
  "partial": false,
  "coverage": "complete",
  "hits": [
    {
      "title": "Dockworkers walk out at Europe's largest port",
      "matched_locations": [
        { "name": "Rotterdam, Zuid-Holland, Netherlands", "country": "NL", "latitude": 51.9225, "longitude": 4.47917, "distance_km": 0.2 },
        { "name": "Vlaardingen, Zuid-Holland, Netherlands", "country": "NL", "latitude": 51.9125, "longitude": 4.34167, "distance_km": 9.4 }
      ]
    }
  ]
}

The figures are illustrative. Fewer than 500 candidates, so one batch checked them all, the total is exact, and the call cost 1 credit.

Over MCP

The arguments are the same. near is a string and radius_km a number. For what happened near a place, ask list_stories:

{
  "name": "list_stories",
  "arguments": {
    "q": "port strike",
    "near": "51.9244,4.4777",
    "radius_km": 50,
    "from": "2026-09-01"
  }
}

For the articles themselves, with matched_locations on each, ask search_news with the same area, or with a box:

{
  "name": "search_news",
  "arguments": {
    "q": "cyclone",
    "bbox": "-21,176,-15,-178",
    "from": "2026-09-01",
    "view": "full"
  }
}