A schema-valid SLD that produces an empty tile is not a rendering bug, it is a selection bug, and it has exactly five possible causes. Work them in order: does the layer resolve, does the extent contain features, is the rule inside the scale window, does the filter match, does the symbolizer paint. Each has a one-request test, and the answer narrows the search by half.
A WMS returns a fully transparent PNG with an HTTP 200 and no headers indicating that zero features were drawn. The ServiceException mechanism exists for malformed requests, not for requests that were perfectly well-formed and simply selected nothing. Neither schema validation nor style upload catches it, because neither has any knowledge of the layer’s actual attributes.
The consequence is that a styling regression can ship silently and stay live until a human looks at the map. That is what makes the render assertion worth automating — but when it does fire, you need a method rather than a hunch, because reading an SLD document top to bottom is a poor way to find out which of five independent conditions failed.
The probe below walks the five gates in order and stops at the first one that fails, reporting which it was.
from __future__ import annotations
import io
from dataclasses import dataclass
from xml.etree import ElementTree as ET
import requests
from PIL import Image
WMS_NS = {"wms": "http://www.opengis.net/wms"}
XSD_NS = {"xsd": "http://www.w3.org/2001/XMLSchema"}
@dataclass
class Verdict:
gate: str
passed: bool
detail: str
def __str__(self) -> str:
mark = "ok " if self.passed else "FAIL"
return f"[{mark}] {self.gate}: {self.detail}"
def _getmap(base: str, layer: str, bbox: str, style: str = "",
size: int = 256) -> Image.Image:
resp = requests.get(base, timeout=30, params={
"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
"LAYERS": layer, "STYLES": style, "CRS": "EPSG:3857", "BBOX": bbox,
"WIDTH": str(size), "HEIGHT": str(size),
"FORMAT": "image/png", "TRANSPARENT": "true",
})
resp.raise_for_status()
if resp.headers.get("content-type", "").startswith("text/xml"):
raise RuntimeError(f"service exception: {resp.text[:400]}")
return Image.open(io.BytesIO(resp.content)).convert("RGBA")
def _paints(img: Image.Image) -> bool:
"""True when at least one pixel is not fully transparent."""
alpha = img.getchannel("A")
return alpha.getextrema()[1] > 0
def attribute_names(base: str, layer: str) -> list[str]:
"""Exact attribute names, in the case the data store advertises them."""
resp = requests.get(base.replace("/wms", "/wfs"), timeout=30, params={
"SERVICE": "WFS", "VERSION": "2.0.0",
"REQUEST": "DescribeFeatureType", "typeNames": layer,
})
resp.raise_for_status()
root = ET.fromstring(resp.content)
return [el.get("name") for el in root.iter(f"{{{XSD_NS['xsd']}}}element")
if el.get("name") and el.get("type")]
def feature_count(base: str, layer: str, bbox: str) -> int:
resp = requests.get(base.replace("/wms", "/wfs"), timeout=30, params={
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
"typeNames": layer, "count": "1", "bbox": f"{bbox},EPSG:3857",
"resultType": "hits",
})
resp.raise_for_status()
root = ET.fromstring(resp.content)
return int(root.get("numberMatched", "0"))
def diagnose(base: str, layer: str, style: str, bbox: str) -> list[Verdict]:
"""Walk the five gates, stopping at the first failure."""
out: list[Verdict] = []
# Gate 1 — does the layer render at all under the server default style?
default = _getmap(base, layer, bbox, style="")
if not _paints(default):
out.append(Verdict("layer/data", False,
"server default style is also blank — not a style problem"))
matched = feature_count(base, layer, bbox)
out.append(Verdict("features in extent", matched > 0,
f"{matched} feature(s) intersect this bbox"))
return out
out.append(Verdict("layer/data", True, "default style renders — the style is at fault"))
# Gate 2 — does the named style render anywhere in this extent?
styled = _getmap(base, layer, bbox, style=style)
if _paints(styled):
out.append(Verdict("style", True, "style renders here — check the failing extent"))
return out
out.append(Verdict("style", False, f"style '{style}' paints nothing at this scale"))
# Gate 3 — is it the scale window? Re-request at 4x the extent.
west, south, east, north = (float(v) for v in bbox.split(","))
dx, dy = (east - west), (north - south)
wide = f"{west - dx},{south - dy},{east + dx},{north + dy}"
if _paints(_getmap(base, layer, wide, style=style)):
out.append(Verdict("scale window", False,
"renders when zoomed out — Min/MaxScaleDenominator excludes this scale"))
return out
out.append(Verdict("scale window", True, "blank at every scale tried"))
# Gate 4 — do the filters name attributes that exist?
names = attribute_names(base, layer)
out.append(Verdict("attributes", True, "store advertises: " + ", ".join(names)))
return out
if __name__ == "__main__":
for verdict in diagnose("https://example.org/geoserver/wms",
"census:tracts", "population_graduated",
"-1000000,5000000,-900000,5100000"):
print(verdict)
Gate one splits the problem in half for the cost of one request. Requesting with an empty STYLES parameter makes the server apply the layer’s default style. If that renders and yours does not, every question about the data, the extent and the reference system is answered — the fault is in the style document. If it does not render either, the style is irrelevant and the investigation moves to the data.
resultType=hits counts without transferring. When the default style is blank, the next question is whether any feature intersects the requested box at all. A WFS GetFeature with resultType set to hits returns a header-only response carrying numberMatched, which answers the question in one cheap round trip rather than downloading a feature collection. If it returns zero, the map is blank because there is nothing there — check the bounding box and, before anything else, the axis order, which the SRS handling guide covers.
Zooming out isolates the scale window. Scale bounds are evaluated per request before any feature is read, so a rule outside the window contributes nothing and leaves no trace. Re-requesting the same centre at four times the extent moves the scale denominator by a factor of four, which is enough to cross most window boundaries. If the wider request paints, the window is the culprit.
DescribeFeatureType is the authority on attribute names. Not the database schema, not the documentation — the names the service advertises, in the case it advertises them. A PostGIS-backed layer publishes lower-cased column names, so a filter on Population never matches and every feature falls through to whatever catch-all rule exists. Printing the advertised list next to your filter’s property names usually makes the mismatch obvious immediately.
Run the probe against a style you know is broken and confirm it names the right gate:
python diagnose_sld.py
[ok ] layer/data: default style renders — the style is at fault
[FAIL] style: style 'population_graduated' paints nothing at this scale
[FAIL] scale window: renders when zoomed out — Min/MaxScaleDenominator excludes this scale
Once the cause is fixed, the same probe is worth keeping as a regression test in the pipeline described in CI/CD and Compliance Testing for Spatial Services — asserting that a known extent renders non-empty pixels for each published style.
A transparent PNG and a white PNG are different failures. With TRANSPARENT=true, an unpainted tile is fully transparent, which is what _paints tests for. With TRANSPARENT=false the same unpainted tile arrives as solid white, and an alpha test passes on it. Always probe with transparency on, or test for colour variance rather than alpha.
Rule shadowing hides a working rule. Because every matching rule renders in document order, a broad rule later in the document paints over a narrower one earlier. The symptom is not a blank map but a wrong one, and the probe above will not catch it — comparing rendered output against a reference image will.
Vendor options can suppress rendering silently. GeoServer’s labelling and conflict-resolution options can drop a TextSymbolizer entirely when it cannot be placed, which reads as a missing label rather than an error. If text is the only thing missing, the symbolizer is probably being placed and rejected rather than never matched.
Check the style is actually applied. A STYLES value naming a style that does not exist is a StyleNotDefined exception on some servers and a silent fall back to the default on others. When the “broken” style renders identically to the default, confirm the name resolves — the style deployment guide covers versioned naming that makes this unambiguous.
Because the request was valid. A ServiceException reports a malformed or unsatisfiable request — an unknown layer, an unsupported CRS, a bad bounding box. A well-formed request whose rules happen to match no features has succeeded by the specification’s definition; it simply produced an image with nothing in it. There is no status code for ‘rendered, but empty’.
Re-request with an empty STYLES parameter. It costs one round trip and immediately tells you whether the problem is in the style you just changed or in something else entirely, which is worth more than any amount of reading the document.
Reuse the filter as a WFS GetFeature constraint with resultType set to hits. The SLD filter grammar and the WFS filter grammar are the same, so the filter can be lifted out of the style verbatim and the returned numberMatched tells you exactly how many features the rule would have painted.
Only partially. You can assert the document is schema-valid, that its bands tile the value range, and that every property name it references appears in a captured DescribeFeatureType response. Whether a rule paints pixels is a property of the renderer plus the data, so proving it needs both — which is why the disposable-server pattern in the CI guide exists.
Back to SLD Styling and Symbology for OGC Services
Related