Publishing a GeoServer Layer Group From Python

A layer group is one addressable layer backed by several. POST a layerGroup payload naming its members in draw order, a style per member, an explicit mode, and recomputed bounds. The three defaults worth overriding are the draw order — the first member is drawn first and therefore sits at the bottom — the per-member style, and the bounding box, which is not derived from the members unless you ask.

The Core Challenge: A Group Is an Ordered Composition

Publishing a group is not the same as publishing a layer. There is no data store behind it and no schema to introspect; it is purely a composition instruction, and every part of that instruction is a decision the API will make for you if you leave it out.

Four modes, and only two of them are requestable A four-row grid of GeoServer layer group modes. In single mode the group appears as one addressable layer whose members render in document order. In named mode both the group and its members are advertised. A container group advertises only its members and cannot itself be requested. Earth observation mode presents a browse image alongside its bands. Group mode Client sees Rendering SINGLE one layer all members, in order NAMED the group and its members all members, in order CONTAINER members only nothing on its own EO one layer a browse image plus bands

The mode matters most for discoverability. SINGLE advertises one layer and hides the members, which is what most consumers want. NAMED advertises both. CONTAINER advertises only the members, so the group itself cannot be requested at all — which is occasionally intentional for a folder-like structure and is otherwise a confusing way to publish nothing. Getting the mode wrong produces a group that is either missing from GetCapabilities or cluttering it.

Production-Ready Code

from __future__ import annotations

from dataclasses import dataclass
from typing import Sequence

import requests
from requests.auth import HTTPBasicAuth

@dataclass(frozen=True)
class Member:
    """One layer in a group, with the style it should render under."""
    qualified_name: str          # "workspace:layer"
    style: str | None = None     # None means that layer's own default

@dataclass
class LayerGroupClient:
    base: str
    user: str
    password: str
    timeout: int = 60

    @property
    def auth(self) -> HTTPBasicAuth:
        return HTTPBasicAuth(self.user, self.password)

    def _get(self, path: str) -> dict | None:
        resp = requests.get(f"{self.base.rstrip('/')}{path}", auth=self.auth,
                            timeout=self.timeout,
                            headers={"Accept": "application/json"})
        if resp.status_code == 404:
            return None
        resp.raise_for_status()
        return resp.json()

    def layer_bounds(self, qualified_name: str) -> tuple[float, ...]:
        """The geographic bounding box GeoServer advertises for one layer."""
        layer = self._get(f"/rest/layers/{qualified_name}.json")
        if layer is None:
            raise KeyError(f"layer {qualified_name!r} does not exist")
        resource = requests.get(layer["layer"]["resource"]["href"],
                                auth=self.auth, timeout=self.timeout).json()
        detail = next(iter(resource.values()))
        box = detail["latLonBoundingBox"]
        return (box["minx"], box["miny"], box["maxx"], box["maxy"])

    def union_bounds(self, members: Sequence[Member]) -> dict:
        """Recompute the group extent as the union of its members.

        GeoServer does not derive this — a group created without bounds
        gets whatever the first member had, and clips everything else.
        """
        boxes = [self.layer_bounds(m.qualified_name) for m in members]
        return {
            "minx": min(b[0] for b in boxes), "miny": min(b[1] for b in boxes),
            "maxx": max(b[2] for b in boxes), "maxy": max(b[3] for b in boxes),
            "crs": "EPSG:4326",
        }

    def publish(self, workspace: str, name: str, title: str,
                members: Sequence[Member], mode: str = "SINGLE",
                bottom_first: bool = True) -> int:
        """Create or replace a layer group.

        `members` is given top-to-bottom by default — the order a person
        reads a legend in — and reversed here, because GeoServer draws the
        first entry first and therefore at the bottom.
        """
        if not members:
            raise ValueError("a layer group needs at least one member")
        for member in members:
            if self._get(f"/rest/layers/{member.qualified_name}.json") is None:
                raise KeyError(f"member {member.qualified_name!r} is not published")

        ordered = list(reversed(members)) if bottom_first else list(members)
        payload = {"layerGroup": {
            "name": name,
            "title": title,
            "mode": mode,
            "workspace": {"name": workspace},
            "publishables": {"published": [
                {"@type": "layer", "name": m.qualified_name} for m in ordered]},
            "styles": {"style": [
                ({"name": m.style} if m.style else "") for m in ordered]},
            "bounds": self.union_bounds(ordered),
        }}

        existing = self._get(f"/rest/workspaces/{workspace}/layergroups/{name}.json")
        if existing is None:
            resp = requests.post(
                f"{self.base}/rest/workspaces/{workspace}/layergroups",
                auth=self.auth, timeout=self.timeout, json=payload,
                headers={"Content-Type": "application/json"})
        else:
            resp = requests.put(
                f"{self.base}/rest/workspaces/{workspace}/layergroups/{name}",
                auth=self.auth, timeout=self.timeout, json=payload,
                headers={"Content-Type": "application/json"})
        if resp.status_code not in (200, 201):
            raise RuntimeError(f"{resp.status_code}: {resp.text[:300]}")
        return resp.status_code

if __name__ == "__main__":
    client = LayerGroupClient("https://example.org/geoserver", "admin", "…")
    client.publish(
        workspace="basemap", name="topographic", title="Topographic basemap",
        members=[                                   # top of the legend first
            Member("basemap:labels", "labels_light"),
            Member("basemap:roads", "roads_thin"),
            Member("basemap:landuse", "landuse_muted"),
            Member("basemap:hillshade"),
        ])

Step-by-Step Walkthrough

Draw order is reversed from legend order. GeoServer renders publishables in document order, so the first entry is painted first and everything after covers it. Humans list layers the way a legend reads — labels at the top, terrain at the bottom — so the bottom_first flag reverses the caller’s natural order once, in one place, instead of leaving every caller to remember.

Five decisions, and z-order is the one people get backwards A five-stage sequence for publishing a layer group. Every member must already be published; the member order is the drawing order with the first entry drawn first and therefore at the bottom; a style is chosen explicitly per member rather than inherited; the group bounds are recomputed rather than left to inherit; and the group is created in a single atomic call. Members exist every layer published Order them bottom of the list draws first Pick styles one per member, or default Set the bounds recompute, do not inherit POST the group one call, atomic assert z-order explicit recalculate

An empty string means “use the member’s default style”. The styles array must be the same length as the members, and each entry is either a style object or an empty string. Omitting the array entirely works but leaves every member on its own default, which is rarely what a composed basemap wants — a road layer styled for standalone viewing usually has labels that fight with the group’s own label layer.

Bounds are not derived. This is the single most surprising behaviour: a group created without a bounds block inherits a box from the first member, and every other member is clipped to it. Computing the union up front costs one request per member and removes an entire class of “half the group is missing” reports.

Publish is create-or-replace. Checking for the group and choosing POST or PUT accordingly makes the operation idempotent, which is what lets it live in the same pipeline as the layer publishing described in Layer Publishing Workflows in Python.

Verification

Confirm the group is advertised as one layer and covers the union of its members:

import xml.etree.ElementTree as ET

caps = requests.get(f"{base}/wms", params={"SERVICE": "WMS",
                                           "REQUEST": "GetCapabilities"}).content
names = {el.text for el in ET.fromstring(caps).iter()
         if el.tag.endswith("}Name") and el.text}
assert "basemap:topographic" in names
assert "basemap:hillshade" not in names, "SINGLE mode should hide the members"

print(client.union_bounds([Member("basemap:roads"), Member("basemap:hillshade")]))
{'minx': 5.9559, 'miny': 45.818, 'maxx': 10.4921, 'maxy': 47.8085, 'crs': 'EPSG:4326'}

Then render it and compare against the members drawn individually — a group whose output differs from the sum of its parts has an order or style problem.

Gotchas & Edge Cases

A group can contain another group. Nesting is supported by setting the publishable type to layerGroup, and it is a good way to compose a basemap from reusable sub-groups. It also makes the draw order harder to reason about, since each nested group contributes its own ordered stack.

Four defaults that quietly decide how a group looks A decision diamond for a layer group that renders incorrectly. Hidden labels usually mean the member order is reversed, since the first member is drawn first and therefore sits at the bottom; unexpected symbology means the style was omitted and each member fell back to its own default; a clipped extent means the group bounds were inherited from one member rather than recomputed; and a group absent from capabilities is in container mode. The group renders but looks wrong. Which decision was implicit? Reverse the order the first member draws first, at the bottom Name the style an omitted style uses the member default Recalculate bounds the group inherited one member's box Mode is CONTAINER container groups are not requestable labels hidden wrong symbology clipped extent group not listed

Deleting a member breaks the group. GeoServer does not cascade: removing a layer that a group references leaves the group in the catalog pointing at nothing, and requests against it fail. Checking group membership before deleting a layer belongs in whatever tooling does the deleting.

Workspace-scoped versus global groups. A group created under /rest/workspaces/{ws}/layergroups is qualified by that workspace; one created under /rest/layergroups is global and can span workspaces. Global groups are convenient and are a common source of name collisions — prefer workspace-scoped unless the group genuinely spans several.

Styles must exist first. A group naming a style that has not been uploaded is rejected, which is the same ordering constraint the catalog restore has to respect.

Frequently Asked Questions

Why does my layer group render in the wrong order?

Because GeoServer draws the first member first, so it ends up at the bottom of the stack. People almost always write the list the way a legend reads — labels first, terrain last — which produces exactly the inverse. Reverse the list once, in the publishing helper, rather than asking every caller to think about it.

Do I have to set the bounds explicitly?

In practice yes. A group created without a bounds block does not compute the union of its members; it takes a box from the first one and clips the rest. Computing the union costs one small request per member and eliminates a failure that otherwise looks like missing data.

What is the difference between SINGLE and NAMED mode?

SINGLE advertises the group as one layer and hides its members from capabilities. NAMED advertises both, which is useful when consumers should be able to request individual layers as well as the composition. CONTAINER advertises only the members and makes the group itself unrequestable.

Can a layer group span workspaces?

A global group can, one created under a workspace cannot. Global groups are the right tool for a basemap composed from layers owned by different teams, at the cost of a flat global namespace where name collisions are easy.


Back to Layer Publishing Workflows in Python

Related