You wrapped something in try/catch, it failed, and the catch block never ran. The error printed in red, the script carried on, and whatever you put in the catch was ignored.
This is not a bug and it is not you. PowerShell has two kinds of error and try/catch only sees one of them.
Everything below was run on PowerShell 7.5.8.
The demonstration
try {
Get-Item 'C:\does\not\exist\nothing.txt'
"...execution continued past the failing line"
} catch {
"CAUGHT: $($_.Exception.Message)"
}
Get-Item:
Line |
6 | Get-Item $missing
| ~~~~~~~~~~~~~~~~~
| Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.
...execution continued past the failing line
The error was written out. The catch block did not run. The line after it did.
Now the same thing with four extra words:
try {
Get-Item 'C:\does\not\exist\nothing.txt' -ErrorAction Stop
} catch {
"CAUGHT: $($_.Exception.Message)"
}
CAUGHT: Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.
Why
Most cmdlet errors are non-terminating. The cmdlet reports a problem and PowerShell carries on, because when you pipe a thousand items into something you usually want the other 999 to be processed.
try/catch only reacts to terminating errors.
-ErrorAction Stop promotes a non-terminating error to a terminating one for that call. That is the whole trick, and it is why so much published PowerShell has try/catch blocks in it that do nothing at all.
Write-Error is not throw
Worth knowing if you are writing your own functions:
try { Write-Error 'this is a Write-Error' } catch { "caught" }
try { throw 'this is a throw' } catch { "caught: $($_.Exception.Message)" }
Write-Error: this is a Write-Error
caught: this is a throw
Write-Error raises a non-terminating error and is not caught. throw is terminating and is.
So if you write a function that reports failure with Write-Error, nobody calling it can catch that failure unless they also pass -ErrorAction Stop. Use throw when you mean stop.
Turning it on for a whole script
Rather than putting -ErrorAction Stop on every line:
$ErrorActionPreference = 'Stop'
CAUGHT without -ErrorAction on the cmdlet
Put that at the top of a script and every cmdlet error becomes terminating. For an admin script that changes things, this is almost always what you want – stopping on the first problem beats carrying on and doing half the job.
The important thing to note is that it is scoped. Set it inside a function and it applies there, not to the caller. That is useful, and it also means setting it in your profile does not protect your scripts.
Catching one kind of error and not others
A bare catch catches everything, including the failures you had not thought about. You can be specific:
try {
Get-Item $path -ErrorAction Stop
} catch [System.Management.Automation.ItemNotFoundException] {
"matched the typed catch: file not found"
} catch {
"fell through to the generic catch"
}
matched the typed catch: file not found
To find the type name for something you are actually hitting, let it fail once and ask:
$Error[0].Exception.GetType().FullName
$Error[0].FullyQualifiedErrorId
$Error[0].CategoryInfo.Category
System.Management.Automation.ItemNotFoundException
PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand
ObjectNotFound
FullyQualifiedErrorId is the precise one. It names both the problem and the cmdlet that raised it, which is what you want when two different cmdlets can produce the same exception type.
Collecting errors without stopping
Sometimes you want the loop to finish and a list of what went wrong:
Get-Item $path -ErrorAction SilentlyContinue -ErrorVariable itemErr
$itemErr.Count
$itemErr[0].Exception.Message
1
Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.
Use +itemErr with a plus sign to append across a loop rather than overwrite each time.
The one that catches everybody: native commands
try/catch does not work on robocopy, git, msiexec or anything else that is not a cmdlet.
try {
cmd.exe /c "exit 7"
"no exception was raised"
} catch {
"caught"
}
no exception was raised
External programs do not raise PowerShell exceptions. They set an exit code, and you have to check it yourself:
cmd.exe /c "exit 7"
$? # False
$LASTEXITCODE # 7
Both measured immediately after the call – and that matters, because $? reflects the last thing that happened. Put a Write-Host between the command and the check and you are reading the result of the Write-Host. I made exactly that mistake while testing this post.
So for anything external:
robocopy $source $dest /E
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with $LASTEXITCODE" }
Note the -ge 8. Robocopy uses 0-7 for various kinds of success, so testing -ne 0 would fail every time it copied something. Check what your tool’s exit codes actually mean rather than assuming zero is the only good one.
PowerShell 7 can do this for you
There is a setting for it, and it is off by default:
$PSNativeCommandUseErrorActionPreference # False
Turn it on, with $ErrorActionPreference set to Stop:
$PSNativeCommandUseErrorActionPreference = $true
$ErrorActionPreference = 'Stop'
try { cmd.exe /c "exit 7" } catch { "caught = true" }
caught = true
A non-zero exit code now raises a terminating error you can catch like anything else. Worth knowing, and worth being careful with – anything that uses non-zero exit codes for non-failures, robocopy included, will start throwing at you.
finally
try {
Get-Item $path -ErrorAction Stop
} catch {
"catch ran"
} finally {
"finally ran"
}
catch ran
finally ran
Runs whether or not anything failed. Use it to disconnect, release, or clean up temporary files – the things that should happen regardless.
What I actually put in a script
$ErrorActionPreference = 'Stop'
try {
# everything that changes something
}
catch {
Write-Error "Failed at $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
throw
}
finally {
Disconnect-PnPOnline -ErrorAction SilentlyContinue
}
$_.InvocationInfo.ScriptLineNumber tells you where it broke, which the message on its own does not. And the bare throw at the end re-raises the original error rather than swallowing it, so whatever called your script still finds out it failed.
Catching an error and then carrying on quietly is worse than not catching it. At least the red text was honest 🙂

