Building WFS Transaction Insert Requests in Python
TL;DR: Build a <wfs:Transaction> containing a <wfs:Insert> with lxml.etree, declaring the wfs, gml, filter, and target feature namespaces on the element nsmap, and embed the geometry as GML with an srsName whose axis order you respect. POST the serialised XML to the WFS endpoint with requests, then read totalInserted and the new feature IDs from the <wfs:TransactionResponse>. WFS 1.1.0 and 2.0.0 use different namespace URIs, different identifier elements (ogc:FeatureId vs fes:ResourceId), and different GML versions, so version-branch both the builder and the parser.
The Core Challenge
The Web Feature Service transactional profile (WFS-T) is the write path of the OGC feature stack: where a read-only GetFeature hands you geometries, a Transaction lets you insert, update, and delete them. The obstacle is that a Transaction is not a tidy JSON body — it is a precisely namespaced XML document in which a single wrong namespace URI, a missing gml:id, or a reversed coordinate pair produces either an ows:ExceptionReport or, worse, a silently misplaced feature. Everything covered here builds on the operation semantics in the WFS Transactional Operations Deep Dive; this page focuses narrowly on assembling a valid <wfs:Insert> and reading back what the server created.
Three things must line up before the server will accept the insert. First, the feature type namespace — the XML namespace that the target layer’s elements live in — is server-specific and comes from the DescribeFeatureType response, not from any OGC constant. Second, the coordinate axis order must match the srsName you declare: a URN-form CRS such as urn:ogc:def:crs:EPSG::4326 is latitude-first, while the short EPSG:4326 is treated as longitude-first by most servers. Third, the version namespaces must be internally consistent — mixing a WFS 2.0.0 envelope with a GML 3.1.1 geometry is the fastest way to a rejected transaction. The breaking differences between the two active versions are catalogued in WFS 2.0 vs 1.1.0 Breaking Changes for Backend Devs.
Production-Ready Code
The script below builds the transaction, POSTs it, and parses the response. It branches on the version string in exactly two places — the namespace map and the identifier element — which keeps a single code path working against both WFS 1.1.0 and WFS 2.0.0 servers. The only third-party dependencies are lxml and requests.
"""
wfs_insert.py — build, POST, and parse a WFS-T Insert transaction.
Dependencies (pip install):
lxml>=5.0, requests>=2.31
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import requests
from lxml import etree
# ---------------------------------------------------------------------------
# Namespace maps. The URIs differ between versions; mixing them is the single
# most common cause of a silently rejected transaction.
# ---------------------------------------------------------------------------
NAMESPACES: dict[str, dict[str, str]] = {
"1.1.0": {
"wfs": "http://www.opengis.net/wfs", # WFS 1.1.0
"gml": "http://www.opengis.net/gml", # GML 3.1.1
"ogc": "http://www.opengis.net/ogc", # filter / FeatureId
},
"2.0.0": {
"wfs": "http://www.opengis.net/wfs/2.0", # WFS 2.0.0
"gml": "http://www.opengis.net/gml/3.2", # GML 3.2.1
"fes": "http://www.opengis.net/fes/2.0", # filter / ResourceId
},
}
OWS_NS = "http://www.opengis.net/ows/1.1" # exception reports (both versions)
@dataclass
class FeatureToInsert:
"""A single point feature destined for a WFS-published layer."""
feature_prefix: str # prefix for the target namespace, e.g. "app"
feature_ns: str # target namespace URI (from DescribeFeatureType)
feature_type: str # local element name, e.g. "points_of_interest"
geometry_property: str # geometry column, e.g. "geom" or "the_geom"
lon: float
lat: float
attributes: dict[str, str] = field(default_factory=dict)
srs_name: str = "urn:ogc:def:crs:EPSG::4326" # URN form is latitude-first
def build_transaction(feature: FeatureToInsert, version: str = "2.0.0") -> bytes:
"""Assemble a wfs:Transaction containing one wfs:Insert; return UTF-8 bytes."""
ns = NAMESPACES[version]
wfs, gml = ns["wfs"], ns["gml"]
# Combine the OGC namespaces with the target feature namespace on the root
# so every prefix is declared once, at the top of the document.
nsmap = dict(ns)
nsmap[feature.feature_prefix] = feature.feature_ns
root = etree.Element(etree.QName(wfs, "Transaction"), nsmap=nsmap)
root.set("service", "WFS")
root.set("version", version)
insert = etree.SubElement(root, etree.QName(wfs, "Insert"))
# The feature element itself lives in the target (server-specific) namespace.
feat = etree.SubElement(insert, etree.QName(feature.feature_ns, feature.feature_type))
# Non-spatial attribute properties come first, in schema order.
for name, value in feature.attributes.items():
prop = etree.SubElement(feat, etree.QName(feature.feature_ns, name))
prop.text = value
# The geometry property wraps a GML geometry.
geom_prop = etree.SubElement(feat, etree.QName(feature.feature_ns, feature.geometry_property))
point = etree.SubElement(geom_prop, etree.QName(gml, "Point"))
point.set("srsName", feature.srs_name)
if version == "2.0.0":
# GML 3.2 requires gml:id on every geometry; GML 3.1.1 does not.
point.set(etree.QName(gml, "id"), f"g_{feature.feature_type}_1")
# A single point uses gml:pos. A LineString/Polygon ring would use
# gml:posList; the deprecated GML 2 gml:coordinates form is avoided here.
pos = etree.SubElement(point, etree.QName(gml, "pos"))
if feature.srs_name.startswith("urn:"):
pos.text = f"{feature.lat} {feature.lon}" # URN CRS -> latitude first
else:
pos.text = f"{feature.lon} {feature.lat}" # short EPSG code -> longitude first
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)
def post_transaction(url: str, xml_body: bytes, timeout: int = 30) -> bytes:
"""POST the transaction body and return the raw response bytes."""
headers = {"Content-Type": "application/xml"}
response = requests.post(url, data=xml_body, headers=headers, timeout=timeout)
response.raise_for_status()
return response.content
def parse_transaction_response(xml_bytes: bytes, version: str = "2.0.0") -> dict[str, Any]:
"""Extract totalInserted and the new feature IDs, or raise on an exception report."""
ns = NAMESPACES[version]
root = etree.fromstring(xml_bytes)
# A failed transaction returns ows:ExceptionReport rather than a summary.
exception = root.find(f".//{{{OWS_NS}}}ExceptionText")
if exception is not None:
raise RuntimeError(f"WFS transaction failed: {exception.text}")
total_inserted = 0
summary = root.find(f"{{{ns['wfs']}}}TransactionSummary")
if summary is not None:
node = summary.find(f"{{{ns['wfs']}}}totalInserted")
if node is not None and node.text:
total_inserted = int(node.text)
inserted_ids: list[str] = []
results = root.find(f"{{{ns['wfs']}}}InsertResults")
if results is not None:
for feat in results.findall(f"{{{ns['wfs']}}}Feature"):
if version == "2.0.0":
# WFS 2.0.0: fes:ResourceId with a rid attribute.
for rid in feat.findall(f"{{{ns['fes']}}}ResourceId"):
inserted_ids.append(rid.get("rid"))
else:
# WFS 1.1.0: ogc:FeatureId with a fid attribute.
for fid in feat.findall(f"{{{ns['ogc']}}}FeatureId"):
inserted_ids.append(fid.get("fid"))
return {"total_inserted": total_inserted, "inserted_ids": inserted_ids}
if __name__ == "__main__":
poi = FeatureToInsert(
feature_prefix="app",
feature_ns="http://www.example.com/app",
feature_type="points_of_interest",
geometry_property="geom",
lon=-0.1276,
lat=51.5072,
attributes={"name": "Trafalgar Square", "category": "landmark"},
)
body = build_transaction(poi, version="2.0.0")
print(body.decode())
# response = post_transaction("https://demo.example.com/geoserver/wfs", body)
# print(parse_transaction_response(response, version="2.0.0"))
Step-by-Step Walkthrough
Version-aware namespace selection. NAMESPACES holds one dictionary per version. The build_transaction function reads ns["wfs"] and ns["gml"] from it, so switching versions swaps every URI at once. This is deliberate: the wfs, gml, and filter URIs must all belong to the same generation. A WFS 2.0.0 Transaction carrying GML 3.1.1 (http://www.opengis.net/gml) geometries will be rejected by a strict validator, because the server’s schema import expects GML 3.2 (http://www.opengis.net/gml/3.2).
Declaring the feature namespace on the root. The feature type lives in a server-specific namespace supplied by DescribeFeatureType, not in the wfs namespace. Adding feature.feature_prefix to the nsmap on the root element declares that prefix once, so lxml serialises xmlns:app="..." at the top rather than repeating it on every element. The feature element and each attribute property are created with etree.QName(feature.feature_ns, ...) so they land in that target namespace.
Building the geometry. The geometry property (geom here) wraps a gml:Point, and the srsName attribute names the CRS. For WFS 2.0.0 the code sets a gml:id on the point — GML 3.2 makes this attribute mandatory on every geometry element, whereas GML 3.1.1 under WFS 1.1.0 does not. A single point uses gml:pos; a LineString or Polygon ring would use gml:posList (a whitespace-separated coordinate run), while the older gml:coordinates form from GML 2 is deprecated and best avoided.
Respecting axis order. The pos.text branch is the subtle part. When srs_name is a URN such as urn:ogc:def:crs:EPSG::4326, the server honours the CRS-defined axis order, which for EPSG:4326 is latitude-first, so the coordinate string is "{lat} {lon}". The short form EPSG:4326 is interpreted longitude-first by most servers. Getting this wrong does not error — it stores the geometry transposed. The same axis rules govern read requests, as detailed in the WFS Transactional Operations Deep Dive.
Posting and parsing. post_transaction sends the serialised bytes with Content-Type: application/xml and calls raise_for_status() so transport-level failures surface immediately. parse_transaction_response first looks for an ows:ExceptionText — both versions report failures through an ows:ExceptionReport — and raises if one is present. It then reads wfs:totalInserted from wfs:TransactionSummary and collects identifiers from wfs:InsertResults, branching to fes:ResourceId (attribute rid) for 2.0.0 or ogc:FeatureId (attribute fid) for 1.1.0.
Verification
Run the module directly to print the request body without touching a server:
python wfs_insert.py
Expected output (a well-formed WFS 2.0.0 insert):
<?xml version='1.0' encoding='UTF-8'?>
<wfs:Transaction xmlns:wfs="http://www.opengis.net/wfs/2.0" xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:fes="http://www.opengis.net/fes/2.0" xmlns:app="http://www.example.com/app" service="WFS" version="2.0.0">
<wfs:Insert>
<app:points_of_interest>
<app:name>Trafalgar Square</app:name>
<app:category>landmark</app:category>
<app:geom>
<gml:Point srsName="urn:ogc:def:crs:EPSG::4326" gml:id="g_points_of_interest_1">
<gml:pos>51.5072 -0.1276</gml:pos>
</gml:Point>
</app:geom>
</app:points_of_interest>
</wfs:Insert>
</wfs:Transaction>
Against a live endpoint, a successful parse_transaction_response returns a dictionary such as {'total_inserted': 1, 'inserted_ids': ['points_of_interest.42']}. Confirm that total_inserted matches the number of features you sent and that each returned ID is a resolvable feature identifier you can pass straight into a follow-up GetFeature by ID.
Gotchas & Edge Cases
Which namespace URIs change between WFS 1.1.0 and WFS 2.0.0?
All three core URIs move. The wfs namespace changes from http://www.opengis.net/wfs to http://www.opengis.net/wfs/2.0, gml changes from http://www.opengis.net/gml (GML 3.1.1) to http://www.opengis.net/gml/3.2 (GML 3.2.1), and the filter/identifier namespace changes from ogc (http://www.opengis.net/ogc) to fes (http://www.opengis.net/fes/2.0). GML 3.2 additionally requires a gml:id attribute on every geometry element, which GML 3.1.1 does not — omitting it is a common cause of 2.0.0 rejections.
Why is my inserted point in the wrong location after a WFS-T Insert?
It is almost always axis order. When srsName is a URN such as urn:ogc:def:crs:EPSG::4326, the server honours the CRS-defined axis order, which for EPSG:4326 is latitude-first, so gml:pos must read lat lon. The short form EPSG:4326 is interpreted longitude-first by most servers. Emitting coordinates in the wrong order does not raise an error — it silently stores the geometry in the wrong hemisphere. Pick one srsName convention and make the coordinate serialisation match it, exactly as the pos.text branch does.
How do I read the new feature IDs from the TransactionResponse?
Read the count from wfs:TransactionSummary/wfs:totalInserted, then collect identifiers from wfs:InsertResults. In WFS 2.0.0 each inserted feature carries a fes:ResourceId with a rid attribute; in WFS 1.1.0 it carries an ogc:FeatureId with a fid attribute. Branch on the version when parsing so the correct element and attribute name are used — a 2.0.0 parser pointed at a 1.1.0 response will find zero IDs even though the insert succeeded.
Is a WFS-T Insert atomic, and do I need to lock features first?
A single Transaction is atomic by specification: the server either commits all of its Insert, Update, and Delete operations or rolls the whole thing back, so a multi-feature Insert never half-applies. Explicit locking with GetFeatureWithLock or LockFeature is only needed for read-modify-write cycles where you must guarantee no other client edits a feature between your read and your Update or Delete. Pure inserts create new features and need no prior lock. If you are weighing this write path against the newer REST alternative, the trade-offs are laid out in OGC API Features and the REST Transition.
Back to WFS Transactional Operations Deep Dive
Related
- WFS Transactional Operations Deep Dive — the full Transaction envelope, Update and Delete operations, and locking semantics
- WFS 2.0 vs 1.1.0 Breaking Changes for Backend Devs — namespace, GML, and identifier differences that break cross-version clients
- OGC API Features and the REST Transition — the JSON/REST successor to XML-based WFS transactions