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