GeoServer holds three independent caches — the catalog’s feature type metadata, the in-memory configuration, and rendered tiles in GeoWebCache — and a change upstream clears none of them. Recalculate the affected feature type with PUT …/featuretypes/{name}?recalculate=nativebbox,latlonbbox, truncate that layer in GeoWebCache, and reserve POST /rest/reload for changes made directly on disk.
Adding a column to a PostGIS table changes what the database returns. It does not change what GeoServer thinks the table contains, because the feature type’s attribute list and bounding boxes were captured when the layer was published and are stored in the catalog. It also does not change any tile already rendered.
The result is a system where a schema change appears to have partially landed: a GetFeature returns the new column because it comes from the store, GetCapabilities still advertises the old bounding box because it comes from the catalog, and a GetMap returns the old imagery because it comes from the tile cache. Each of the three is refreshed by a different call, and the instinct to reach for a full catalog reload — which does clear everything — is exactly what makes a routine schema change a service-wide event on a large catalog.
from __future__ import annotations
import time
from dataclasses import dataclass
import requests
from requests.auth import HTTPBasicAuth
@dataclass
class GeoServer:
base: str
user: str
password: str
timeout: int = 120
@property
def auth(self) -> HTTPBasicAuth:
return HTTPBasicAuth(self.user, self.password)
def _call(self, method: str, path: str, **kwargs) -> requests.Response:
resp = requests.request(method, f"{self.base.rstrip('/')}{path}",
auth=self.auth, timeout=self.timeout, **kwargs)
resp.raise_for_status()
return resp
# ── narrowest first ──────────────────────────────────────────────
def recalculate(self, workspace: str, store: str, layer: str,
boxes: str = "nativebbox,latlonbbox") -> None:
"""Re-read one feature type's schema and recompute its extents.
The recalculate parameter is what makes this useful: without it,
GeoServer re-reads the attribute list but keeps the cached bounding
boxes, so a layer whose extent grew still advertises the old one.
"""
self._call("PUT",
f"/rest/workspaces/{workspace}/datastores/{store}"
f"/featuretypes/{layer}",
params={"recalculate": boxes},
json={"featureType": {"name": layer, "enabled": True}})
def truncate_tiles(self, workspace: str, layer: str,
gridset: str = "EPSG:3857", fmt: str = "image/png") -> None:
"""Drop cached tiles for one layer, leaving every other layer alone."""
body = f"""<truncateLayer>
<layerName>{workspace}:{layer}</layerName>
<gridSetId>{gridset}</gridSetId>
<format>{fmt}</format>
</truncateLayer>"""
self._call("POST", f"/gwc/rest/masstruncate",
data=body, headers={"Content-Type": "text/xml"})
def reset(self) -> None:
"""Drop in-memory caches (store connections, resource caches).
Cheap and safe: the catalog is untouched, so nothing is re-parsed
from disk. This is the right call after changing a store's
credentials or connection parameters.
"""
self._call("POST", "/rest/reset")
def reload(self) -> None:
"""Re-read the entire data directory. Expensive — use sparingly.
Only necessary when configuration was changed on disk behind the
REST API's back. Every layer is re-parsed, and on a large catalog
the service is effectively unavailable while it runs.
"""
self._call("POST", "/rest/reload")
# ── verification ─────────────────────────────────────────────────
def advertised_bbox(self, workspace: str, layer: str) -> tuple[float, ...]:
resp = self._call("GET", f"/rest/layers/{workspace}:{layer}.json")
resource = resp.json()["layer"]["resource"]["href"]
detail = requests.get(resource, auth=self.auth, timeout=self.timeout).json()
box = detail["featureType"]["latLonBoundingBox"]
return (box["minx"], box["miny"], box["maxx"], box["maxy"])
def refresh_layer(gs: GeoServer, workspace: str, store: str, layer: str,
wait: float = 1.0) -> dict[str, tuple[float, ...]]:
"""The targeted sequence: recalculate, truncate, verify."""
before = gs.advertised_bbox(workspace, layer)
gs.recalculate(workspace, store, layer)
time.sleep(wait) # the catalog write is not instant
gs.truncate_tiles(workspace, layer)
after = gs.advertised_bbox(workspace, layer)
return {"before": before, "after": after}
if __name__ == "__main__":
gs = GeoServer("https://example.org/geoserver", "admin", "…")
print(refresh_layer(gs, "cadastre", "pg_store", "parcels"))
recalculate is the parameter that does the work. A PUT to a feature type without it re-reads the attribute list and leaves the bounding boxes exactly as they were. That is the case that produces the strangest symptom: new features exist, are returned by GetFeature, and are invisible on a map because they fall outside the advertised extent the renderer clips to. Passing nativebbox,latlonbbox recomputes both.
GeoWebCache is a separate service with a separate REST API. It lives under /gwc/rest and knows nothing about catalog changes. A truncate scoped to one layer and one gridset is fast; the mass-truncate endpoint with no layer name is not, and it discards work that will have to be re-rendered.
reset and reload are not synonyms. reset drops in-memory caches — store connections, resource pools — while leaving the catalog alone, which is what you want after changing a database password. reload re-parses the entire data directory, which is what you want after editing XML on disk and almost never otherwise. Confusing them is how a credential rotation becomes a minute of downtime.
Verify against the catalog, not the return code. Both calls return 200 on success and neither tells you what changed. Reading the advertised bounding box before and after gives a concrete before-and-after that can be asserted in a pipeline.
Run the targeted refresh and compare the extents:
python refresh_layer.py
{'before': (8.4501, 47.3201, 8.6103, 47.4402),
'after': (8.4501, 47.3201, 8.6890, 47.4402)}
An eastern edge that moved is proof the recalculation happened. Then confirm the tile cache actually dropped:
before = requests.get(tile_url).headers.get("geowebcache-cache-result")
gs.truncate_tiles("cadastre", "parcels")
after = requests.get(tile_url).headers.get("geowebcache-cache-result")
print(before, "->", after) # HIT -> MISS
The geowebcache-cache-result header going from HIT to MISS is the only direct evidence that the truncate reached the right layer.
A full reload blocks requests. On a catalog with hundreds of layers, POST /rest/reload re-parses every one, and requests during that window queue or fail. It is the correct call after restoring configuration files on disk and the wrong one after a schema change, which is why the narrow operations exist.
Recalculating an empty layer sets a null bounding box. If the table is empty at the moment of recalculation, GeoServer records no extent, and the layer then renders nothing until it is recalculated again with data present. Sequencing matters: load the data, then recalculate.
Truncate is asynchronous on large layers. The call returns promptly while the deletion proceeds in the background, so an immediate tile request may still hit a cached tile. Polling the geowebcache-cache-result header, as above, is more reliable than a fixed sleep.
Automate this alongside publishing. A layer publish and its first recalculation belong in the same pipeline step, which is how Layer Publishing Workflows in Python sequences it — otherwise the first user request is what discovers the missing extent.
When configuration changed on disk without going through the REST API — a restored data directory, a hand-edited XML file, a configuration file dropped in by a deployment. For anything done through the REST API itself, the catalog is already current and a reload only re-parses work that was never stale.
Because the recalculate parameter was absent. GeoServer re-reads the attribute list on a feature type PUT but keeps the cached native and geographic bounding boxes unless explicitly asked to recompute them. This is the single most common surprise in GeoServer automation.
Yes, for the layer, gridset and format you named. That is the intent, and it is why the operation should be scoped as narrowly as possible: re-rendering a fully seeded high-zoom layer can take hours, so truncating more than changed is expensive in a way that is not visible until traffic arrives.
Yes. It drops in-memory caches and pooled connections, so the next few requests pay a reconnection cost and everything else continues. It is the correct response to a rotated database credential, and unlike reload it does not re-parse the catalog.
Back to Automating GeoServer With the Python REST API
Related