Requesting WMS GetFeatureInfo and Parsing the Response in Python

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.

The Core Challenge: The Pixel Offset Has No Meaning on Its Own

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.

Two parameters were renamed; everything else stayed A six-row grid comparing GetFeatureInfo parameters between WMS 1.1.1 and 1.3.0. Only the pixel coordinate parameters changed name, from X and Y to I and J. The layers to query, the feature count limit, the response format parameter and the top-left pixel origin are identical in both versions. Parameter WMS 1.1.1 WMS 1.3.0 Pixel column X I Pixel row Y J Layers to query QUERY_LAYERS QUERY_LAYERS Result limit FEATURE_COUNT FEATURE_COUNT Response format INFO_FORMAT INFO_FORMAT Origin of I/J top-left of the image top-left of the image

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.

Production-Ready Code

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 []

Step-by-Step Walkthrough

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.

GetFeatureInfo is GetMap plus three parameters A five-stage chain. A click gives screen coordinates; the request repeats the exact bounding box, image size, reference system and layer list of the GetMap that produced the visible image; the pixel offset is added as I and J; QUERY_LAYERS names the subset that is actually queryable; and INFO_FORMAT negotiates the response encoding, preferring JSON when the service advertises it. Map click screen x, y Same GetMap params bbox, size, CRS, layers Add I / J pixel offset in that image QUERY_LAYERS must be queryable INFO_FORMAT json if advertised browser identical integers subset

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.

Verification

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.

Gotchas & Edge Cases

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.

Four reasons a visible feature is not identifiable A decision diamond for an empty GetFeatureInfo response over a visibly rendered feature. A bounding box that differs from the originating GetMap makes the pixel offset point somewhere else entirely; a layer whose capabilities entry is not marked queryable never returns attributes; sending X and Y to a 1.3.0 service means the pixel offset is missing; and a thin line or small point needs a pixel tolerance buffer to be hit at all. GetFeatureInfo returns an empty result over a feature you can see. Why? Reuse the exact GetMap bbox I and J are relative to that image Check queryable='1' styling does not imply queryability X/Y vs I/J 1.3.0 ignores X and Y Raise the vendor buffer a thin line needs pixels of tolerance bbox differs layer not queryable wrong version params buffer too small

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.

Frequently Asked Questions

Why does GetFeatureInfo return nothing when the feature is clearly visible?

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.

Which INFO_FORMAT should I request?

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.

Is FEATURE_COUNT limited?

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.

Can I call GetFeatureInfo without having called GetMap?

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