Pakkit.net
← Back to blog

Engineering Practice

A Namespace Makes Your Selector Match Nothing

An automation that edited an XML config ran clean, reported success, and changed nothing — because the file declared a default namespace and the unprefixed XPath matched zero nodes, and a count of zero is not an error.

  • Engineering Practice
  • Automation
  • Debugging
  • XML

Here’s a bug that cost real time and left no trace: an automation whose job was to edit a value in an XML config file ran to completion, reported success, and didn’t change the value. No error. No warning. A clean green run that did nothing. The culprit was an XML namespace, and the shape of the failure — silent success — is one of the most dangerous patterns in automation, because everything looks fine right up until you check the thing you supposedly changed.

The setup that hides the failure

The automation followed a sensible-looking pattern: before editing a node, count how many match, and only edit if exactly one does. Count first, then act on the count. It reads as defensive programming — don’t blindly edit, confirm the target exists. The problem is what happens when the count comes back zero. The guard says “zero matches, so skip the edit,” the edit is skipped, and nothing reports a problem, because finding zero of something isn’t an error. It’s a perfectly valid count. The run ends successful and empty-handed.

A selector that matches nothing isn’t a crash. It’s a confident “there was nothing to do” — which is a lie the code doesn’t know it’s telling.

Default namespaces change what “unprefixed” means

So why did a selector that obviously should match come back with zero? Because the file declared a default namespace on its root element — something like xmlns="http://example/config". When a document does that, every element in it belongs to that namespace, whether or not it carries a prefix. And in the XPath the tooling used (XPath 1.0, which is what most XML libraries default to), an unprefixed name in your query does not mean “any namespace.” It means “the no-namespace.” So a query like /config/entry/property is asking for elements that are in no namespace, while every element in the file is in the default one. They never match. Zero nodes. Every time.

The insidious part is that the query looks correct. It mirrors the document structure exactly. You can stare at it and the file side by side and see nothing wrong, because the mismatch is invisible — it’s in the namespace binding, not the element names.

Two ways to write a selector that actually matches

The fix is to make the selector namespace-aware. Two options, depending on how much you know about the file:

  • Bind the namespace and prefix your steps. Declare a prefix that maps to the document’s namespace URI, and use it on every element in the path: /n:config/n:entry/n:property, with n bound to http://example/config. Precise, and it matches the file’s real structure. (Attributes without a namespace stay unprefixed — only the elements need it.)
  • Match by local name and ignore the namespace entirely. Use a wildcard like /*[local-name()='config']/*[local-name()='entry']. Uglier, but it works whether or not the file has a namespace and regardless of the URI — a belt-and-suspenders choice when you can’t count on the document being consistent.

Either one turns “zero matches” into “one match,” and the edit that was silently skipping starts actually running.

The real lesson: prove the change happened

The namespace detail is worth knowing, but the transferable habit is bigger than XML. Any time automation edits something by first finding it, a selector that finds nothing produces a silent no-op, and a silent no-op passes every check that only asks “did the run finish?” The exit code is success. The logs are clean. The thing you meant to change is untouched.

So the guard I now add is an assertion in the other direction: don’t just confirm the run finished — confirm the change took. The edit task should report it changed something, or a follow-up read should show the new value, or a second idempotent run should report “already done” instead of “did it again.” This is the same discipline as “it ran” is not “it worked”: finishing without error is not evidence of success. And it’s the exact failure mode behind a query that matches nothing being a silent bug — an empty result set masquerading as “all clear.”

Where else this bites

Once you’ve been burned by it, you start seeing the pattern everywhere a selector meets structured data: a CSS/XPath scrape that returns an empty list because the page added a wrapper; a config patch that no-ops because a key moved under a new section; a jq filter that silently yields nothing because the JSON shape shifted. In every case the tool did exactly what you asked — it just found nothing, and finding nothing looked like success.

The move is always the same: when an automated edit “succeeds” but reality is unchanged, suspect the selector before the target, and make the automation prove it changed something rather than merely prove it ran. A namespace is one way a selector quietly lies to you. There are others. The defense is refusing to accept “no error” as proof of “it worked.” If you’ve chased a silent no-op down to a namespace or a moved node, trade notes with me.