Creating GeoServer Workspaces and Datastores with Python

TL;DR: Drive the GeoServer REST API with a single authenticated requests.Session. For each resource, GET first: if it is missing, POST a JSON body; if it already exists, treat the 200 or 409 response as success and move on. A workspace is a two-line JSON body against /geoserver/rest/workspaces; a PostGIS datastore is a connectionParameters block with dbtype=postgis against /geoserver/rest/workspaces/{ws}/datastores. Never log the passwd, and return whether each resource was created, exists, or updated.

The Core Challenge

Provisioning scripts run more than once. A CI pipeline re-applies the same infrastructure on every deploy; an engineer re-runs a bootstrap script after a partial failure. A naive POST to create a GeoServer workspace works the first time and then fails on the second run, because the workspaces collection endpoint rejects a duplicate name. If your script aborts on that failure, it never reaches the datastore step, and the run is stuck.

The fix is idempotency: the script must converge on the same end state regardless of how many times it runs. In REST terms that means separating “does this resource exist?” from “create it”, and treating an already-present resource as a non-error. GeoServer makes this awkward in two ways. First, POST on a collection endpoint is not idempotent — it creates and complains about duplicates — while PUT on a resource endpoint is idempotent but requires the resource to already exist. Second, GeoServer’s conflict signalling is inconsistent across versions: a duplicate workspace may return 409 Conflict on recent builds or 500 on older ones. A robust routine handles both.

A GeoServer workspace is a named container that scopes layers and stores — it maps directly to the namespace prefix in your WMS and WFS GetCapabilities output. A datastore is a connection to a source of vector features; for PostGIS it holds the JDBC connection parameters GeoServer uses to read tables. You must create the workspace before the datastore, because the datastore endpoint is nested beneath the workspace. This ordering, plus the check-then-create pattern, is the whole job.

Idempotent Ensure Flow for Workspace and Datastore Flowchart showing the ensure logic: a GET request checks whether a resource exists; a 200 response means skip creation, a 404 triggers a POST, and a 409 or 500 duplicate response from the POST is folded back into the exists path so the run continues. ensure_resource(session, url, body) GET resource URL status code? 200 vs 404 return status = "exists" 200 POST JSON body 404 POST result? 201 vs 409/500 return status = "created" 201 409 / 500 duplicate → treat as exists

Production-Ready Code

The script below ensures both resources. It uses only requests beyond the standard library, reads the database password from an environment variable, and returns a small status dictionary so a caller (or a CI log) can see exactly what changed.

"""
provision_geoserver.py — idempotent workspace + PostGIS datastore creation.

Dependencies (pip install):
    requests>=2.31

Environment:
    PGPASSWORD  — PostGIS role password (never hard-code or log this)
"""
from __future__ import annotations

import os
import urllib.parse
from dataclasses import dataclass
from typing import Any, Literal

import requests

Status = Literal["created", "exists", "updated"]

# GeoServer signals a duplicate on the collection endpoint inconsistently
# across versions: 409 on recent builds, 500 with a duplicate message on older.
_DUPLICATE_CODES = {409, 500}


@dataclass
class GeoServerClient:
    base_url: str          # e.g. "http://localhost:8080/geoserver"
    user: str
    password: str
    timeout: int = 30

    def session(self) -> requests.Session:
        """One Session per client: shared TCP connection, auth, and JSON headers."""
        s = requests.Session()
        s.auth = (self.user, self.password)
        s.headers.update({
            "Accept": "application/json",
            "Content-Type": "application/json",
        })
        return s

    def rest(self, *parts: str) -> str:
        """Build a REST URL, percent-encoding each path segment."""
        encoded = "/".join(urllib.parse.quote(p, safe="") for p in parts)
        return f"{self.base_url.rstrip('/')}/rest/{encoded}"


def ensure_workspace(client: GeoServerClient, s: requests.Session,
                     name: str) -> Status:
    """Create the workspace if absent; treat an existing one as success."""
    resource_url = client.rest("workspaces", name)
    head = s.get(resource_url, timeout=client.timeout)
    if head.status_code == 200:
        return "exists"
    if head.status_code != 404:
        head.raise_for_status()  # surface auth (401) or server (503) faults

    body = {"workspace": {"name": name}}
    created = s.post(client.rest("workspaces"), json=body, timeout=client.timeout)
    if created.status_code in (200, 201):
        return "created"
    if created.status_code in _DUPLICATE_CODES:
        return "exists"          # lost a create race; the state we want holds
    created.raise_for_status()
    return "created"


def ensure_postgis_datastore(client: GeoServerClient, s: requests.Session,
                             workspace: str, store: str, *,
                             host: str, port: int, database: str,
                             schema: str, user: str, passwd: str) -> Status:
    """Create (POST) or reconcile (PUT) a PostGIS datastore idempotently."""
    connection_parameters = {
        "host": host,
        "port": str(port),
        "database": database,
        "schema": schema,
        "user": user,
        "passwd": passwd,          # sent over the wire, never logged
        "dbtype": "postgis",
    }
    payload = {
        "dataStore": {
            "name": store,
            "connectionParameters": {
                "entry": [{"@key": k, "$": v}
                          for k, v in connection_parameters.items()],
            },
        }
    }

    resource_url = client.rest("workspaces", workspace, "datastores", store)
    head = s.get(resource_url, timeout=client.timeout)
    if head.status_code == 200:
        # Reconcile connection parameters on an existing store.
        updated = s.put(resource_url, json=payload, timeout=client.timeout)
        updated.raise_for_status()
        return "updated"
    if head.status_code != 404:
        head.raise_for_status()

    collection_url = client.rest("workspaces", workspace, "datastores")
    created = s.post(collection_url, json=payload, timeout=client.timeout)
    if created.status_code in (200, 201):
        return "created"
    if created.status_code in _DUPLICATE_CODES:
        return "exists"
    created.raise_for_status()
    return "created"


def provision() -> dict[str, Status]:
    password = os.environ["PGPASSWORD"]      # KeyError early if unset
    client = GeoServerClient(
        base_url="http://localhost:8080/geoserver",
        user="admin",
        password=os.environ.get("GEOSERVER_PASSWORD", "geoserver"),
    )
    with client.session() as s:
        ws_status = ensure_workspace(client, s, "urban_analytics")
        ds_status = ensure_postgis_datastore(
            client, s,
            workspace="urban_analytics",
            store="parcels_pg",
            host="db.internal",
            port=5432,
            database="gis",
            schema="public",
            user="geoserver_ro",
            passwd=password,
        )
    return {"workspace": ws_status, "datastore": ds_status}


if __name__ == "__main__":
    result = provision()
    # Safe to print: contains only resource names and status, never the password.
    print(f"workspace: {result['workspace']}, datastore: {result['datastore']}")

Step-by-Step Walkthrough

One session, reused. GeoServerClient.session() returns a requests.Session carrying the basic-auth credentials and JSON headers once. Every subsequent get, post, and put reuses the same underlying TCP connection through urllib3’s connection pool, which avoids a fresh TLS and auth handshake on each call. The with client.session() as s: block in provision() closes the pool cleanly when the run finishes. This is the same connection-reuse discipline covered in the GeoServer REST automation guide, and it matters more as the number of resources per run grows.

URL construction. GeoServerClient.rest() joins path segments and passes each through urllib.parse.quote with safe="", so a workspace named with a space or reserved character does not corrupt the path. GeoServer’s REST tree is strictly nested: workspaces/{ws}/datastores/{store} is the resource endpoint, while workspaces/{ws}/datastores (no trailing name) is the collection endpoint you POST to.

ensure_workspace — check then create. The function GETs the resource endpoint first. A 200 means the workspace is already present and it returns "exists" without writing anything. A 404 is the expected “not there yet” signal, so it POSTs the minimal {"workspace": {"name": name}} body to the collection endpoint. Any other GET status — a 401 from bad credentials, a 503 from a starting server — is escalated through raise_for_status() rather than silently swallowed, because those are real faults, not idempotency cases. If two provisioning runs race and the POST loses, GeoServer returns a code in _DUPLICATE_CODES and the function folds that back into "exists".

ensure_postgis_datastore — the connection payload. The connectionParameters block is where GeoServer differs from a plain JSON API. It expects an entry array of {"@key": ..., "$": ...} objects — the JSON encoding of the underlying XML key/value pairs. The mandatory keys for a PostGIS store are host, port, database, schema, user, passwd, and dbtype, and dbtype must be exactly postgis for GeoServer to select the PostGIS JDBC factory. Note that port is serialised as a string; GeoServer parses these entries as text.

POST versus PUT. When the GET finds an existing store (200), the function PUTs the full payload to the resource endpoint to reconcile any drifted connection parameters and returns "updated". When the store is absent (404), it POSTs to the collection endpoint and returns "created". This split is deliberate: POST creates but is not idempotent, whereas PUT on the resource URL is idempotent and is the correct verb for updates. The same shapefile-oriented publishing flow appears in the auto-publish shapefiles to a GeoServer workspace walkthrough.

Password hygiene. The password enters only through os.environ["PGPASSWORD"] and travels only inside the passwd entry of the payload, which requests sends in the request body. Nothing interpolates it into a log line, and the final print emits only resource names and statuses. Keeping the password out of source, out of the process argument list, and out of logs is the baseline for the wider environment parity practice of promoting the same provisioning code across staging and production with only the secrets swapped.

Verification

Run the script twice against a local GeoServer. The first run creates both resources; the second proves idempotency by reporting them as already present.

export PGPASSWORD='••••••••'
python provision_geoserver.py   # first run
python provision_geoserver.py   # second run

Expected output across the two runs:

workspace: created, datastore: created
workspace: exists, datastore: updated

Confirm the resources landed with a direct REST query. The workspace should appear in the JSON listing, and the datastore should report dbtype as postgis:

curl -s -u admin:geoserver \
  http://localhost:8080/geoserver/rest/workspaces/urban_analytics/datastores/parcels_pg.json \
  | python -m json.tool

The response includes "name": "parcels_pg", "enabled": true, and a connectionParameters block. GeoServer omits the passwd value from GET responses, so a leaked listing never exposes the credential.

Gotchas & Edge Cases

Should I POST to create a datastore or PUT to update it?

POST to /geoserver/rest/workspaces/{ws}/datastores creates a new store and returns 201, but fails if the store already exists. PUT to /geoserver/rest/workspaces/{ws}/datastores/{name} updates an existing store and is idempotent. For a routine that is safe to re-run, GET the resource endpoint first: on 404 POST to create, on 200 PUT to reconcile the connectionParameters. That is exactly what ensure_postgis_datastore does — POST for the create path, PUT for the update path.

Why does GeoServer return 409 or 500 when I create a workspace that already exists?

The workspaces collection endpoint rejects a POST for a name that is already taken. Recent GeoServer versions return 409 Conflict; older builds return 500 with a duplicate-name message in the body. Because the signalling is inconsistent, the code treats both codes as “the resource is present” via the _DUPLICATE_CODES set and continues. Aborting on that response would leave the datastore step unreached on any re-run.

How do I keep the PostGIS password out of logs and tracebacks?

Read it from an environment variable such as PGPASSWORD, never interpolate it into log or print strings, and redact the passwd key before dumping any connectionParameters dict for debugging. requests does not log request bodies by default, but a global logging.basicConfig(level=logging.DEBUG) combined with urllib3 connection-pool debug logging can expose the wire payload — so keep HTTP debug logging off in production. GeoServer itself strips passwd from GET responses, so reading back a store never re-exposes it.

Do I need to URL-encode the workspace name in the REST path?

Yes, if a name can contain spaces or reserved characters. The rest() helper passes each segment through urllib.parse.quote with safe="" so the path stays valid. The safer habit is to restrict workspace and datastore names to lowercase letters, digits, and hyphens, because those names also become the namespace prefix and layer identifiers in your WMS and WFS GetCapabilities output, where awkward characters cause client-side breakage.


Back to Automating GeoServer with the Python REST API

Related