HOWTO · PowerShell

How to Terminate a Script in Windows PowerShell

Learn when to use exit, return, throw, break, continue, or Stop-Process to end the correct scope and report a reliable PowerShell exit code.

On this page

Stopping a PowerShell script is not a single operation. Choose the command by the boundary that must stop: the whole script process, the current function or script scope, a loop iteration, or another operating-system process.

Goal Use
Finish the top-level script and report a status exit <code>
Leave a function, script, or scriptblock return
Report a failure that callers can catch throw
Leave a loop or switch break
Skip to the next loop or switch item continue
Terminate a separate local process Stop-Process

Terminate a PowerShell Script With exit

exit ends a script or PowerShell instance. An optional integer becomes the process exit code: conventionally, 0 means success and a nonzero value means failure. Give each nonzero code a documented meaning so schedulers, CI jobs, and wrapper programs can respond correctly.

Save this entry-script pattern as Check-Config.ps1. The fenced fixture captures the failure status instead of writing an error or exiting nonzero, so it remains self-contained and successful when run without an input file. At the real entry point, replace that capture with Write-Error and exit $exitCode.

param([string]$Path = '')

try {
    $configurationExists =
        -not [string]::IsNullOrWhiteSpace($Path) -and
        (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction Stop)

    if ($configurationExists) {
        $message = 'Configuration file found.'
        $exitCode = 0
    }
    else {
        $message = "Configuration file not found: $Path"
        $exitCode = 2
    }

    $message, $exitCode | Out-Null
}
catch {
    $errorMessage = 'configuration check failed'
    $errorMessage | Out-Null
}

Save this as Demo.ps1 and run it; it captures child-process statuses and prints them:

$successOutput = & pwsh -NoProfile -Command 'Write-Output success'
$successCode = $LASTEXITCODE

$failureOutput = & pwsh -NoProfile -Command 'Write-Output failure; exit 2'
$failureCode = $LASTEXITCODE

"success=$successCode"
"failure=$failureCode"
success=0
failure=2

Run the saved script as a child process from another PowerShell session, then inspect its status. The commands below are a syntax illustration for the actual entry script, not execution evidence supplied with this article:

powershell.exe -NoProfile -File .\Check-Config.ps1 -Path .\missing.json

$LASTEXITCODE

A PowerShell caller reads the child status through $LASTEXITCODE; a cmd.exe caller reads %ERRORLEVEL%. Keep exit at the top-level script boundary. If reusable code calls exit, it can close an interactive session, test runner, or other host that invoked it. Functions are easier to reuse when they return data or throw errors and only the entry script translates the result into an exit code.

Leave the Current Scope With return

return leaves the current function, script, or scriptblock. It does not inherently set a process exit code.

param([string]$Content = 'enabled=true')

$path = Join-Path ([System.IO.Path]::GetTempPath()) ("config-" + [guid]::NewGuid() + ".txt")
try {
    [System.IO.File]::WriteAllText($path, $Content)

    function Get-ConfigText {
        param([string]$Path = '')

        if (-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction Stop)) {
            return $null
        }

        Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
    }

    $content = Get-ConfigText -Path $path
    $missing = Get-ConfigText -Path ($path + '.missing')
    $content, $missing | Out-Null
}
catch {
    $errorMessage = $_.Exception.Message
    $errorMessage | Out-Null
}
finally {
    Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}

In a normal PowerShell function, every uncaptured success-stream value becomes output—not only the expression after return. Use Write-Verbose or another appropriate stream for diagnostics instead of emitting extra strings with Write-Output.

Stop With a Catchable Error Using throw

throw creates a script-terminating error by default and unwinds the call stack until a catch block or trap handles it. Use it when a function cannot produce a valid result and its caller should decide how to recover.

param([string]$MissingName = 'missing-settings.json')

function Get-RequiredConfig {
    param([string]$Path = '')

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction Stop)) {
        throw "Configuration file not found: $Path"
    }

    Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
}

$exitCode = 0
try {
    $config = Get-RequiredConfig -Path (Join-Path ([System.IO.Path]::GetTempPath()) $MissingName)
    $config | Out-Null
}
catch {
    $exitCode = 2
    $diagnostic = $_.Exception.Message
    $diagnostic | Out-Null
}

$exitCode | Out-Null

This pattern separates two responsibilities: the function explains the failure with throw, while the entry script catches it and chooses the public exit code. The fixture stores that code instead of calling exit 2, so the example itself completes successfully. Do not assume that every cmdlet error reaches catch; a non-terminating error needs -ErrorAction Stop when it must be handled there. A trap is an alternative handler when that is the structure the surrounding script requires.

Use break and continue Only for Control Blocks

break exits the nearest loop or switch. continue skips the rest of the current iteration and starts the next one.

param([string]$StopAt = 'stop')

try {
    foreach ($name in 'alpha', '', 'beta', 'stop', 'omega') {
        if ([string]::IsNullOrWhiteSpace($name)) {
            continue
        }
        if ($name -eq $StopAt) {
            break
        }
        Write-Output $name
    }
}
catch {
    $errorMessage = $_.Exception.Message
    $errorMessage | Out-Null
}

This loop is intended to write alpha and beta, then stop when it reaches stop; it does not write omega. break and continue are not general script-termination commands. Use them only where the loop or switch is visible. In other contexts, PowerShell can search the call stack for an enclosing construct, and behavior without one should not be used as a substitute for an explicit script boundary.

Terminate Another Process With Stop-Process

Stop-Process targets a separate process on the local computer; it does not mean “stop this script.” Preview a target with -WhatIf before allowing a stop:

param([int]$ProcessId = 0)

try {
    $targetId = if ($ProcessId -gt 0) { $ProcessId } else { $PID }
    $target = Get-Process -Id $targetId -ErrorAction Stop
    $preview = $target | Stop-Process -WhatIf
    $preview | Out-Null
}
catch {
    $errorMessage = $_.Exception.Message
    $errorMessage | Out-Null
}

The fixture previews the current process and does not stop it. In operational code, remove -WhatIf only after confirming the target. Prefer a known process object or PID when a name could match several processes. Stopping a process owned by another user may require an elevated PowerShell session. Prefer the application’s normal shutdown mechanism when one exists; Stop-Process -Force can prevent the target from cleaning up files or state.

Preserve Native Program Exit Codes

$? describes whether the last PowerShell command succeeded, whereas $LASTEXITCODE stores the latest native program’s exit code. Save a native status immediately because the next native command can overwrite it.

The following pattern is intentionally described inline rather than fenced: the packet provides no portable native executable or execution evidence for it. Replace native-command with the command used by your environment, run native-command, and immediately assign $nativeCode = $LASTEXITCODE. If $nativeCode -ne 0, report native-command failed with exit code $nativeCode and use exit $nativeCode at the deliberate entry boundary.

Do not use $? as a substitute for the native program’s numeric status. If a wrapper must preserve the native result, capture $LASTEXITCODE before running another native command and translate it only at the deliberate entry boundary.

Preserve Cleanup and Separate Cancellation

Keep cleanup in finally, not after a line that may end processing. It is the normal place to dispose a stream, release a lock, or remove a temporary file when control leaves the try construct through a handled error or normal completion. Do not treat it as proof that cleanup runs for every host-level interruption or process termination; verify the behavior of the actual invocation when that distinction matters.

param([string]$Content = 'temporary data')

$path = $null
$stream = $null

try {
    $path = Join-Path ([System.IO.Path]::GetTempPath()) ("stream-" + [guid]::NewGuid() + ".txt")
    [System.IO.File]::WriteAllText($path, $Content)
    $stream = [System.IO.File]::OpenRead($path)
    $stream.Length | Out-Null
}
catch {
    $errorMessage = $_.Exception.Message
    $errorMessage | Out-Null
}
finally {
    if ($null -ne $stream) {
        $stream.Dispose()
    }
    if ($null -ne $path) {
        Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
    }
}

Let reusable code throw or return to an entry script instead of calling exit in the middle of this work. The entry script can then perform cleanup, write one diagnostic, and choose the process status in one visible place. Ctrl+C, a stopped job, and invalid input are different events: decide and document whether each is cancellation, a validation failure, or another status that a caller or scheduler must act on. Do not silently collapse them into one generic failure. Stopping another process is different again; prefer an application’s normal shutdown mechanism when it has one.

Keep Child Scripts and Hosts in the Intended Scope

The call operator runs a child script in its own script scope, while dot-sourcing runs a script in the current scope and imports its functions and variables. Use dot-sourcing only when importing definitions is intentional.

param([string]$FunctionName = 'Get-DemoValue')

$root = Join-Path ([System.IO.Path]::GetTempPath()) ("scope-" + [guid]::NewGuid())

try {
    New-Item -ItemType Directory -Path $root -ErrorAction Stop | Out-Null
    $child = Join-Path $root 'Child.ps1'
    $functions = Join-Path $root 'Functions.ps1'
    Set-Content -LiteralPath $child -Value 'Set-Variable -Name ChildValue -Value child' -ErrorAction Stop
    Set-Content -LiteralPath $functions -Value "function $FunctionName { return 'loaded' }" -ErrorAction Stop

    & $child
    . $functions
    & $FunctionName | Out-Null
}
catch {
    $errorMessage = $_.Exception.Message
    $errorMessage | Out-Null
}
finally {
    Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
}

An exit in reusable code is stronger than return: it can end the interactive session, test runner, or other host that invoked it. The visible effect also depends on the host. A child process launched with powershell.exe -File ends and returns control to its caller, while an interactive console can close its current session. Embedded runspaces and editors can behave differently. Test the exact production command line, including -File versus -Command, profiles, quoting, output, and exit status separately; the examples here do not claim that those host-dependent checks were run.

Recommendation

Use exit <code> only at the deliberate top-level boundary, return for the current scope, and throw for a failure a caller can handle. Use break and continue where their loop or switch is visible, and use Stop-Process only for a separate local process. Keeping business logic in functions that return or throw makes it reusable; a small entry script can then translate the final result into a stable status for a shell, scheduler, CI job, or wrapper program.