Home › Topics › PowerShell Automation › Errors & Logs

Error Handling and Logging in PowerShell Transfer Scripts

The worst transfer script is not the one that fails. It is the one that fails and reports success. The job shows a green checkmark in Task Scheduler for six weeks while the partner's inbox stays empty. The failure is discovered only when someone downstream asks where their data went. Scripts get into that state for a very specific, very learnable reason. PowerShell's error model does not work the way most people assume. A try/catch written on those assumptions catches nothing.

This article decodes the model properly. It covers terminating versus non-terminating errors and what $ErrorActionPreference and -ErrorAction actually change. It covers try/catch/finally patterns that genuinely catch and the separate discipline external programs require. It covers exit codes that make failure visible to the scheduler and log lines a stranger can act on at three in the morning. It is the last article in our PowerShell Automation series because everything else — file operations, SFTP scripting, the complete job template — depends on the habits built here.

The Two Kinds of PowerShell Errors

PowerShell has two species of error, and the difference decides whether your catch block ever runs. A terminating error stops the current operation and propagates upward until something handles it — this is the kind try/catch exists for. A non-terminating error writes an error record to the error stream, turns the text red in a console, and then execution simply continues with the next statement.

Most cmdlet failures are non-terminating by default. That is a deliberate design inherited from shell tradition. If Get-ChildItem is walking a thousand folders and one is unreadable, you usually want the other nine hundred ninety-nine listed, not the whole command dead. Sensible for interactive browsing — and quietly catastrophic for automation. Watch the trap in action:

try {
    Copy-Item -Path 'D:\exports\report.csv' -Destination 'D:\staging'
    Write-Output 'copy succeeded'      # runs even if the copy just failed
}
catch {
    Write-Output 'this never executes for a non-terminating error'
}

If the source file is missing, Copy-Item emits a non-terminating error. In that case, the catch block is never entered. The script cheerfully prints "copy succeeded," and every later step operates on a file that is not there. Multiply that by an unattended schedule and you have the six-week silent failure from the introduction. The cure is one parameter, next.

When you are diagnosing after the fact, PowerShell keeps receipts. The automatic $Error variable is a running collection of every error the session has produced, newest first. So $Error[0] shows the most recent failure in full detail even if the script ignored it at the time. In an interactive session, inspecting $Error[0].Exception.Message and $Error[0].InvocationInfo.Line after a suspicious run tells you what actually went wrong and which line asked for it. This two-minute habit beats re-running the whole job with guesses.

ErrorActionPreference and -ErrorAction: The Conversion Switch

PowerShell lets you convert non-terminating errors into terminating ones — per command, or for the whole script. Per command, add -ErrorAction Stop:

try {
    Copy-Item -Path 'D:\exports\report.csv' -Destination 'D:\staging' `
        -ErrorAction Stop
    Write-Output 'copy succeeded'      # now this line is trustworthy
}
catch {
    Write-Output "copy failed: $($_.Exception.Message)"
}

For a whole script, set the preference variable at the top: $ErrorActionPreference = 'Stop'. Now every cmdlet failure terminates unless you say otherwise. This flips the default from "keep limping" to "fail fast" — the right default for a job, where an unnoticed half-failure is worse than a clean stop. The values you will actually use:

  • Continue — the default: report the error, keep going. Fine interactively, dangerous in jobs.
  • Stop — make the error terminating and catchable. The job standard.
  • SilentlyContinue — suppress the error report and keep going. Legitimate only for narrow, expected noise — deleting a temp file that may not exist — and always with a comment saying why: Remove-Item $tmp -ErrorAction SilentlyContinue # may not exist; fine. Used any wider, it is the tool scripts use to lie to you.

Remember: in a transfer job, set $ErrorActionPreference = 'Stop' at the top, and still write -ErrorAction Stop on the individual cmdlets your logic depends on. The explicit parameter survives being copied into another script whose author forgot the global setting — and functions you share will be copied.

try/catch/finally That Earns Its Keep

With errors reliably terminating, structure becomes useful. Inside a catch block, the automatic variable $_ holds the error record. The property $_.Exception.Message is the human-readable reason, and $_.Exception.GetType().FullName tells you which kind of exception occurred. That enables the most underused feature, typed catch blocks:

try {
    $files = Get-ChildItem -Path $SourceDir -Filter $Pattern -ErrorAction Stop
    Copy-Item -Path $files.FullName -Destination $StageDir -ErrorAction Stop
}
catch [System.UnauthorizedAccessException] {
    Write-Log "permissions problem on $SourceDir : $($_.Exception.Message)" 'ERROR'
    $exitCode = 2      # environment problem - fix is local
    throw
}
catch [System.IO.IOException] {
    Write-Log "disk or file I/O failure: $($_.Exception.Message)" 'ERROR'
    $exitCode = 2
    throw
}
catch {
    Write-Log "unexpected: $($_.Exception.GetType().Name): $($_.Exception.Message)" 'ERROR'
    $exitCode = 1
    throw
}

PowerShell tries the typed blocks in order and falls through to the untyped one. So known failure classes get precise logging and exit codes while everything else still gets caught. The bare throw at the end of each block rethrows the same error upward. This pattern classifies and records, then lets the job's outer structure decide what dying looks like. A catch that logs and does not rethrow has silently converted a failure into a success; do that only when the script genuinely handles the problem.

finally completes the trio: its block runs whether the try succeeded, threw, or even exited. Anything the job opened gets closed there. An SFTP session from a module gets removed so it cannot linger against the server's connection limit. Temporary batch files get deleted so credentials to nothing pile up in temp folders. The command Stop-Transcript runs so the flight recorder is properly sealed. A useful review question for any job: "if this script died on its worst line, what would be left open, locked, or half-written?" Everything on that list belongs in finally, as the job template demonstrates end to end.

External Programs Play by Different Rules

Everything above governs cmdlets. The moment your job runs an external program — sftp.exe, a vendor CLI, anything with an .exe — none of it applies. A failing external program does not throw, does not produce an error record, and sails straight past catch blocks and $ErrorActionPreference alike. It communicates the only way processes do: an exit code, which PowerShell parks in $LASTEXITCODE. (The related automatic variable $? just tells you whether the last operation "succeeded". For an external program it mirrors whether the exit code was zero, so $LASTEXITCODE is the primary source.)

The discipline: check $LASTEXITCODE immediately after every external call, and convert failure into a real PowerShell error so the rest of your handling applies. Better yet, wrap the discipline in a helper so no call site can forget it:

function Invoke-External {
    param(
        [Parameter(Mandatory)][string]$Exe,
        [string[]]$Arguments,
        [string]$What = 'external command'
    )
    $output = & $Exe @Arguments 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "$What failed (exit $LASTEXITCODE): $output"
    }
    return $output
}

# usage - the throw lands in your normal try/catch
$out = Invoke-External -Exe 'sftp' -What 'SFTP upload' -Arguments @(
    '-b', $batchFile, '-i', $KeyPath,
    '-o', 'BatchMode=yes', "$SftpUser@$SftpHost"
)

Details that matter: @Arguments (splatting) passes the array as separate arguments, keeping paths with spaces intact. The 2>&1 folds the program's error output into $output so the eventual log line contains the actual reason. The throw promotes the exit code into the single error model the rest of the script already handles. One helper, and external commands stop being a separate failure universe.

Failing Loudly: Exit Codes for the Scheduler

Inside the script, errors are now caught and classified. The last step is telling the outside world. Task Scheduler records one number per run — the process exit code, surfaced as Last Run Result. That number is your job's entire public testimony. Two rules make it honest. Launch with powershell.exe -File (not -Command) so your script's exit N becomes the process exit code. And give N a documented meaning, kept small and stable:

Exit code Meaning Who acts
0 Success — including a logged, deliberate "nothing to send" Nobody
1 Unexpected error — the catch-all Script owner, with the transcript open
2 Environment problem — missing folder, permissions, bad config Local Windows admin
3 Transfer failed — connection, auth, or mid-transfer error Whoever owns the partner relationship
4 Verification failed — sent, but proof of arrival is missing Script owner + partner together
5 Expected file missing — upstream never produced it Owner of the upstream system

One presentation quirk to warn your team about: Task Scheduler displays Last Run Result in hexadecimal, so exit code 3 appears as 0x3 and success as 0x0. Your codes are all small numbers, so the translation is trivial. But the first time someone sees 0x2 without warning, they tend to assume a mysterious Windows error rather than your documented "environment problem." A line in the script header ("scheduler shows these in hex") saves that confusion.

Put the table in the script's header comment, and monitoring can route each failure to the right person without opening the log. Alerting on those codes — and on the silence of a job that never ran at all — is the province of our transfer job monitoring series and of alerts from transfer logs. If your job runs a task-based tool instead of raw scripts, the same principle holds in different clothes. Sysax FTP Automation tasks carry built-in retry and error handling and send email notifications when a transfer fails. So the "fail loudly" half is configuration rather than code.

Log Lines Worth Reading at 3 A.M.

Error handling decides what the job does; logging decides what the humans can know afterward. The craft fits in five habits. Timestamp every line. Tag a level (INFO, WARN, ERROR). State one event per line. Attach evidence — counts, sizes, exit codes, durations — not adjectives. Log decisions, not just actions, because "matched 0 files, treating as success per config" is the line that ends an argument three months later. A shape that both humans and scripts can parse:

Mar 14 02:10:07 [INFO] connecting to sftp.example.com as transfer
Mar 14 02:10:09 [WARN] attempt 1 failed (exit 255): connection timed out
Mar 14 02:11:12 [INFO] attempt 2 succeeded: 3 file(s) uploaded in 41s
Mar 14 02:11:13 [INFO] archived 3 file(s) to D:\archive
Mar 14 02:11:13 [INFO] job end: exit 0

Note what that excerpt quietly proves: the retry worked (and the network hiccup is now on record). The durations are visible, and the final line states the verdict the scheduler saw. Durations are worth capturing deliberately, not just implying through timestamps: note $started = Get-Date before the transfer and log ((Get-Date) - $started).TotalSeconds after, rounded. A job whose upload time creeps from forty seconds toward its timeout over a few months is announcing a growing file or a shrinking network. Only a log with durations lets you hear the announcement before the timeout does. Keep the fixed-format prefix — timestamp, bracketed level — machine-parseable and let the message stay human. Rotate by writing a file per day or per month, and delete old ones with the retention pattern from the file-operations article. What belongs in the record — and what, like credentials, must never appear — is the subject of what to log. The skill of reading such logs under pressure is its own article.

Know your streams

PowerShell has several output streams, and job authors need the map. The command Write-Output sends objects down the success stream — capturable by variables, redirection, and the transcript. That makes the command the backbone of a logging function. Write-Host paints the console (the information stream): fine for interactive color, but not something callers can easily capture, so avoid it inside reusable functions. Write-Error emits a non-terminating error record — useful when you want to report a problem without stopping. Write-Warning and Write-Verbose have their own streams. Verbose detail is invaluable while developing and belongs switched off in production. That is not least because chatty streams can leak connection detail into transcripts, a leak discussed in the credentials article.

Transient or Permanent? Retry With Judgment

Once failures are visible and classified, one final judgment call: which deserve a retry? Timeouts, name-resolution blips, and "connection reset" are transient — the environment hiccupped. A patient second attempt with growing delays often succeeds (the wrapper is in the job-patterns article). Authentication failures are the opposite: the password or key is wrong, and retrying cannot fix it. Hammering the server can lock the account — turning one failed night into a blocked account and a support call. Permission errors, missing folders, and malformed config are likewise permanent until a human acts. Classify first, retry second; the full taxonomy and backoff design live in our retry and error handling series.

Never retry an authentication failure. It is the one failure class where persistence makes things worse: account lockout policies read your retry loop as an attack. Fail immediately with a distinct log line and exit code, and let a human fix the credential.

The Whole Discipline on One Page

Here is the skeleton every rule in this article folds into — the compact version of the full template from the job-patterns article:

$ErrorActionPreference = 'Stop'
$exitCode = 0
Start-Transcript -Path $TransPath -Append | Out-Null
try {
    Write-Log 'job start'
    # cmdlets: rely on Stop + try/catch
    # externals: Invoke-External promotes exit codes to throws
    Write-Log 'job success'
}
catch {
    if ($exitCode -eq 0) { $exitCode = 1 }
    Write-Log "job FAILED: $($_.Exception.Message)" 'ERROR'
}
finally {
    Stop-Transcript | Out-Null
}
exit $exitCode

Debugging the logic inside that skeleton is ordinary PowerShell work — add verbose lines, test functions interactively, run the job on demand as the service account. If your automation lives in a tool rather than raw scripts, the equivalent facility matters when choosing one. Sysax FTP Automation, for instance, includes a script editor with a line-by-line debugger for the task scripts it runs. This serves the same "watch it fail slowly instead of guessing" purpose.

That closes the series. If you started here, work backward through the complete job template that applies these patterns end to end. In that case, also read SFTP scripting for the transfer engine these errors and logs are wrapped around. A script that fails loudly, logs honestly, and exits meaningfully is a script you can finally stop thinking about. That was the point of automating in the first place.

Frequently Asked Questions

Why did my try/catch not catch the error?
Because the error was non-terminating — the default for most cmdlet failures — and catch only sees terminating errors. Add -ErrorAction Stop to the cmdlet (or set $ErrorActionPreference = 'Stop' at the top of the script) and the same failure becomes catchable.
What is the difference between $? and $LASTEXITCODE?
$? is a true/false flag saying whether the immediately previous operation succeeded — any operation. $LASTEXITCODE holds the actual numeric exit code of the last external program, which carries more information and survives until the next external call. For judging external commands, check $LASTEXITCODE.
Does -ErrorAction Stop make a failing .exe throw an error?
No. -ErrorAction and $ErrorActionPreference govern cmdlet error records only; external programs communicate through exit codes regardless. Check $LASTEXITCODE after every external call and throw yourself when it is nonzero — or use a wrapper function so the check can never be forgotten.
Should I use Write-Host or Write-Output in a scheduled job?
Prefer Write-Output (or a logging function built on it): it flows down the success stream where variables, redirection, and the transcript can capture it. Write-Host writes to the console's information stream, which is awkward to capture and useless to callers — reserve it for interactive scripts.
Should a job retry after a failed login?
No. A wrong credential will not become right on attempt three. Repeated failures can trip the server's lockout protection, converting a config problem into a blocked account. Retry only transient failures — timeouts and connection drops — and fail immediately and loudly on authentication errors.
What exit code should "the expected file never arrived" use?
Its own dedicated code (5 in this article's table), distinct from transfer failure. The fix lives with the upstream system that failed to produce the file. So the code lets monitoring route the alert to the right owner without anyone reading the log first.

From the Sysax team: we build secure file transfer software for Windows. Sysax Multi Server is an FTP, FTPS, SFTP, and HTTPS server. Sysax FTP Automation handles scheduled, scripted transfers. Free trials are on the download page.