PowerShell

PowerShell try/catch Is Not Catching Your Error

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 🙂

PowerShell: Running Scripts Is Disabled on This System

You try to run a script and PowerShell refuses:

File C:\Scripts\demo.ps1 cannot be loaded because running scripts is disabled
on this system. For more information, see about_Execution_Policies at
https:/go.microsoft.com/fwlink/?LinkID=135170.
    + CategoryInfo          : SecurityError: (:) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : UnauthorizedAccess

Or this one, which is a different problem with the same cause:

File C:\Scripts\demo.ps1 cannot be loaded. The file C:\Scripts\demo.ps1 is not
digitally signed. You cannot run this script on the current system.

Every answer you will find says to run Set-ExecutionPolicy. That is usually right and often does not work, because there are three separate things that produce these messages and only one of them is fixed that way.

First, what execution policy is not

It is not a security boundary. Microsoft say so themselves in the documentation. Anyone who can run PowerShell can bypass it in about four keystrokes, and I will show you how further down.

It exists to stop you double-clicking something by accident. Treat it as a seatbelt, not a lock.

The five scopes, and which one is winning

This is the part that catches people. There is not one execution policy, there are five, and they override each other in a fixed order:

MachinePolicy  ->  UserPolicy  ->  Process  ->  CurrentUser  ->  LocalMachine

Highest wins. So Get-ExecutionPolicy on its own tells you the effective answer but not which scope produced it. Always ask for the list:

Get-ExecutionPolicy -List

Here is my own machine:

        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy       Undefined
   UserPolicy       Undefined
      Process          Bypass
  CurrentUser       Undefined
 LocalMachine    RemoteSigned

The effective policy is Bypass, not RemoteSigned. Something set it at Process scope for this session, and Process beats LocalMachine.

The important thing to note is that MachinePolicy and UserPolicy come from Group Policy. If either of those is set, nothing you type will change anything, and you will spend an afternoon finding out. Check those two first.

Fix 1 – set it for yourself only

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

-Scope CurrentUser does not need administrator rights, and it does not change the machine for anybody else. On a shared or managed machine that matters.

RemoteSigned is the sensible setting: scripts you wrote locally run, scripts that came from somewhere else need a signature. It is the default on Windows Server for that reason.

Fix 2 – bypass it for one command

If you cannot change the policy, or do not want to:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\demo.ps1

This changes nothing permanently. It applies to that one process and then it is gone.

I ran the same blocked script three ways to show the difference:

-ExecutionPolicy Restricted    "running scripts is disabled on this system"
-ExecutionPolicy AllSigned     "is not digitally signed"
-ExecutionPolicy Bypass        the script ran

Which is also the demonstration that execution policy is not security. Four extra words on the command line and the “blocked” script runs.

Fix 3 – the one nobody mentions

Here is the case that makes people think the policy is broken.

Your policy is RemoteSigned. You wrote a script locally, it runs fine. A colleague emails you one, or you download it, and it refuses – with the “not digitally signed” message rather than the “disabled on this system” one.

The policy is correct. The file is marked.

Windows adds a hidden alternate data stream to anything that arrives from the internet or an email client. You can see it:

Get-Item .\demo.ps1 -Stream *
Stream          Length
------          ------
:$DATA              30
Zone.Identifier     26

That Zone.Identifier stream is the mark of the web, and RemoteSigned is doing exactly what it says – treating the file as remote.

Remove it:

Unblock-File -Path .\demo.ps1

Same script, same policy, straight afterwards:

the script ran

Note that Unblock-File only removes the mark. It does not weaken your policy and it does not affect any other file. It is the right fix for this case, and changing the execution policy to work around it is the wrong one.

For a folder of scripts:

Get-ChildItem .\Scripts\*.ps1 -Recurse | Unblock-File

Fix 4 – you fixed the wrong PowerShell

This one cost me time, so it is worth knowing.

Windows PowerShell 5.1 and PowerShell 7 keep their execution policies in completely separate places. Setting it in one does nothing for the other.

They are different registry keys:

Windows PowerShell 5.1  HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell
PowerShell 7 (Core)     HKLM\SOFTWARE\Microsoft\PowerShellCore\ShellIds\Microsoft.PowerShell

On my machine 5.1 has RemoteSigned set at both LocalMachine and CurrentUser, and the PowerShell 7 keys do not exist at all.

So if you opened the blue window, ran Set-ExecutionPolicy, and then went back to the black window and got the same error – that is why. Run $PSVersionTable.PSVersion in whichever one is refusing, and fix that one.

The order I would check them in

  1. Get-ExecutionPolicy -List – find which scope is actually winning
  2. If MachinePolicy or UserPolicy is set, stop. That is Group Policy and it is not your decision
  3. Get-Item .\script.ps1 -Stream * – if there is a Zone.Identifier, Unblock-File is your fix
  4. Check which PowerShell you are in
  5. Then, and only then, Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

Four of those five cost you nothing and change nothing. Reaching for Set-ExecutionPolicy first is how people end up with an unrestricted machine and a script that still does not run 🙂