GetFeatureInfo is GetMap with three extra parameters: the pixel offset (I/J in WMS 1.3.0, X/Y in 1.1.1), QUERY_LAYERS, and INFO_FORMAT. Every other parameter — bounding box, image size, reference system, layer list — must be byte-identical to the request that produced the image the user clicked on, because the pixel offset is meaningless otherwise.
A GetFeatureInfo request does not ask “what is at this coordinate”. It asks “what is at this pixel of the image you would produce for these exact parameters”. The server re-renders — or at least re-computes — the same view, then maps the pixel back to a ground position using the bounding box and image dimensions supplied in the request.
That is why reusing the originating GetMap parameters is not a convenience but a correctness requirement. A client that recomputes the bounding box, or rounds the width, or sends a different reference system, is asking about a different image, and the answer it gets back is about a different place on the ground. The renaming of X/Y to I/J in WMS 1.3.0 compounds this: a 1.3.0 service receiving X and Y does not error, it simply has no pixel offset and typically returns nothing.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from xml.etree import ElementTree as ET
import requests
@dataclass(frozen=True)
class MapView:
"""Exactly the parameters that produced the image on screen."""
base_url: str
layers: tuple[str, ...]
crs: str
bbox: tuple[float, float, float, float]
width: int
height: int
version: str = "1.3.0"
def get_map_params(self) -> dict[str, str]:
key = "CRS" if self.version.startswith("1.3") else "SRS"
return {
"SERVICE": "WMS", "VERSION": self.version, "REQUEST": "GetMap",
"LAYERS": ",".join(self.layers), "STYLES": "",
key: self.crs, "BBOX": ",".join(f"{v:.10g}" for v in self.bbox),
"WIDTH": str(self.width), "HEIGHT": str(self.height),
"FORMAT": "image/png", "TRANSPARENT": "true",
}
@dataclass
class InfoResult:
layer: str
properties: dict[str, Any] = field(default_factory=dict)
def feature_info(view: MapView, i: int, j: int, query_layers: tuple[str, ...],
info_format: str = "application/json",
feature_count: int = 10, buffer_px: int = 5) -> list[InfoResult]:
"""Identify features under a pixel of the image `view` describes.
Every GetMap parameter is reused verbatim: the server resolves I and J
against the bounding box and size in this same request, so any change
silently moves the question to a different ground position.
"""
if not (0 <= i < view.width and 0 <= j < view.height):
raise ValueError(f"pixel ({i},{j}) is outside the {view.width}x{view.height} image")
params = view.get_map_params()
params["REQUEST"] = "GetFeatureInfo"
params["QUERY_LAYERS"] = ",".join(query_layers)
params["INFO_FORMAT"] = info_format
params["FEATURE_COUNT"] = str(feature_count)
if view.version.startswith("1.3"):
params["I"], params["J"] = str(i), str(j)
else:
params["X"], params["Y"] = str(i), str(j)
if buffer_px:
params["BUFFER"] = str(buffer_px) # vendor: pixel tolerance
resp = requests.get(view.base_url, params=params, timeout=30)
resp.raise_for_status()
return _parse(resp.headers.get("content-type", ""), resp.content, query_layers)
def _parse(content_type: str, body: bytes, layers: tuple[str, ...]) -> list[InfoResult]:
"""Normalise whichever INFO_FORMAT the server actually returned.
Servers routinely ignore the requested format and answer in their
default, so dispatch on the response content type, not the request.
"""
if "json" in content_type:
payload = json.loads(body)
return [
InfoResult(layer=str(f.get("id", "")).rsplit(".", 1)[0] or layers[0],
properties=dict(f.get("properties") or {}))
for f in payload.get("features", [])
]
if "xml" in content_type or "gml" in content_type:
root = ET.fromstring(body)
results: list[InfoResult] = []
for member in root.iter():
tag = member.tag.rsplit("}", 1)[-1]
if tag not in {"featureMember", "member"}:
continue
for feature in member:
results.append(InfoResult(
layer=feature.tag.rsplit("}", 1)[-1],
properties={c.tag.rsplit("}", 1)[-1]: (c.text or "").strip()
for c in feature if len(c) == 0}))
return results
if "html" in content_type:
raise RuntimeError(
"server answered text/html — it does not support the requested "
"INFO_FORMAT; read the advertised formats from GetCapabilities")
raise RuntimeError(f"unhandled GetFeatureInfo content type: {content_type!r}")
def supported_info_formats(capabilities: bytes) -> list[str]:
"""The INFO_FORMAT values the service actually advertises."""
root = ET.fromstring(capabilities)
for op in root.iter():
if op.tag.rsplit("}", 1)[-1] == "GetFeatureInfo":
return [f.text for f in op.iter()
if f.tag.rsplit("}", 1)[-1] == "Format" and f.text]
return []
MapView exists so the parameters cannot drift. Modelling the visible view as one frozen value, and deriving both the GetMap and the GetFeatureInfo parameters from it, makes it structurally impossible for the two requests to disagree about the bounding box or the image size. Passing those values around individually is how the mismatch in the first branch of the diagnosis below happens.
Dispatch on the response content type, not the request. INFO_FORMAT is a request for a format, not a guarantee. A service that does not implement application/json frequently answers in text/html with a 200, and a client that calls json.loads on that gets a decode error rather than a useful message. Reading the advertised formats from capabilities first, and falling back to application/vnd.ogc.gml, is the portable path.
FEATURE_COUNT defaults to 1. The specification’s default returns a single feature even where several overlap the pixel, which on a layer of stacked administrative boundaries means the answer is arbitrary. Setting it explicitly is almost always what you want.
BUFFER is a vendor extension and worth using anyway. Hitting a one-pixel-wide line exactly is unreasonable to ask of a human with a mouse and impossible on a touch screen. GeoServer’s BUFFER and MapServer’s equivalent tolerance both widen the hit test by a pixel radius. Where portability matters, the fallback is to widen the query yourself with a small WFS bounding-box request instead.
Check the advertised formats first, then identify a known feature:
caps = requests.get(base, params={"SERVICE": "WMS", "REQUEST": "GetCapabilities"}).content
print(supported_info_formats(caps))
view = MapView(base, ("cadastre:parcels",), "EPSG:3857",
(949000, 6002000, 952000, 6005000), 512, 512)
for hit in feature_info(view, i=256, j=256, query_layers=("cadastre:parcels",)):
print(hit.layer, hit.properties)
['text/plain', 'application/vnd.ogc.gml', 'text/html', 'application/json']
cadastre:parcels {'parcel_id': '4471', 'owner': 'Kanton Zurich', 'area_m2': '1284.5'}
If the format list does not contain application/json, the parser’s content-type dispatch is what stops the run failing with a JSON decode error against an HTML table.
Queryable is a per-layer flag, not a service capability. A layer’s capabilities entry carries queryable="0" or "1", and a layer that renders beautifully may simply not be queryable — commonly the case for raster basemaps and for layer groups. Naming it in QUERY_LAYERS returns nothing rather than an error.
QUERY_LAYERS must be a subset of LAYERS. The specification requires it, and servers enforce it inconsistently: some return a LayerNotDefined exception, others silently return nothing. Deriving the query layers from the view’s layer list, rather than assembling them independently, avoids the whole question.
The response can be enormous. A click over a dense polygon layer with FEATURE_COUNT=50 returns fifty full feature representations, each with every attribute. Where the client only needs a couple of fields, propertyName — supported by GeoServer on GetFeatureInfo — trims the payload substantially.
Axis order applies here too. The BBOX in a GetFeatureInfo follows exactly the same rules as in GetMap, so a 1.3.0 request in EPSG:4326 needs latitude first. Deriving both requests from one MapView means the axis decision is made once, which is the pattern SRS and Coordinate Reference System Handling recommends generally.
The most common cause by far is a bounding box that does not match the GetMap that produced the image. The pixel offset is resolved against the bounding box in the same request, so a client that recomputes or rounds it is asking about a different patch of ground. The second most common cause is a layer that is rendered but not marked queryable in capabilities.
application/json when the service advertises it — it parses in one line and carries typed values. Fall back to application/vnd.ogc.gml, which every compliant WMS supports and which parses into the same structure with a little more work. Avoid text/html entirely: it is a presentation format, its structure varies per server, and scraping it breaks on the next upgrade.
By the server, and often to a low number. Requesting a thousand does not mean receiving a thousand, and no part of the response says the result was truncated. Where completeness matters, a WFS GetFeature with a small bounding-box intersection filter gives an authoritative count and a paged result.
Yes — nothing requires the image to have been fetched. What is required is that the parameters describe a coherent view: a bounding box, a size, a reference system and a pixel inside that image. Constructing the view synthetically is a perfectly good way to build a point-query API on top of a WMS that does not expose WFS.
Back to Understanding OGC Web Map Service Specifications
Related