Bulk Updating GeoServer Layer Styles via the REST API
TL;DR: Upload the SLD once with a POST to /rest/styles carrying Content-Type: application/vnd.ogc.sld+xml, then loop over your target layers and PUT a small {"layer": {"defaultStyle": {"name": ...}}} body to /rest/layers/{layer}. Drive the loop from a bounded ThreadPoolExecutor with a rate cap, snapshot each layer’s existing default style first so you can roll back, and support a dry-run that reports intended changes without issuing any writes.
The Core Challenge
Reassigning one layer’s default style through the GeoServer REST API is trivial. Doing it across two hundred layers — safely, at controlled throughput, and reversibly — is where teams get burned. The GeoServer configuration catalog is guarded by a single write lock: every defaultStyle change rewrites catalog XML and invalidates cached layer configuration. Fire two hundred concurrent PUT requests at it and the catalog lock serialises them anyway, while live GetMap traffic queues behind your batch and starts timing out.
The style registry and the layer catalog are also two separate REST resources. The SLD document lives once in the global (or workspace-scoped) style registry at /rest/styles; each layer merely holds a reference to a style by name at /rest/layers/{layer}. So a bulk restyle is really two distinct operations: publish the SLD once, then rewrite many lightweight references to point at it. Getting the Content-Type wrong on the first step, or issuing the second step without a captured snapshot, are the two failures that turn a five-minute job into an incident. This guide is the concurrency-and-rollback companion to Automating GeoServer with the Python REST API; if you need the same style promoted through several environments rather than across many layers in one, see Automating SLD Style Deployment Across Staging and Production.
Production-Ready Code
The script below uploads one SLD, snapshots every target layer’s current default style, reassigns the new default concurrently under a rate cap, and exposes a rollback that replays the snapshot. It depends only on requests.
"""
bulk_restyle.py — assign one SLD as the default style across many GeoServer layers.
pip install requests>=2.31
"""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
import requests
SLD_MIME = "application/vnd.ogc.sld+xml" # SLD 1.0; use vnd.ogc.se+xml for SLD 1.1
class RateLimiter:
"""Allow at most `rate` operations per second across all worker threads."""
def __init__(self, rate_per_sec: float) -> None:
self._min_interval = 1.0 / rate_per_sec if rate_per_sec > 0 else 0.0
self._lock = threading.Lock()
self._next_allowed = 0.0
def wait(self) -> None:
if self._min_interval <= 0:
return
with self._lock:
now = time.monotonic()
sleep_for = self._next_allowed - now
start = max(now, self._next_allowed)
self._next_allowed = start + self._min_interval
if sleep_for > 0:
time.sleep(sleep_for)
@dataclass
class RestyleReport:
ok: list[str] = field(default_factory=list)
failed: dict[str, str] = field(default_factory=dict) # layer -> error text
snapshot: dict[str, str | None] = field(default_factory=dict) # layer -> old style
dry_run: bool = False
class GeoServerRestyler:
def __init__(
self,
base_url: str,
auth: tuple[str, str],
*,
workers: int = 4,
rate_per_sec: float = 8.0,
timeout: int = 30,
) -> None:
self.rest = base_url.rstrip("/") + "/rest"
self.timeout = timeout
self.workers = workers
self.limiter = RateLimiter(rate_per_sec)
self.session = requests.Session()
self.session.auth = auth
# Size the connection pool to the worker count so threads never block on it.
adapter = requests.adapters.HTTPAdapter(
pool_connections=workers, pool_maxsize=workers
)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
# --- Style registry -----------------------------------------------------
def upload_style(self, name: str, sld_path: str | Path) -> None:
"""Create the style, or overwrite it in place if it already exists."""
sld = Path(sld_path).read_bytes()
headers = {"Content-Type": SLD_MIME}
created = self.session.post(
f"{self.rest}/styles",
params={"name": name},
data=sld,
headers=headers,
timeout=self.timeout,
)
if created.status_code in (200, 201):
return
if created.status_code in (403, 409):
# Style exists — overwrite its body with a PUT.
updated = self.session.put(
f"{self.rest}/styles/{name}",
data=sld,
headers=headers,
timeout=self.timeout,
)
updated.raise_for_status()
return
created.raise_for_status()
# --- Layer default style ------------------------------------------------
def current_default(self, layer: str) -> str | None:
r = self.session.get(f"{self.rest}/layers/{layer}.json", timeout=self.timeout)
r.raise_for_status()
return r.json().get("layer", {}).get("defaultStyle", {}).get("name")
def set_default(self, layer: str, style: str) -> None:
body = {"layer": {"defaultStyle": {"name": style}}}
r = self.session.put(
f"{self.rest}/layers/{layer}",
json=body,
timeout=self.timeout,
)
r.raise_for_status()
# --- Orchestration ------------------------------------------------------
def apply(
self, layers: list[str], style: str, *, dry_run: bool = False
) -> RestyleReport:
report = RestyleReport(dry_run=dry_run)
# Snapshot current defaults sequentially first — cheap GETs, and the
# snapshot must be complete before any write so rollback is total.
for layer in layers:
try:
report.snapshot[layer] = self.current_default(layer)
except requests.RequestException as exc:
report.failed[layer] = f"snapshot failed: {exc}"
targets = [lyr for lyr in layers if lyr not in report.failed]
if dry_run:
report.ok = targets # report intent; issue no writes
return report
def worker(layer: str) -> tuple[str, str | None]:
self.limiter.wait()
self.set_default(layer, style)
return layer, None
with ThreadPoolExecutor(max_workers=self.workers) as pool:
futures = {pool.submit(worker, lyr): lyr for lyr in targets}
for fut in as_completed(futures):
layer = futures[fut]
try:
fut.result()
report.ok.append(layer)
except requests.RequestException as exc:
report.failed[layer] = str(exc)
return report
def rollback(self, report: RestyleReport) -> RestyleReport:
"""Restore captured default styles for layers that were modified."""
restore = RestyleReport()
for layer in report.ok: # only touch layers we actually changed
previous = report.snapshot.get(layer)
if not previous:
restore.failed[layer] = "no previous style captured; left as-is"
continue
self.limiter.wait()
try:
self.set_default(layer, previous)
restore.ok.append(layer)
except requests.RequestException as exc:
restore.failed[layer] = str(exc)
return restore
if __name__ == "__main__":
restyler = GeoServerRestyler(
"http://localhost:8080/geoserver",
auth=("admin", "geoserver"),
workers=4,
rate_per_sec=8.0,
)
restyler.upload_style("unified_basemap", "styles/unified_basemap.sld")
target_layers = ["topp:roads", "topp:rivers", "topp:boundaries"]
result = restyler.apply(target_layers, "unified_basemap", dry_run=False)
print(f"updated: {result.ok}")
print(f"failed: {result.failed}")
# Reverse the batch if too many layers failed.
if len(result.failed) > len(result.ok):
undo = restyler.rollback(result)
print(f"rolled back: {undo.ok}")
Step-by-Step Walkthrough
Session and connection pool. GeoServerRestyler.__init__ builds one requests.Session carrying HTTP Basic auth, then mounts an HTTPAdapter whose pool_connections and pool_maxsize both match the worker count. Without this, the default pool size of ten silently caps or serialises connections once your thread count climbs, and threads block waiting for a free socket rather than on GeoServer itself. Reusing one session also keeps the TCP connection warm across hundreds of small PUT requests.
Uploading the SLD once (upload_style). The SLD bytes are read from disk and sent as the raw request body — not as multipart form data — with Content-Type: application/vnd.ogc.sld+xml. GeoServer chooses its style parser from that header, so an application/xml value is misread and can register an empty style. The method POSTs to /rest/styles?name={name} to create the style; if GeoServer answers 403 or 409 (the style already exists) it falls back to PUT /rest/styles/{name} to overwrite the body in place. That create-or-update pattern makes repeated runs idempotent.
Snapshotting before writing (current_default and the loop in apply). Every target layer is GETed first and its existing defaultStyle.name recorded into report.snapshot. This runs sequentially and completes before any write, so the snapshot is total — a prerequisite for a trustworthy rollback. A layer whose snapshot GET fails is dropped from targets and recorded in report.failed, so the batch never writes to a layer it could not first read.
Dry-run mode. When dry_run=True, apply returns after snapshotting with report.ok populated by the intended targets and zero writes issued. That lets you diff the planned change set — and confirm every layer name resolves — against production before committing.
Concurrency with a rate cap. The write phase submits one worker per layer to a ThreadPoolExecutor bounded by max_workers. Each worker calls RateLimiter.wait() before its PUT, and the limiter uses a lock plus a shared _next_allowed timestamp to space requests to at most rate_per_sec across all threads. Bounding both the pool size and the rate keeps the GeoServer catalog lock — and live GetMap traffic — from being starved by the batch. Results are collected as futures complete: successes append to report.ok, RequestExceptions land in report.failed keyed by layer.
Rollback (rollback). Rollback iterates only report.ok — the layers actually modified — and replays each captured snapshot value back through set_default. Layers that had no previous style (a rare freshly published layer with no default) are skipped and noted rather than blanked. Because it touches only modified layers, a partial failure can be reversed without disturbing anything the batch left alone.
Verification
Confirm the new default took effect on a single layer with a direct REST read:
curl -s -u admin:geoserver \
http://localhost:8080/geoserver/rest/layers/topp:roads.json \
| python -m json.tool
Expected output (truncated) — the defaultStyle.name now points at the uploaded SLD:
{
"layer": {
"name": "roads",
"type": "VECTOR",
"defaultStyle": {
"name": "unified_basemap",
"href": "http://localhost:8080/geoserver/rest/styles/unified_basemap.json"
},
"resource": {
"@class": "featureType",
"name": "topp:roads"
}
}
}
The script’s own console output should list every target under updated: with an empty failed: dictionary. For a visual check, request a GetMap for the layer and confirm the rendering matches the new SLD.
Gotchas & Edge Cases
What Content-Type must I send when POSTing an SLD to /rest/styles?
Send Content-Type: application/vnd.ogc.sld+xml for SLD 1.0 documents. GeoServer selects the style parser from this header, so an application/xml or text/xml value is silently misread and can register an empty or unparseable style that renders nothing. SLD 1.1 / Symbology Encoding 1.1 documents use application/vnd.ogc.se+xml instead — match the header to the actual SLD version in the file.
How do I create a style only if it does not already exist?
POST to /rest/styles?name={name} to create a new style. If it already exists GeoServer returns 403 or 409; catch that and PUT the SLD body to /rest/styles/{name} to overwrite it in place. Wrapping this create-or-update logic in one helper — as upload_style does — keeps repeated runs idempotent, which matters when the same job runs from CI on every deploy.
Why cap the request rate when GeoServer already accepts concurrent connections?
Each defaultStyle change forces GeoServer to rewrite catalog XML and invalidate cached configuration, all behind a single catalog write lock. A large unthrottled burst serialises on that lock anyway while live GetMap and GetCapabilities requests queue behind it and start timing out. A bounded ThreadPoolExecutor limits parallelism and the RateLimiter spaces requests so the running server stays responsive to real traffic during the batch.
How does rollback restore the previous default styles?
Before any write, the script GETs each layer and records its current defaultStyle name into report.snapshot. Rollback replays that snapshot by PUTting each captured name back to /rest/layers/{layer}, iterating only the layers in report.ok that were actually modified. A partial failure is therefore fully reversible without touching untouched layers. Persisting the snapshot to disk (JSON) before the write phase means you can still roll back after a crash.
Should styles be global or workspace-scoped for a bulk assignment?
A global style at /rest/styles is visible to every workspace and is the right choice when one SLD applies across the whole server. When the same style name should differ per workspace, create it under /rest/workspaces/{ws}/styles instead and reference the workspace-qualified layer name (ws:layer) in the PUT. Mixing the two silently — a global style name colliding with a workspace one — is a common cause of the wrong symbology appearing after a batch run.
Back to Automating GeoServer with the Python REST API
Related
- Automating GeoServer with the Python REST API — session setup, authentication, and the REST resource model that underpins every catalog operation
- Automating SLD Style Deployment Across Staging and Production — promoting the same style safely through multiple environments
- Layer Publishing Workflows in Python — end-to-end publishing pipelines that assign styles as part of layer creation