Generating SLD From Python Data Classifications

Compute the breaks from the data, then emit one half-open Rule per band with lxml. Never hand-write the bounds: a graduated style with seven classes has fourteen numbers in it, and a single overlap or gap produces a map that is wrong in a way no validator reports. The function below turns a list of values and a colour ramp into a complete SLD 1.0.0 document.

The Core Challenge: Breaks Decide What the Map Says

A choropleth is an argument about the data, and the classification scheme is the argument. The same attribute rendered with equal-interval and with quantile breaks produces two maps that a reader will interpret completely differently — one showing a handful of extreme outliers against a uniform background, the other showing an evenly graded surface. Choosing the scheme is an editorial decision; implementing it is not, and that is the part worth automating.

Five classification schemes, and when each tells the truth A five-row grid of classification schemes for a graduated style. Equal interval splits the value range into equal widths and suits uniformly spread data; quantile gives every class the same number of features and suits skewed data; natural breaks minimise within-class variance and suit clustered data; standard deviation classes express distance from the mean; and manual thresholds are the only correct choice when the class boundaries carry legal or policy meaning. Scheme Break placement Best when Equal interval range split into n equal widths the attribute is uniformly spread Quantile equal feature count per class the distribution is skewed Natural breaks minimises within-class variance the data has visible clusters Standard deviation distance from the mean the reader cares about the average Manual thresholds fixed by policy the classes have legal meaning

The mechanical hazard is different from the editorial one. Class bounds written by hand tend to overlap at their shared boundary, because the obvious filter to reach for is PropertyIsBetween, which is inclusive at both ends. Two adjacent bands of 0–100 and 100–200 both match a feature whose value is exactly 100, and since SLD renders every matching rule rather than stopping at the first, that feature is painted twice — the second colour winning. Generating the rules from a single ordered list of breaks makes the overlap impossible to express.

Production-Ready Code

from dataclasses import dataclass
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}

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

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

@dataclass(frozen=True)
class Band:
    """A half-open class band: lower <= value < upper. None means unbounded."""
    lower: float | None
    upper: float | None
    colour: str

    @property
    def title(self) -> str:
        if self.lower is None:
            return f"under {self.upper:g}"
        if self.upper is None:
            return f"{self.lower:g} and over"
        return f"{self.lower:g} to {self.upper:g}"

def quantile_breaks(values: Sequence[float], classes: int) -> list[float]:
    """Interior break points placed at equal feature counts.

    Duplicate breaks are collapsed: a skewed attribute where 60% of the
    features share one value cannot support the requested class count,
    and silently emitting an empty band would produce a flat map.
    """
    clean = sorted(v for v in values if v is not None)
    if len(clean) < classes:
        raise ValueError(f"{len(clean)} values cannot fill {classes} classes")
    step = len(clean) / classes
    raw = [clean[min(len(clean) - 1, int(round(step * i)))] for i in range(1, classes)]
    unique = sorted(set(raw))
    if len(unique) != len(raw):
        raise ValueError(
            f"attribute is too concentrated for {classes} quantile classes "
            f"({len(unique) + 1} distinct bands possible)")
    return unique

def bands_from_breaks(breaks: Sequence[float], ramp: Sequence[str]) -> list[Band]:
    """Turn n-1 interior breaks and n colours into n non-overlapping bands."""
    if len(ramp) != len(breaks) + 1:
        raise ValueError("ramp must hold exactly one colour per band")
    edges: list[float | None] = [None, *breaks, None]
    return [Band(edges[i], edges[i + 1], ramp[i]) for i in range(len(ramp))]

def sld_document(layer: str, attribute: str, bands: Sequence[Band],
                 null_colour: str = "#d9d9d9") -> bytes:
    """Serialise a graduated polygon style, plus an explicit no-data rule."""
    root = etree.Element(_s("StyledLayerDescriptor"), nsmap=NSMAP, version="1.0.0")
    named = etree.SubElement(root, _s("NamedLayer"))
    etree.SubElement(named, _s("Name")).text = layer
    style = etree.SubElement(named, _s("UserStyle"))
    etree.SubElement(style, _s("Title")).text = f"{attribute} graduated"
    fts = etree.SubElement(style, _s("FeatureTypeStyle"))

    for i, band in enumerate(bands):
        rule = etree.SubElement(fts, _s("Rule"))
        etree.SubElement(rule, _s("Name")).text = f"band-{i}"
        etree.SubElement(rule, _s("Title")).text = band.title
        _band_filter(rule, attribute, band)
        _polygon(rule, band.colour)

    # NULL satisfies no comparison operator, so it needs its own rule.
    other = etree.SubElement(fts, _s("Rule"))
    etree.SubElement(other, _s("Name")).text = "no-data"
    etree.SubElement(other, _s("Title")).text = "no data"
    etree.SubElement(other, _s("ElseFilter"))
    _polygon(other, null_colour)

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

def _band_filter(rule, attribute: str, band: Band) -> None:
    flt = etree.SubElement(rule, _f("Filter"))
    parts = []
    if band.lower is not None:
        parts.append(("PropertyIsGreaterThanOrEqualTo", band.lower))
    if band.upper is not None:
        parts.append(("PropertyIsLessThan", band.upper))
    target = flt if len(parts) == 1 else etree.SubElement(flt, _f("And"))
    for op, value in parts:
        node = etree.SubElement(target, _f(op))
        etree.SubElement(node, _f("PropertyName")).text = attribute
        etree.SubElement(node, _f("Literal")).text = f"{value:g}"

def _polygon(rule, fill: str) -> None:
    sym = etree.SubElement(rule, _s("PolygonSymbolizer"))
    fill_el = etree.SubElement(sym, _s("Fill"))
    etree.SubElement(fill_el, _s("CssParameter"), name="fill").text = fill
    etree.SubElement(fill_el, _s("CssParameter"), name="fill-opacity").text = "0.9"
    stroke = etree.SubElement(sym, _s("Stroke"))
    etree.SubElement(stroke, _s("CssParameter"), name="stroke").text = "#4d4d4d"
    etree.SubElement(stroke, _s("CssParameter"), name="stroke-width").text = "0.3"

if __name__ == "__main__":
    population = [120, 340, 890, 1200, 2400, 5600, 9100, 15000, 22000, 41000]
    ramp = ["#eff3ff", "#c6dbef", "#9ecae1", "#6baed6", "#2171b5"]
    breaks = quantile_breaks(population, classes=len(ramp))
    doc = sld_document("census:tracts", "population",
                       bands_from_breaks(breaks, ramp))
    print(doc.decode())

Step-by-Step Walkthrough

Bands are a value type, not a loop variable. Modelling a class band as a frozen dataclass with an explicit lower and upper means the half-open convention is stated once, in one place, and every consumer inherits it. The alternative — carrying a flat list of breaks and indexing into it inside the serialiser — is where off-by-one errors live.

Classification is computed once, then frozen into the document A five-stage chain. Attribute values are read from the data store, the chosen scheme derives the break points in one pass, each band is mapped to a colour from the ramp, one half-open rule is emitted per band so no feature can match two, and the tree is serialised with lxml into a schema-valid document. Read values from PostGIS or a GeoDataFrame Derive breaks scheme applied once Map to colours one per band Emit rules half-open, no overlap Serialise lxml, schema-valid query numpy ramp per band

quantile_breaks refuses rather than degrades. When an attribute is concentrated enough that two quantile boundaries land on the same value, the honest outcome is an error naming how many distinct bands the data can actually support. Emitting the duplicate silently produces a band whose filter can never be true, which renders as a missing class and is diagnosed as a styling bug rather than a data one.

The filter is built from whichever bounds exist. An interior band has both bounds and needs an ogc:And wrapping two comparisons. The first and last bands are unbounded on one side and take a single comparison with no wrapper, because an And with one child is legal but pointlessly verbose. Using PropertyIsGreaterThanOrEqualTo with PropertyIsLessThan — rather than PropertyIsBetween — is what makes the bands half-open and therefore non-overlapping.

The no-data rule is not optional. A feature whose attribute is NULL satisfies neither comparison in any band, so without an ElseFilter rule it is simply not drawn. On a polygon layer that reads as a hole in the map, which is indistinguishable from a genuine gap in coverage. Painting it in an explicit neutral colour makes missing data visible as missing data.

Verification

Assert the two properties that matter — that the bands tile the value range without overlapping, and that the document is schema-valid — before the style ever reaches a server.

def assert_bands_tile(bands) -> None:
    """Every value falls in exactly one band."""
    assert bands[0].lower is None, "first band must be open below"
    assert bands[-1].upper is None, "last band must be open above"
    for a, b in zip(bands, bands[1:]):
        assert a.upper == b.lower, f"gap or overlap between {a.title} and {b.title}"

def assert_valid(doc: bytes, xsd_path: str) -> None:
    from lxml import etree
    schema = etree.XMLSchema(etree.parse(xsd_path))
    schema.assertValid(etree.fromstring(doc))

Running the example produces a document whose first rule opens with the unbounded lower band:

<Rule>
  <Name>band-0</Name>
  <Title>under 890</Title>
  <ogc:Filter>
    <ogc:PropertyIsLessThan>
      <ogc:PropertyName>population</ogc:PropertyName>
      <ogc:Literal>890</ogc:Literal>
    </ogc:PropertyIsLessThan>
  </ogc:Filter>
  ...
</Rule>

Gotchas & Edge Cases

A quantile scheme on skewed data can be unbuildable. Population, income and count attributes are frequently zero-inflated: if forty per cent of features share the value zero, no quantile boundary can separate them and the requested class count is not achievable. The error raised above is the correct outcome — the fix is a different scheme or fewer classes, not a nudged boundary.

A flat map is a classification failure, not a rendering failure A decision diamond for a graduated style that renders as one colour. Repeated quantile break values collapse several bands into an empty range; an attribute that is entirely null sends every feature to the else-rule; a ramp shorter than the band count silently drops the trailing classes; and when the bands are sound the usual remaining cause is an attribute name whose case does not match the stored column. The map is a single flat colour. Which step collapsed? Check for duplicates a skewed attribute can repeat a quantile Filter nulls first every feature hit the else-rule One colour per band len(ramp) must be len(breaks)+1 Check the attribute name case must match the column one break value all values null ramp too short bands fine

Colour ramps must be ordered. bands_from_breaks pairs ramp[i] with the i-th band from the bottom up, so a ramp supplied dark-to-light produces an inverted map that is perfectly valid and completely misleading. Ramps are worth treating as named constants rather than inline lists.

Attribute names are case-sensitive against the store. A PostGIS-backed layer exposes lower-cased column names, so PropertyName of Population against a column named population matches nothing and every feature falls through to the no-data rule. The symptom — an entirely grey map — is indistinguishable from an all-null attribute, which is why the debugging guide starts by dumping the store’s advertised attribute names.

Regenerate when the data changes. Breaks derived from last quarter’s distribution describe last quarter’s map. Wiring style generation into the same pipeline that publishes the data — as in Layer Publishing Workflows in Python — keeps classification and content in step by construction.

Frequently Asked Questions

Which classification scheme should I default to?

Quantile, when you have no editorial reason to prefer another. It guarantees every class contains features, which means every colour in the legend appears on the map — a property equal-interval loses immediately on skewed data. Switch to natural breaks when the distribution has visible clusters worth preserving, and to manual thresholds whenever the boundaries carry legal or policy meaning, because a computed boundary that happens to sit near a regulatory threshold is worse than useless.

Why use PropertyIsGreaterThanOrEqualTo and PropertyIsLessThan instead of PropertyIsBetween?

Because PropertyIsBetween is inclusive at both ends. Adjacent bands written with it overlap on their shared boundary, and since SLD renders every matching rule rather than the first, a feature sitting exactly on a break is painted twice with the later colour winning. The half-open pair makes overlap unrepresentable rather than merely unlikely.

How many classes is too many?

Beyond about seven, readers stop distinguishing adjacent colours on a sequential ramp, and the extra rules cost render time on every request. If the data genuinely needs more resolution than that, a continuous raster rendering is a better fit than a classified vector style.

Should the no-data rule come first or last?

Last. ElseFilter matches whatever no other rule in the same FeatureTypeStyle matched, and its position determines only what it paints over. Placed last it sits above the bands in z-order, which is correct — a no-data polygon should not be obscured by a class colour that does not apply to it.


Back to SLD Styling and Symbology for OGC Services

Related