A WFS-T Update or Delete is a feature type plus an fes:Filter, and the filter is the entire safety mechanism. Send the same predicate first as a GetFeature with resultType=hits to learn how many features it selects, assert that against what you expect, then run the transaction and check totalUpdated or totalDeleted matches. An omitted filter matches every feature in the type, and nothing asks you to confirm.
Insert is forgiving — the worst case is duplicate features you can identify and remove. Update and Delete are not. Both take a filter that selects an arbitrary set, both apply to every selected feature, and neither has a dry-run mode in the specification. A filter that is one predicate too broad is a data loss event that the protocol reports as a successful transaction.
Compounding this, fes:Filter is optional on both operations. A Delete element carrying a feature type and no filter is a valid, well-formed request meaning “delete every feature of this type”, and a server that implements the specification correctly will do exactly that and return a summary saying so. The Insert guide covers the namespace requirements these share; this one is about not selecting more than you meant to.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable
from xml.etree import ElementTree as ET
import requests
WFS = "http://www.opengis.net/wfs/2.0"
FES = "http://www.opengis.net/fes/2.0"
NS = {"wfs": WFS, "fes": FES}
for prefix, uri in NS.items():
ET.register_namespace(prefix, uri)
class BlastRadiusError(RuntimeError):
"""The filter selects a different number of features than expected."""
@dataclass
class Summary:
inserted: int
updated: int
deleted: int
replaced: int
def _filter_by_ids(ids: Iterable[str]) -> ET.Element:
"""Identity selection — the only form whose blast radius is exact."""
flt = ET.Element(f"{{{FES}}}Filter")
ids = list(ids)
parent = flt if len(ids) == 1 else ET.SubElement(flt, f"{{{FES}}}Or")
for rid in ids:
ET.SubElement(parent, f"{{{FES}}}ResourceId", rid=rid)
return flt
def _filter_equals(prop: str, value: str) -> ET.Element:
flt = ET.Element(f"{{{FES}}}Filter")
node = ET.SubElement(flt, f"{{{FES}}}PropertyIsEqualTo")
ET.SubElement(node, f"{{{FES}}}ValueReference").text = prop
ET.SubElement(node, f"{{{FES}}}Literal").text = value
return flt
def count_matching(url: str, type_name: str, filter_el: ET.Element,
timeout: int = 60) -> int:
"""How many features this filter selects — without transferring any.
resultType=hits returns a header-only response carrying numberMatched,
so this costs one cheap round trip regardless of the match size.
"""
body = ET.Element(f"{{{WFS}}}GetFeature", version="2.0.0", service="WFS",
resultType="hits")
query = ET.SubElement(body, f"{{{WFS}}}Query", typeNames=type_name)
query.append(filter_el)
resp = requests.post(url, timeout=timeout,
data=ET.tostring(body, encoding="utf-8"),
headers={"Content-Type": "text/xml"})
resp.raise_for_status()
return int(ET.fromstring(resp.content).get("numberMatched", "0"))
def _transaction(url: str, operation: ET.Element, timeout: int) -> Summary:
tx = ET.Element(f"{{{WFS}}}Transaction", version="2.0.0", service="WFS")
tx.append(operation)
resp = requests.post(url, timeout=timeout,
data=ET.tostring(tx, encoding="utf-8"),
headers={"Content-Type": "text/xml"})
resp.raise_for_status()
root = ET.fromstring(resp.content)
# An exception report arrives with HTTP 200 — the whole transaction
# rolled back, and nothing but the body says so.
for el in root.iter():
if el.tag.rsplit("}", 1)[-1] == "Exception":
raise RuntimeError(
"transaction rolled back: "
+ " ".join((t.text or "").strip() for t in el))
summary = root.find(f"{{{WFS}}}TransactionSummary")
read = lambda tag: int((summary.findtext(f"{{{WFS}}}{tag}") or "0")
if summary is not None else 0)
return Summary(read("totalInserted"), read("totalUpdated"),
read("totalDeleted"), read("totalReplaced"))
def update(url: str, type_name: str, changes: dict[str, Any],
filter_el: ET.Element, expected: int, timeout: int = 120) -> Summary:
"""Apply property changes to exactly `expected` features, or refuse."""
if len(filter_el) == 0:
raise BlastRadiusError("refusing an Update with an empty filter")
matched = count_matching(url, type_name, filter_el)
if matched != expected:
raise BlastRadiusError(
f"filter selects {matched} feature(s), expected {expected}")
op = ET.Element(f"{{{WFS}}}Update", typeName=type_name)
for prop, value in changes.items():
node = ET.SubElement(op, f"{{{WFS}}}Property")
ET.SubElement(node, f"{{{WFS}}}ValueReference").text = prop
# An omitted Value element sets the property to null — which is
# different from setting it to an empty string.
if value is not None:
ET.SubElement(node, f"{{{WFS}}}Value").text = str(value)
op.append(filter_el)
result = _transaction(url, op, timeout)
if result.updated != expected:
raise BlastRadiusError(
f"server reported {result.updated} updated, expected {expected}")
return result
def delete(url: str, type_name: str, ids: Iterable[str],
timeout: int = 120) -> Summary:
"""Delete by identity only. Attribute-filtered deletes belong in review."""
ids = list(ids)
if not ids:
raise BlastRadiusError("refusing a Delete with no resource ids")
op = ET.Element(f"{{{WFS}}}Delete", typeName=type_name)
op.append(_filter_by_ids(ids))
result = _transaction(url, op, timeout)
if result.deleted != len(ids):
raise BlastRadiusError(
f"server deleted {result.deleted}, expected {len(ids)}")
return result
Refuse the empty filter structurally. update checks that the filter element has children before doing anything else. An fes:Filter with no predicate inside it is exactly the “matches everything” case, and it arises naturally from a filter builder handed an empty list of conditions — which is why the check belongs in the transaction function rather than in the caller.
resultType=hits is the dry run the protocol does not have. The same filter element is appended to a GetFeature and the response carries numberMatched without a single feature crossing the wire. Comparing that against a caller-supplied expectation converts “I think this updates about a dozen roads” into an assertion that fails loudly when the predicate is wrong.
Delete takes identifiers, not predicates. delete above deliberately accepts only a list of resource identifiers. Attribute-filtered deletion is occasionally necessary, but it should be a separate, explicitly named function that a reviewer notices, because the difference between deleting twelve features and twelve thousand is one predicate.
An omitted Value means null, not empty. A wfs:Property containing a ValueReference and no Value sets the property to null. Writing an empty Value element sets it to an empty string. These are different values with different meanings to every downstream consumer, and the code distinguishes them by testing for None explicitly rather than for falsiness.
Check the count before, run the transaction, and confirm the summary:
predicate = _filter_equals("surface", "gravel")
print("would touch:", count_matching(url, "ns:roads", predicate))
summary = update(url, "ns:roads", {"surface": "asphalt", "resurfaced": "2026-08-01"},
predicate, expected=37)
print(summary)
would touch: 37
Summary(inserted=0, updated=37, deleted=0, replaced=0)
Both numbers must agree with the expectation. A totalUpdated lower than the count means a partial commit — some features were locked or failed a constraint — and the transaction succeeded anyway, which is the case worth alerting on.
Partial success is a successful transaction. WFS 2.0 permits a server to commit some operations and skip others, reporting the real totals in TransactionSummary. HTTP status is 200 either way. The assertion on the returned total is therefore not belt-and-braces — it is the only place a partial commit is detectable.
ValueReference replaced PropertyName. WFS 1.1.0 used wfs:Property/wfs:Name; WFS 2.0 uses wfs:Property/wfs:ValueReference, in a different namespace. A client that carries the old spelling over produces a transaction that a 2.0 server rejects — one of the renames catalogued in WFS 2.0 vs 1.1.0 Breaking Changes.
Geometry updates carry their own reference system. Updating a geometry property means writing a GML geometry inside the Value element, complete with srsName, and the axis rules of SRS handling apply in full. Updating attributes only avoids the question entirely, which is a good reason to keep the two kinds of update separate.
Resource identifiers are not stable across reloads on every server. Some implementations derive gml:id from a primary key, which is stable; others derive it from row position, which is not. Confirm which before building a workflow that stores identifiers and uses them hours later.
Not in the specification. The closest equivalent is sending the identical filter as a GetFeature with resultType=hits, which returns the number of features the predicate selects without transferring or modifying anything. That is what the code above does, and treating it as mandatory rather than optional is the difference between a safe update and a hopeful one.
Because its blast radius is exactly the length of the list, and it cannot drift. An attribute filter selects whatever matches at the moment the server evaluates it, which may not be what matched when you counted a second earlier. For deletion, that gap is not worth accepting when the alternative is enumerating identifiers.
A partial commit. Some features matched the filter but were not updated — commonly because another client holds a lock, or because a database constraint rejected the new value. The transaction still succeeded from the protocol’s point of view, so this is only visible if you compare the summary against your expectation.
Yes, and they are applied in document order within a single atomic unit on a compliant server. That is useful for keeping a set of related changes consistent, but it makes the blast radius harder to reason about, so count each operation’s filter separately before assembling them.
Back to WFS Transactional Operations Deep Dive
Related