Handle XML Namespaces Correctly in PowerShell: Reliable XPath, Safe Updates, Clean UTF-8 Saves
XML that uses a default namespace can make your XPath look broken in PowerShell. If you have ever tried //Settings/TimeoutSec and got null even though the node exists, you have hit the namespace wall. The fix is simple and robust: create an XmlNamespaceManager, register a prefix once, and reuse it across SelectNodes and SelectSingleNode. Then, when you edit the document, check nodes before writing, and save using UTF-8 without BOM with indentation so your diffs stay clean.
This post shows you how to tame XML namespaces in PowerShell with reliable, repeatable patterns you can drop into your build, release, or migration scripts.
1) Understand the namespace pitfall and why naive XPath fails
The default namespace trap
Consider this minimal XML file:
<Settings xmlns='http://schemas.example.com/app'>
<TimeoutSec>20</TimeoutSec>
</Settings>There is a default namespace on Settings. In XPath, elements in a default namespace do not match unprefixed XPath steps. That means these will fail in PowerShell:
# Naive attempts that return $null
[xml]$doc = Get-Content -Path './app.config.xml' -Raw
$doc.SelectSingleNode('//Settings/TimeoutSec') # null
$doc.SelectNodes('//Settings') # nullWhen a default namespace exists, you must:
- Create an
XmlNamespaceManagerbound to the sameNameTableas the document - Register the namespace URI with a prefix (any prefix you choose)
- Use that prefix for all element names in your XPath
[xml]$doc = Get-Content -Path './app.config.xml' -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('a', 'http://schemas.example.com/app')
# Now your XPath works
$timeout = $doc.SelectSingleNode('//a:Settings/a:TimeoutSec', $ns)
$timeout.InnerText # '20'Key point: attributes are not in the default namespace unless specifically namespace-qualified, so you usually do not prefix attribute names in XPath (e.g., @id not @a:id).
Detect and register namespaces from the document
If you do not know the URI in advance, read it from the document root:
[xml]$doc = Get-Content -Path './app.config.xml' -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$defaultNs = $doc.DocumentElement.NamespaceURI
if ($defaultNs) { $ns.AddNamespace('a', $defaultNs) }From now on, a: is your reliable prefix for XPath queries in that document.
2) Register once, then write reliable queries
Do not recreate the namespace manager for every query. Register it once and reuse it across all SelectNodes and SelectSingleNode calls. This reduces mistakes and keeps your code consistent.
[xml]$doc = Get-Content -Path './app.config.xml' -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('a', 'http://schemas.example.com/app')
# Single node
$timeout = $doc.SelectSingleNode('//a:Settings/a:TimeoutSec', $ns)
# Many nodes
$features = $doc.SelectNodes('//a:Settings/a:Features/a:Feature', $ns)
foreach ($f in $features) {
Write-Host ('Feature: ' + $f.InnerText)
}Multiple namespaces
Real-world configs often mix vendor schemas. Register each with a unique prefix:
[xml]$doc = Get-Content -Path './web.config.xml' -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('app', 'http://schemas.example.com/app')
$ns.AddNamespace('sec', 'http://schemas.example.com/security')
# Query nodes from each namespace
$policy = $doc.SelectSingleNode('//sec:Security/sec:Policy', $ns)
$setting = $doc.SelectSingleNode('//app:Settings/app:TimeoutSec', $ns)When you must match regardless of namespace
Prefer registering namespaces. If you truly cannot, you can fall back to local-name(), but it is slower and less readable:
# Namespace-agnostic (avoid if you can)
$timeout = $doc.SelectSingleNode('//*[local-name()=''Settings'']/*[local-name()=''TimeoutSec'']')Use this only for ad-hoc tasks or heterogeneous documents that you cannot predict.
Selecting attributes
Most attributes remain unqualified. Example:
# <Feature name='Cache' enabled='true' />
$enabled = $doc.SelectSingleNode('//a:Feature[@name=''Cache'']/@enabled', $ns)
$enabled.Value # 'true'When an attribute is in a namespace, use a prefix and namespace-uri() as needed:
# <Feature x:flag='test' xmlns:x='http://x' />
$ns.AddNamespace('x', 'http://x')
$flag = $doc.SelectSingleNode('//a:Feature/@x:flag', $ns)3) Safe edits and clean saves: checks, UTF-8 without BOM, and indentation
Before editing, always confirm that your target node exists and whether a change is actually needed. Then write with an XmlWriter configured for UTF-8 without BOM and indentation so your diffs are clean and CI pipelines do not thrash.
$path = './app.config.xml'
[xml]$doc = Get-Content -Path $path -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('a', 'http://schemas.example.com/app')
$node = $doc.SelectSingleNode('//a:Settings/a:TimeoutSec', $ns)
if ($node -and $node.InnerText -ne '30') {
$node.InnerText = '30'
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false) # no BOM
$settings.Indent = $true
$writer = [System.Xml.XmlWriter]::Create($path, $settings)
try { $doc.Save($writer) } finally { $writer.Dispose() }
Write-Host 'Updated TimeoutSec -> 30'
} else {
Write-Host 'No change'
}This pattern gives you idempotent updates: running it again produces no changes unless the value differs, which avoids needless churn in source control.
Create missing nodes correctly (with the right namespace)
Sometimes the node is missing. Create it using the document and the correct namespace URI, not by string concatenation:
[xml]$doc = Get-Content -Path './app.config.xml' -Raw -ErrorAction Stop
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('a', 'http://schemas.example.com/app')
$root = $doc.DocumentElement
$uri = $root.NamespaceURI
$settingsNode = $doc.SelectSingleNode('//a:Settings', $ns)
if (-not $settingsNode) {
$settingsNode = $doc.CreateElement('Settings', $uri)
[void]$root.AppendChild($settingsNode)
}
$timeoutNode = $doc.SelectSingleNode('//a:Settings/a:TimeoutSec', $ns)
if (-not $timeoutNode) {
$timeoutNode = $doc.CreateElement('TimeoutSec', $uri)
[void]$settingsNode.AppendChild($timeoutNode)
}
if ($timeoutNode.InnerText -ne '30') {
$timeoutNode.InnerText = '30'
}
# Save with UTF-8 (no BOM) and indentation
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false)
$settings.Indent = $true
$writer = [System.Xml.XmlWriter]::Create('./app.config.xml', $settings)
try { $doc.Save($writer) } finally { $writer.Dispose() }By calling CreateElement(name, namespaceUri), you ensure your new nodes are in the same namespace as the document, so future XPath queries with your registered prefix keep working.
Extra tips and gotchas
- Bind the namespace manager to the same
NameTableas the document ($doc.NameTable). Do not construct it without the document or reuse a manager from a different document instance. - Prefer a single, consistent prefix across your script. The actual prefix text does not matter; the URI does.
- Use
SelectSingleNodewhen you expect exactly one node andSelectNodesfor collections. Always pass the namespace manager. - Whitespace control: if you need very stable diffs, consider
$doc.PreserveWhitespace = $falsebefore saving so indentation is normalized by the writer. - Idempotence and safety: check for
$nulland compare values before writing to avoid noisy commits and accidental changes. - CI/CD friendliness: saving as UTF-8 without BOM (
UTF8Encoding($false)) avoids BOM-related issues in tooling and keeps Git diffs clean. - Attributes do not inherit the default namespace. Namespace-qualify attributes only if the XML declares them with a prefix.
Putting it all together
The following snippet combines the key ideas into a repeatable recipe you can reuse across SelectNodes and SelectSingleNode, with safe updates and clean saves:
$path = './app.config.xml'
[xml]$doc = Get-Content -Path $path -Raw -ErrorAction Stop
# Namespace manager registered once and reused
$ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace('a', 'http://schemas.example.com/app')
# Query
$node = $doc.SelectSingleNode('//a:Settings/a:TimeoutSec', $ns)
# Edit safely and save cleanly
if ($node -and $node.InnerText -ne '30') {
$node.InnerText = '30'
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false)
$settings.Indent = $true
$writer = [System.Xml.XmlWriter]::Create($path, $settings)
try { $doc.Save($writer) } finally { $writer.Dispose() }
Write-Host 'Updated TimeoutSec -> 30'
} else {
Write-Host 'No change'
}What you get: fewer parsing bugs, correct queries that work with default namespaces, safer idempotent updates, and clean UTF-8 output that is easy to review and diff.
If you want more patterns like this, see PowerShell recipes for configuration management, XML, and automation in the PowerShell Advanced Cookbook. Read more at this link.