SLD Styling and Symbology for OGC Services

Styling is where an OGC service stops being a data contract and starts being a picture. The Styled Layer Descriptor (SLD) profile, together with the Symbology Encoding (SE) specification it depends on, is the interchange format that decides what a WMS GetMap response actually looks like. It is also the part of a spatial stack that most often ships untested: an SLD document is XML that validates cleanly, uploads without complaint, and renders an empty tile. This guide covers the SLD document model, how rules are selected and ordered, how to generate styles from Python rather than editing XML by hand, and how to catch the failure modes that never raise an error.

Prerequisites & Architecture Context

You need a running map server that consumes SLD — GeoServer accepts SLD 1.0.0 and 1.1.0 natively, while MapServer translates a subset of SLD onto its own CLASS blocks. On the Python side this guide uses lxml for construction and validation, mapclassify or plain numpy for deriving class breaks, and requests for uploading through the REST catalog.

Two version facts shape everything below. SLD 1.0.0 embeds its symbolizers directly in the http://www.opengis.net/sld namespace; SLD 1.1.0 splits them out into the Symbology Encoding namespace http://www.opengis.net/se, and moves filtering from ogc:Filter to the same ogc namespace used by WFS 1.1. A document that mixes the two namespaces parses, uploads and renders nothing, because the renderer resolves symbolizer elements by qualified name and simply finds none. Decide which version your server is configured for and generate only that one.

Five nesting levels, and only the innermost paints anything Five stacked bands showing the SLD document hierarchy. The StyledLayerDescriptor root carries the version and namespaces; a NamedLayer with a UserStyle binds the style to a published layer; a FeatureTypeStyle is one rendering pass; a Rule pairs a filter with a scale window and is where every styling decision is actually made; and a Symbolizer is the only element that produces pixels. StyledLayerDescriptor the document root — version and namespace live here NamedLayer / UserStyle binds the style to a published layer name FeatureTypeStyle one rendering pass over the feature set Rule a filter plus a scale window — the unit of decision Symbolizer Point, Line, Polygon, Text or Raster — the actual paint

The hierarchy above is worth internalising because the failure modes map onto its levels. A mistake at the StyledLayerDescriptor or NamedLayer level produces an upload error and is trivially caught. A mistake at the Rule level produces a valid document that renders the wrong subset. A mistake at the Symbolizer level produces a valid document that renders nothing at all — and neither of the last two reports anything.

Specification Deep-Dive: How Rules Are Selected

The single most misunderstood property of SLD is that rule evaluation is not a switch statement. Within one FeatureTypeStyle, every rule whose filter matches and whose scale window contains the current scale is rendered, in document order, one on top of the next. There is no first-match-wins. A rule with no ogc:Filter and no scale bounds matches every feature at every scale, so placing one first means every later rule paints on top of it, and placing one last means it paints over everything else.

What each rule selector actually gates A five-row grid of the selectors available inside an SLD Rule. Attribute filters are evaluated per feature; the two scale-denominator bounds are evaluated once per request before any feature is read, which is why they are the cheapest way to drop work; ElseFilter catches whatever no other rule matched; and a rule with neither a filter nor a scale window matches everything and quietly shadows the rules after it. Rule selector Selects on Evaluated ogc:Filter attribute values per feature MinScaleDenominator map scale per request, before filtering MaxScaleDenominator map scale per request, before filtering ElseFilter whatever no rule matched after every other rule (no filter, no scale) everything always — it shadows later rules

MinScaleDenominator and MaxScaleDenominator are evaluated once per GetMap request against the scale implied by the requested BBOX and WIDTH/HEIGHT, before any feature is read from the data store. That makes them the cheapest possible filter: a rule outside the scale window costs nothing, whereas an ogc:Filter is evaluated per feature and, depending on the data store, may or may not be pushed down into SQL. Scale windows are half-open — MinScaleDenominator is inclusive and MaxScaleDenominator is exclusive — so adjacent windows written as 0–50000 and 50000–200000 do not overlap and do not leave a gap.

ElseFilter is an empty element, not a filter expression. It matches exactly those features that no other rule in the same FeatureTypeStyle matched, which makes it the correct way to render an “everything else” class without duplicating the negation of every preceding filter. It is scoped to its FeatureTypeStyle; two FeatureTypeStyle elements each with an ElseFilter produce two independent else-classes.

The ogc:Filter grammar itself is the same one used by WFS, which is convenient: a filter that selects a class of features for styling can be reused verbatim as a GetFeature constraint to count how many features that class contains. PropertyIsBetween, PropertyIsEqualTo, PropertyIsLike and the logical And/Or/Not operators cover almost every classification. Property names are matched case-sensitively against the attribute names the data store advertises, which for a PostGIS-backed layer means the lower-cased column names PostgreSQL actually stores — a filter on Population against a column named population is silently never true.

Python Implementation: Generating a Graduated Style

Hand-writing an SLD with seven classes means writing seven nearly identical Rule blocks and getting every bound right. Generating it means computing the breaks once and letting a serialiser emit the XML. The function below takes a list of values, derives quantile breaks, and produces a complete SLD 1.0.0 document with one rule per class plus a final else-rule.

from typing import Sequence
from lxml import etree

SLD_NS = "http://www.opengis.net/sld"
OGC_NS = "http://www.opengis.net/ogc"
NSMAP = {None: SLD_NS, "ogc": OGC_NS, "xlink": "http://www.w3.org/1999/xlink"}

def _q(tag: str) -> str:
    return f"{{{SLD_NS}}}{tag}"

def _o(tag: str) -> str:
    return f"{{{OGC_NS}}}{tag}"

def quantile_breaks(values: Sequence[float], classes: int) -> list[float]:
    """Return `classes - 1` interior break points at equal counts."""
    ordered = sorted(v for v in values if v is not None)
    if len(ordered) < classes:
        raise ValueError("fewer values than requested classes")
    step = len(ordered) / classes
    return [ordered[int(round(step * i))] for i in range(1, classes)]

def graduated_sld(
    layer: str,
    attribute: str,
    breaks: Sequence[float],
    ramp: Sequence[str],
    stroke: str = "#333333",
) -> bytes:
    """Build an SLD 1.0.0 polygon style with one rule per class band.

    `breaks` holds the interior boundaries, so len(ramp) must be
    len(breaks) + 1 — one colour per band, lowest band first.
    """
    if len(ramp) != len(breaks) + 1:
        raise ValueError("ramp must hold exactly one colour per band")

    root = etree.Element(_q("StyledLayerDescriptor"), nsmap=NSMAP, version="1.0.0")
    named = etree.SubElement(root, _q("NamedLayer"))
    etree.SubElement(named, _q("Name")).text = layer
    user = etree.SubElement(named, _q("UserStyle"))
    etree.SubElement(user, _q("Title")).text = f"{attribute} (graduated)"
    fts = etree.SubElement(user, _q("FeatureTypeStyle"))

    bounds = [None, *breaks, None]        # open-ended first and last band
    for i, colour in enumerate(ramp):
        lower, upper = bounds[i], bounds[i + 1]
        rule = etree.SubElement(fts, _q("Rule"))
        etree.SubElement(rule, _q("Name")).text = f"class-{i}"
        etree.SubElement(rule, _q("Title")).text = _band_title(lower, upper)
        _append_range_filter(rule, attribute, lower, upper)
        _append_polygon_symbolizer(rule, colour, stroke)

    # Anything the bands missed — a NULL attribute, most often.
    other = etree.SubElement(fts, _q("Rule"))
    etree.SubElement(other, _q("Name")).text = "no-data"
    etree.SubElement(other, _q("ElseFilter"))
    _append_polygon_symbolizer(other, "#cccccc", stroke)

    return etree.tostring(root, pretty_print=True, xml_declaration=True,
                          encoding="UTF-8")

def _band_title(lower, upper) -> str:
    if lower is None:
        return f"< {upper:g}"
    if upper is None:
        return f">= {lower:g}"
    return f"{lower:g} - {upper:g}"

def _append_range_filter(rule, attribute, lower, upper) -> None:
    """Emit the tightest filter that expresses this half-open band."""
    flt = etree.SubElement(rule, _o("Filter"))
    if lower is not None and upper is not None:
        node = etree.SubElement(flt, _o("PropertyIsBetween"))
        etree.SubElement(node, _o("PropertyName")).text = attribute
        etree.SubElement(etree.SubElement(node, _o("LowerBoundary")),
                         _o("Literal")).text = f"{lower:g}"
        etree.SubElement(etree.SubElement(node, _o("UpperBoundary")),
                         _o("Literal")).text = f"{upper:g}"
    else:
        op = "PropertyIsLessThan" if lower is None else "PropertyIsGreaterThanOrEqualTo"
        node = etree.SubElement(flt, _o(op))
        etree.SubElement(node, _o("PropertyName")).text = attribute
        etree.SubElement(node, _o("Literal")).text = f"{(upper if lower is None else lower):g}"

def _append_polygon_symbolizer(rule, fill: str, stroke: str) -> None:
    sym = etree.SubElement(rule, _q("PolygonSymbolizer"))
    f = etree.SubElement(sym, _q("Fill"))
    _css(f, "fill", fill)
    _css(f, "fill-opacity", "0.85")
    s = etree.SubElement(sym, _q("Stroke"))
    _css(s, "stroke", stroke)
    _css(s, "stroke-width", "0.4")

def _css(parent, name: str, value: str) -> None:
    etree.SubElement(parent, _q("CssParameter"), name=name).text = value

Three details in that code are worth calling out. PropertyIsBetween is inclusive at both ends, so consecutive bands written naively overlap at their shared boundary; the code sidesteps this by using strict PropertyIsLessThan for the open lower band and PropertyIsGreaterThanOrEqualTo for the open upper band, which reproduces the half-open convention the rest of the pipeline assumes. The explicit no-data rule with ElseFilter exists because a NULL attribute satisfies no comparison operator in the filter grammar — without it, features with a missing value vanish from the map without any indication that they were ever there. And every colour is written through a CssParameter, because SLD 1.0.0 has no colour attribute: fill set as an XML attribute is silently ignored.

Error Handling & Edge Cases

A style that uploads is not a style that renders. GeoServer validates an SLD against the schema on upload and will reject malformed XML, but it does not check that the property names in your filters exist on the target layer, that the scale windows cover the full range, or that any rule matches anything. All three produce a blank or partial map with an HTTP 200.

Four reasons a rule produces no pixels A decision diamond covering a layer whose features render only partially. A filter that is never true is usually a case-mismatched property name; a rule outside the current scale window is skipped before any feature is examined; a broad earlier rule can paint over a narrower later one because SLD renders every matching rule rather than stopping at the first; and a symbolizer with neither Fill nor Stroke is structurally valid and paints nothing at all. A layer renders, but one class of features is missing. Where did the rule go wrong? Check the property name SLD property names are case-sensitive Widen Min/MaxScaleDenominator the rule is skipped before filtering Reorder or add ElseFilter first match does not win — all do Set Fill or Stroke explicitly an empty symbolizer paints nothing filter never true outside the scale window shadowed by an earlier rule symbolizer has no fill

Rule shadowing. Because every matching rule paints, a broad rule placed after a narrow one hides it completely. This is the usual explanation for a style that looks correct in isolation and wrong once merged with another author’s rules. Generating styles programmatically avoids it by construction: the bands emitted above are mutually exclusive by definition.

Scale windows and reprojection. The scale denominator a server computes depends on the pixel size the OGC standard assumes — 0.28 mm — and on the coordinate reference system of the request. The same map requested in EPSG:4326 and EPSG:3857 does not necessarily land in the same scale band, so a style tuned against Web Mercator can behave differently when a client requests geographic coordinates. The SRS and Coordinate Reference System Handling guide covers how the requested CRS reaches the renderer.

Fonts and external graphics. A TextSymbolizer naming a font the server does not have falls back to a default that may not contain the glyphs your labels need, and an ExternalGraphic pointing at a URL the server cannot reach renders as nothing rather than as an error. Both are environment-dependent, which means they pass in development and fail in production — exactly the class of drift covered in Environment Parity for Spatial Servers.

Testing & Compliance Verification

Validate structurally first, then render. Structural validation against the SLD schema catches namespace and ordering mistakes; only a render proves that rules match.

import io
import requests
from lxml import etree
from PIL import Image

def validate_sld(sld_bytes: bytes, xsd_path: str) -> list[str]:
    """Return schema errors as strings; an empty list means structurally valid."""
    schema = etree.XMLSchema(etree.parse(xsd_path))
    doc = etree.fromstring(sld_bytes)
    if schema.validate(doc):
        return []
    return [f"line {e.line}: {e.message}" for e in schema.error_log]

def rule_coverage(sld_bytes: bytes) -> dict[str, int]:
    """Count rules and flag ones that can never be selected."""
    doc = etree.fromstring(sld_bytes)
    ns = {"sld": "http://www.opengis.net/sld"}
    rules = doc.findall(".//sld:Rule", ns)
    unbounded = [r for r in rules
                 if not r.findall(".//{http://www.opengis.net/ogc}Filter", {})
                 and r.find("sld:ElseFilter", ns) is None
                 and r.find("sld:MinScaleDenominator", ns) is None]
    return {"rules": len(rules), "match_everything": len(unbounded)}

def renders_pixels(wms_url: str, layer: str, style: str, bbox: str) -> bool:
    """True when GetMap returns an image that is not a single flat colour."""
    resp = requests.get(wms_url, timeout=30, params={
        "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
        "LAYERS": layer, "STYLES": style, "CRS": "EPSG:3857",
        "BBOX": bbox, "WIDTH": "512", "HEIGHT": "512",
        "FORMAT": "image/png", "TRANSPARENT": "true",
    })
    resp.raise_for_status()
    img = Image.open(io.BytesIO(resp.content)).convert("RGBA")
    return len(img.getcolors(maxcolors=1 << 16) or [(0, None), (0, None)]) > 1

renders_pixels is the assertion most style pipelines are missing. A GetMap that returns a fully transparent PNG is a successful HTTP response containing no map, and nothing else in the stack will tell you. Wiring it into the pipeline described in CI/CD and Compliance Testing for Spatial Services turns a silent styling regression into a red build.

For formal conformance, the OGC CITE suite includes an SLD module that exercises the GetStyles and DescribeLayer operations of a WMS advertising SLD support, plus schema conformance of the documents it returns. It does not, and cannot, assert anything about whether your rules select the features you intended.

Performance & Scaling Notes

Scale windows are the cheapest optimisation available. A rule outside the current window is discarded before the data store is touched. A layer of building footprints that only renders below 1:10,000 costs nothing at country zoom levels, whereas the same layer without a MaxScaleDenominator reads every row on every request.

Keep the rule count low at low zoom. Rendering cost scales with rules times features, not with rules alone. Seven classification bands over ten thousand polygons is fine; seven bands over four million is not, and the fix is a coarser style at coarse scales rather than a faster renderer.

Push filters into the data store. GeoServer translates many ogc:Filter expressions into SQL WHERE clauses against a PostGIS store, but only when the filter references a plain attribute. Wrapping the attribute in a function — a PropertyIsEqualTo over strToLowerCase(name), for instance — defeats the pushdown and forces a full scan with filtering in the JVM.

Cache what does not change. Styled raster output for a stable layer is a perfect candidate for the tile-cache hierarchy described in WMTS Tile Matrix Sets Explained; a style change then becomes a cache invalidation rather than a rendering cost paid per request.

Version the style, not the layer. Uploading a new style under a new name and switching the layer’s default in a second call makes the change atomic and reversible, which is the pattern used in Automating SLD Style Deployment Across Staging and Production.

Frequently Asked Questions

Does the order of Rule elements matter if my filters are mutually exclusive?

Not for correctness, but it still matters for output. Every matching rule renders, so with genuinely exclusive filters only one ever matches a given feature and the order is irrelevant. The moment a rule is added without a filter — a base layer, a highlight overlay, an ElseFilter catch-all — order decides what paints over what. Treat document order as the z-order it actually is.

Should I generate SLD 1.0.0 or 1.1.0?

Generate whichever version your server is configured to consume, and generate only one. SLD 1.0.0 remains the most widely supported and is what GeoServer accepts by default. SLD 1.1.0 moves symbolizers into the Symbology Encoding namespace, which is cleaner but far less portable across renderers. A document that mixes both namespaces validates and renders nothing, because symbolizer elements are resolved by qualified name.

Why do my labels disappear at some zoom levels but not others?

Label placement is subject to conflict resolution: a renderer that cannot place a label without overlapping one it has already placed drops it silently. This is scale-dependent by nature, since the same features are closer together in pixel space at coarse scales. Vendor options such as GeoServer’s conflictResolution and spaceAround control the behaviour, but the durable fix is a MaxScaleDenominator on the labelling rule so labels only appear where there is room for them.

How do I style a layer whose classification changes as the data changes?

Regenerate the style whenever the data changes rather than choosing bounds that will still be right later. Compute the breaks from the current attribute distribution, emit a fresh SLD, upload it under a versioned name and switch the layer default. Because the whole document is derived from data, the classification can never drift out of step with what is actually published.

Can I use one SLD document across GeoServer and MapServer?

Partially. MapServer accepts SLD and maps it onto internal CLASS blocks, but its coverage of the specification is narrower — complex filters, some symbolizer options and most vendor extensions are dropped rather than rejected. If both servers must render the same map identically, generate from one source model and emit a native style per server, exactly as described in the MapServer Configuration as Code guide, rather than hoping one SLD behaves the same in both.


Back to OGC Standards Architecture & Service Fundamentals

Related