Debugging SLD Rules That Render Nothing

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.

The Core Challenge: Nothing in the Stack Reports This

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.

Five gates a feature must pass to become a pixel A five-stage chain of the conditions a feature must satisfy to appear on a rendered map. The layer must resolve, the data store must actually contain features in the requested extent, the rule's scale window must contain the current scale, the rule's filter must match the feature, and the symbolizer must specify a fill or stroke. Failing any one of them produces a blank image and an HTTP 200. Layer resolves DescribeLayer answers Features exist GetFeature count > 0 Scale window rule not skipped Filter matches attribute name and case Symbolizer paints Fill or Stroke set else 404 else empty else skipped else no match

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.

Production-Ready Code

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)

Step-by-Step Walkthrough

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.

Bisect the style, do not read it A five-row grid of bisection steps for a blank map. Requesting with an empty STYLES parameter falls back to the server default and proves whether the data renders at all; stripping the scale bounds isolates the scale window; replacing a filter with ElseFilter isolates the filter; DescribeFeatureType gives the exact attribute names and case the store advertises; and setting a violently visible fill colour proves whether a rule matched but painted nothing. Question Bisect it with Confirms Layer or style? STYLES= (empty, server default) the data renders at all Scale or filter? strip Min/MaxScaleDenominator the window was excluding it Filter or attribute? replace the filter with ElseFilter the filter never matched Attribute name? DescribeFeatureType the exact case the store advertises Symbolizer? set fill to #ff00ff the rule matched but painted nothing

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.

Verification

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.

Gotchas & Edge Cases

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.

One request tells you which half of the problem to look at A decision diamond that splits a transparent GetMap response into its possible causes. If the server default style renders and yours does not, the fault is in the style. If neither renders, the fault is in the data or the requested extent and reference system. If the layer itself does not resolve, the fault is in the catalog. And if the same request renders at a different scale, the scale window is excluding every rule. GetMap returned a fully transparent PNG. Which layer of the stack is responsible? The style is at fault bisect the rules The data is at fault check the extent and CRS The layer is at fault check DescribeLayer and the store The scale window tune Min/MaxScaleDenominator default style works default style blank too neither renders renders at other scales

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.

Frequently Asked Questions

Why does GetMap return 200 for a style that selects nothing?

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’.

What is the fastest single check when a map goes blank after a style change?

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.

How do I know whether my filter matched anything without rendering?

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.

Can I test this without a running server?

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