Reloading the GeoServer Catalog and Clearing Caches From Python

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.

The Core Challenge: Three Caches, None of Them Coherent

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.

Five refresh scopes, from one layer to the entire catalog A five-row grid of GeoServer refresh operations ordered by blast radius. A catalog reload re-reads the entire data directory; a reset drops in-memory caches while leaving the catalog intact; recalculating one feature type refreshes a single layer's schema and bounding box; replacing a style refreshes that style alone; and a GeoWebCache truncate clears cached tiles for one layer. What needs refreshing Endpoint Blast radius Catalog reload POST /rest/reload re-reads the whole data directory Config reload POST /rest/reset drops caches, keeps the catalog Feature type reload PUT …/featuretypes/{n}?recalculate= one layer's schema and bbox Style cache PUT /rest/styles/{n} one style Tile cache truncate POST /gwc/rest/…/truncate one layer's cached tiles

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.

Production-Ready Code

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"))

Step-by-Step Walkthrough

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.

Three independent caches, cleared three different ways A decision diamond for a schema change that is not visible. A stale attribute list means the feature type must be re-read with a recalculate parameter; an unchanged bounding box means GeoServer kept the cached extents because recalculation was not requested; unchanged rendered tiles mean GeoWebCache still holds the old imagery regardless of the catalog; and once all three are cleared, an unchanged result means the underlying data did not change. A schema change is not visible through WMS. Which cache still holds the old value? recalculate=nativebbox,latlonbbox the feature type was cached recalculate both boxes GeoServer does not re-derive them Truncate GeoWebCache tiles outlive the catalog Change is live the data itself is unchanged attribute list stale bbox unchanged tiles unchanged all cleared

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.

Verification

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.

Refresh the layer that changed, not the catalog A four-stage refresh sequence. A schema change is made upstream in the data store; the affected layer alone is recalculated through a scoped PUT; that layer's cached tiles are truncated in GeoWebCache; and the result is verified through the capabilities document and a rendered request rather than assumed. Change the source new column in PostGIS Recalculate the layer one PUT, scoped Truncate its tiles GeoWebCache, that layer Verify GetCapabilities + GetMap upstream targeted targeted

Gotchas & Edge Cases

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.

Frequently Asked Questions

When do I actually need a full catalog reload?

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.

Why did my bounding box not change after a PUT?

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.

Does truncating GeoWebCache delete the tiles from disk?

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.

Is reset safe to run in production?

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