Stop Using Write-Host — PowerShell Output Streams and Log Design
· Go Komura · PowerShell, Windows, Logging, Operational Improvement, Automation, Script, Design, Maintainability
“The script runs, but when it fails I have no idea what happened” — this is by far the most common complaint we hear about PowerShell scripts that have been put into production. And when you trace the cause, you almost always land on the same structure: the state of the processing is expressed only through Write-Host, and for an overnight run that nobody was watching, nothing was left behind.
PowerShell has six output streams, and each one differs in who the information is for. Is it a value being returned? Something for a human to read? Something needed only during an investigation? Once you write with that distinction in mind, the same script behaves helpfully in an interactive run and machine-readably in an unattended one. Funnel everything into Write-Host instead, and all you accumulate is information that is neither usable as a value nor preserved in a log.
This article covers the roles of the six streams, where Write-Host genuinely belongs, the mechanism by which function return values get polluted, how to hand verbosity control to the caller, and a structured logging pattern you can actually use in production.
1. The Bottom Line First
- PowerShell has six output streams. Success (1), Error (2), Warning (3), Verbose (4), Debug (5) and Information (6), each redirectable by number.
*>covers all streams.1 - From PowerShell 5.0 onwards,
Write-Hostwrites to the information stream. That means it can be redirected with6>and captured with-InformationVariable. Before that, it could be neither captured nor suppressed.2 Write-Hostis exclusively for “display shown to a human”. You cannot use it to return values. Values passed down the pipeline go throughWrite-Output(or a bare expression).23- A function returns every object emitted inside it. Whether or not there is a
returnis irrelevant. The standard move is to discard unwanted output with$null = ....4 - Progress goes to
Write-Progress; the course of processing goes toWrite-Verbose. Progress display is not a redirectable data stream.5 - A function with
[CmdletBinding()]automatically gains the common parameters such as-Verbose,-Debugand-InformationAction. Putting verbosity in the caller’s hands is the right answer.67 - The defaults are worth remembering.
$VerbosePreference,$DebugPreferenceand$InformationPreferenceare SilentlyContinue;$WarningPreferenceand$ErrorActionPreferenceare Continue.8 - Logs you intend to analyse later should be structured (one JSON object per line). If you want to reproduce what the screen looked like, run
Start-Transcriptalongside it.9
2. The Six Streams and Their Audiences
| # | Stream | Command that writes to it | Intended reader | Default preference |
|---|---|---|---|---|
| 1 | Success | Write-Output / bare expression |
Downstream processing (the pipeline) | — |
| 2 | Error | Write-Error / throw |
Humans + monitoring | Continue |
| 3 | Warning | Write-Warning |
Humans | Continue |
| 4 | Verbose | Write-Verbose |
Humans mid-investigation | SilentlyContinue |
| 5 | Debug | Write-Debug |
Developers | SilentlyContinue |
| 6 | Information | Write-Information / Write-Host |
Humans + records | SilentlyContinue |
The most important row in this table is the first one. The success stream is not the place to write messages for humans. Put a human-readable string there and the moment you connect that function with a |, an unexpected string flows into whatever comes next.
function Get-KsTargetFile {
Write-Output "Searching for targets..." # [BAD] gets mixed into the return value
Get-ChildItem -Path $path -Filter '*.csv'
}
# The caller expects an array of FileInfo, but a string arrives at the front
$files = Get-KsTargetFile
$files[0].FullName # -> empty (because the first element is a string)
The correct approach is to send progress reports to the verbose or information stream.
function Get-KsTargetFile {
[CmdletBinding()]
param([string] $Path)
Write-Verbose "Searching for targets: $Path" # shown only when -Verbose is specified
Get-ChildItem -Path $Path -Filter '*.csv' # the return value is FileInfo only
}
3. Is Write-Host Really Evil?
The claim “never use Write-Host” spread widely at one point, but the situation in modern PowerShell has changed. From PowerShell 5.0 onwards, Write-Host is implemented as a write to the information stream (number 6), so you can redirect it with 6> or capture it with -InformationVariable.2 The criticism of the day — “it can only go to the screen and can never be picked up afterwards” — no longer applies.
Even so, its uses are limited.
Where Write-Host is appropriate
- In interactive tools, when you want coloured headings or separators (
-ForegroundColor) - When telling the user what the script is about to do
- When the decorated display itself, rather than a processing value, is the point
Where Write-Host must not be used
- When you want to pass a value as the function’s return value (→
Write-Output) - When you want to leave an operational log for later analysis (→ structured logging, or
Write-Information) - When the caller should be able to toggle display on and off (→
Write-Verbose)
In an unattended script there is no display target at all. A script that expresses its state only through Write-Host becomes “a script that tells you nothing” the moment Task Scheduler runs it. That is what this article’s title is getting at.
4. Handing Control to the Caller — [CmdletBinding()] and the Common Parameters
The real value of Write-Verbose is that the caller decides whether it is displayed. Simply adding [CmdletBinding()] to a function makes common parameters such as -Verbose, -Debug, -WarningAction, -InformationAction and -ErrorAction automatically available.67
function Invoke-KsImport {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $CsvPath
)
Write-Verbose "Import started: $CsvPath" # not shown by default
Write-Information "Importing: $CsvPath" -Tags 'KsImport' # not shown by default (but capturable)
$rows = Import-Csv -Path $CsvPath
if ($rows.Count -eq 0) {
Write-Warning "$CsvPath contains nothing to import" # shown by default
return
}
Write-Verbose "Processing $($rows.Count) rows"
$rows | ForEach-Object { ConvertTo-KsRecord $_ } # this alone is the return value
}
# Normal run: only warnings are displayed, and the return value is the records
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv'
# During investigation: we also want to see the progress
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv' -Verbose
# Capture only the information stream into a variable, then write it to a log file
$records = Invoke-KsImport -CsvPath 'D:\in\orders.csv' -InformationVariable info
$info | ForEach-Object { $_.MessageData } | Add-Content -Path $logPath
You often see implementations that invent a $LogLevel variable and branch on it with if statements, but riding on the standard mechanism is shorter and conveys your intent to others. Everyone who uses PowerShell shares the understanding that adding -Verbose produces detail.
Note that preference variables such as $VerbosePreference apply to the current scope and its child scopes.8 Calling a function with -Verbose also makes the cmdlets called from inside it start producing verbose output, so you can end up with more output than you expected.
5. Polluted Return Values — a Pitfall Specific to PowerShell
PowerShell functions return every object emitted inside them, even without an explicit return.4 This is a powerful design, but it is also the source of the most accidents.
function New-KsWorkFolder {
param([string] $Path)
New-Item -Path $Path -ItemType Directory # [TRAP] DirectoryInfo joins the return value
$list = [System.Collections.Generic.List[string]]::new()
$list.Add('log') # [TRAP 2] .Add() is void, so no harm done
$sb = [System.Text.StringBuilder]::new()
$sb.Append('x') # [TRAP 3] the StringBuilder itself is returned
return $Path
}
$p = New-KsWorkFolder -Path 'D:\work' # $p becomes a three-element array (DirectoryInfo, StringBuilder, string)
The fix is to “throw away the unwanted output”. There are three ways to write it, but $null = ... is the lightest.
$null = New-Item -Path $Path -ItemType Directory # recommended
New-Item -Path $Path -ItemType Directory | Out-Null # slower by the cost of the pipe
[void] $sb.Append('x') # commonly used for .NET methods
Write a test and you’ll spot this behaviour immediately. The importance of tests that “pin down the shape of the return value” is covered in “Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break”.
6. Redirection and Capture
Streams can be redirected individually by number.1
.\Invoke-NightlyExport.ps1 3> warnings.log # warnings only, to a separate file
.\Invoke-NightlyExport.ps1 4>&1 | Tee-Object -FilePath run.log # merge verbose into the success stream
.\Invoke-NightlyExport.ps1 *> all.log # every stream into one file
.\Invoke-NightlyExport.ps1 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }
> overwrites and >> appends. Be aware, though, that merging via redirection mixes types. When you merge the error stream of a PowerShell script or function as in the example above, the elements remain ErrorRecord objects, so you can sort them out by type as shown.
On the other hand, 2>&1 on an external program (a native command) behaves differently. From PowerShell 7.4 onwards the redirected output is treated as a byte stream and becomes strings after the merge, so sorting by ErrorRecord no longer works. If you need to distinguish an external command’s stdout from its stderr, receive them separately rather than merging them (see “Calling External Executables Correctly From PowerShell”).
If you want to keep a complete record of a run, Start-Transcript is the easy option. It records the session’s commands and output to text, so you can reconstruct “what was on screen at the time” afterwards.9
Start-Transcript -Path "C:\Logs\export_$(Get-Date -f yyyyMMdd_HHmmss).log" -Append
try { Invoke-KsExport }
finally { Stop-Transcript }
7. Making Logs Analysable Later — Structured Logging
Logs that humans read and logs that machines aggregate are different things. When you want to know “how many times did this error occur last month?”, free-form text turns into a battle of endurance with grep. Write one JSON object per line (JSON Lines) and the aggregation can be done entirely in PowerShell.
function Write-KsLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [ValidateSet('INFO','WARN','ERROR')] [string] $Level,
[Parameter(Mandatory)] [string] $Message,
[hashtable] $Data,
[string] $Path = $script:KsLogPath
)
$entry = [ordered]@{
ts = (Get-Date).ToString('o') # ISO 8601: easy to sort and to correlate
level = $Level
message = $Message
script = $MyInvocation.ScriptName
host = $env:COMPUTERNAME
user = $env:USERNAME
}
# Put extra information under data. Mixing it into the top level means the caller
# can overwrite base fields by passing keys such as level or user
if ($Data) { $entry['data'] = $Data }
# -Compress puts it on one line. Append with Add-Content (UTF-8)
$entry | ConvertTo-Json -Compress -Depth 5 | Add-Content -Path $Path -Encoding utf8
# Human-facing display rides on the standard mechanism (shown on screen, but no value returned)
switch ($Level) {
# Do not specify -ErrorAction. Fixing it here would stop the caller from
# using -ErrorAction Stop to turn this into a terminating error
'ERROR' { Write-Error $Message }
'WARN' { Write-Warning $Message }
default { Write-Verbose $Message }
}
}
# Example use
Write-KsLog -Level INFO -Message 'Import complete' -Data @{ rows = 1250; file = 'orders.csv'; ms = 4210 }
# -> {"ts":"...","level":"INFO","message":"Import complete","script":"...","host":"...","user":"...",
# "data":{"rows":1250,"file":"orders.csv","ms":4210}}
Aggregation then looks like this.
Get-Content 'C:\Logs\ks.log' |
ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object { $_.level -eq 'ERROR' -and [datetime]$_.ts -ge (Get-Date).AddDays(-30) } |
Group-Object message | Sort-Object Count -Descending | Select-Object Count, Name
Putting your logs on Windows’ standard logging infrastructure (Event Log and ETW) is another option. If you are thinking about integration with monitoring tools or collection from multiple machines, that route has the advantage. A design comparison is in “An Introduction to Windows Event Log and ETW — Putting Your Business App’s Logs on the OS’s Standard Mechanisms”, and log file rotation is covered in “Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting”.
8. Handling Progress Display
Write-Progress uses the host’s progress display feature, and it is not a redirectable data stream.5 In other words, you cannot keep it in a log. In an unattended run there is nowhere to display it, and in some environments the cost of updating progress is far from negligible.
# Turn off progress display at the top of an unattended script
$ProgressPreference = 'SilentlyContinue'
If you want progress in your operational log, writing only the milestones to the verbose stream is the practical approach.
$i = 0
foreach ($row in $rows) {
$i++
if ($i % 100 -eq 0) { Write-Verbose "$i / $($rows.Count) complete" }
...
}
9. Standard Practice in the Field (Decision Table)
| Information you want to emit | What to use | Why |
|---|---|---|
| Values for downstream processing | Write-Output / bare expression |
The success stream is for data only3 |
| Progress (only wanted during investigation) | Write-Verbose |
Controlled by the caller with -Verbose7 |
| Events you want recorded operationally | Write-Information + structured logging |
Capturable with -InformationVariable2 |
| Decorated display in an interactive tool | Write-Host |
Goes via the information stream, so it is capturable too2 |
| Expected but noteworthy conditions | Write-Warning |
Displayed by default, capturable with -WarningVariable8 |
| Failures | Write-Error / throw |
See the dedicated article on error handling |
| Internal state during development | Write-Debug |
Only when -Debug is specified7 |
| Progress | Write-Progress (interactive only) |
Never appears in logs. Turn it off for unattended runs5 |
| A complete record of the run | Start-Transcript |
Run alongside your own logging as a safety net9 |
10. Summary
- PowerShell’s output is divided into six streams. The success stream is for data only, and mixing human-facing messages into it breaks the return value.
- From PowerShell 5.0 onwards
Write-Hostwrites to the information stream and is therefore capturable, but it still cannot be used to return values or to produce operational logs. - A function returns everything emitted inside it. The standard move is to discard unwanted output with
$null = .... - Add
[CmdletBinding()]and useWrite-Verbose/Write-Informationto hand verbosity control to the caller. It is shorter than your own log-level variable and conveys your intent. - Logs you intend to aggregate later should be structured, one JSON object per line. If reproducing the screen is the goal, run
Start-Transcriptalongside it. - Progress display is not a data stream, so it never ends up in a log. For unattended runs, the practical answer is to switch it off with
$ProgressPreference = 'SilentlyContinue'.
Download the Sample Code
The code covered in this article is packaged in ready-to-run form. It includes the one-JSON-per-line structured log and the way to write functions that don’t pollute their return values.
Download the sample code (zip)
The samples in this article have been actually executed and verified on PowerShell 7.6 (14 Pester tests). Run the Invoke-SampleTests.ps1 included in the zip and you can reproduce the same verification on your own machine.
# Syntax parsing + static analysis + Pester tests
./Invoke-SampleTests.ps1
The configuration values (paths, server names, tenant IDs and so on) are examples. Do not run them against a production environment as they are — adapt them to your own environment.
Related Articles
- PowerShell Error Handling and Retry Design — From the try/catch Trap to Exit Codes and Retry Best Practices
- PowerShell Parameter Design and Modularisation — From “a Script That Works” to “a Script You Can Hand Over”
- Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting
- An Introduction to Windows Event Log and ETW — Putting Your Business App’s Logs on the OS’s Standard Mechanisms
- Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break
- Minimum Requirements for a Custom Logger, with an Integration Test Checklist
Related Consulting Areas
KomuraSoft LLC handles reviews of logging design in operations scripts, resolving the “we can’t tell why it failed” situation, and building log foundations that feed into monitoring and aggregation.
- Technical Consulting & Design Review
- Bug Investigation & Root-Cause Analysis
- Maintenance & Modernization of Existing Windows Software
- Contact Us
References
-
Microsoft Learn, about_Redirection. On PowerShell having success, error, warning, verbose, debug and information streams each identified by a number; redirection to a file with
>and>>; merging into another stream withn>&1; and redirecting all streams with*>. ↩ ↩2 -
Microsoft Learn, Write-Host. On Write-Host from PowerShell 5.0 onwards becoming a wrapper around Write-Information that outputs to the information stream, making redirection with 6> possible; on decoration via -ForegroundColor / -BackgroundColor; and on its output not being passed down the pipeline. Relatedly, Write-Information covers explicit writes to the information stream and classification with -Tags. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Write-Output. On sending objects to the success stream (the pipeline), and on the result of an expression being output in the same way even without calling it explicitly. ↩ ↩2
-
Microsoft Learn, about_Return. On PowerShell functions returning every object emitted inside the function to the caller regardless of whether return is present, and on return being the syntax for returning a value while exiting the current scope. ↩ ↩2
-
Microsoft Learn, Write-Progress. On emitting a command’s progress as the host’s progress display, on controlling that display with $ProgressPreference, and on it also being controllable via the -ProgressAction common parameter from PowerShell 7.4 onwards. ↩ ↩2 ↩3
-
Microsoft Learn, about_Functions_CmdletBindingAttribute. On advanced functions carrying the [CmdletBinding()] attribute behaving like compiled cmdlets, and on the common parameters becoming automatically available. ↩ ↩2
-
Microsoft Learn, about_CommonParameters. On the behaviour of -Verbose / -Debug / -WarningAction / -InformationAction / -ErrorAction and the corresponding -*Variable parameters, and their relationship to the preference variables. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, about_Preference_Variables. On $VerbosePreference, $DebugPreference and $InformationPreference defaulting to SilentlyContinue while $WarningPreference and $ErrorActionPreference default to Continue; on controlling progress display with $ProgressPreference; and on these applying to the current scope and its child scopes. ↩ ↩2 ↩3
-
Microsoft Learn, Start-Transcript. On recording a session’s commands and console output to a text file, on appending with -Append, and on stopping with Stop-Transcript. ↩ ↩2 ↩3
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Where to Look When a PowerShell Script Is Slow — Arrays, Pipelines and Matching
The classic causes of slow PowerShell scripts, laid out. Why += on an array is O(n^2), the difference between the pipeline and foreach, t...
Parallel Processing in PowerShell — Choosing Between ForEach-Object -Parallel and Jobs
A practical rundown of the differences between ForEach-Object -Parallel, Start-ThreadJob and Start-Job and when to use each, $using: and ...
Calling External EXEs Correctly from PowerShell — The Pitfalls of Argument Quoting, Exit Codes, and Mojibake
Call robocopy or an in-house EXE from PowerShell and the arguments break, the exit code is unavailable, and the output turns into mojibak...
Handling Credentials Safely in PowerShell — Banishing Plaintext Passwords from Your Scripts
A practical walkthrough of moving plaintext passwords out of PowerShell scripts and into safe storage: what SecureString really is and wh...
Parameter Design and Modularization for PowerShell Scripts — From a Script That Works to a Script You Can Hand Over
A step-by-step procedure for raising a PowerShell script to a quality you can hand to someone else. Covers the param block and [CmdletBin...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.
Frequently Asked Questions
Common questions about the topic of this article.
- Is Write-Host something I must never use?
- It isn't banned — the accurate way to put it is that its uses are limited. From PowerShell 5.0 onwards Write-Host writes to the information stream (number 6), so it can be redirected with 6> and captured with -InformationVariable. That said, it is a command built on the assumption that its output always appears on screen by default, and you cannot use it to send a value down the pipeline. The split is: Write-Host for decorated output meant for a human in an interactive tool; Write-Verbose or Write-Information for progress and supplementary information; and Write-Output (or a bare expression) for values that later stages need to consume.
- Unintended values are getting mixed into my function's return value.
- That's because a PowerShell function returns every object emitted inside it, whether or not there is an explicit return. When you call a command or method that returns a value — New-Item, or StringBuilder's .Append(), for example — without doing anything with the result, that return value flows into the success stream and reaches the caller. Conversely, methods that return void, such as List[T]'s .Add(), emit nothing, so no suppression is needed. The fix is to discard the unwanted output with $null = ..., append | Out-Null, or cast with [void]. In performance terms, $null = ... is the lightest of the three.
- I want to be able to switch a script's detailed logging on and off at run time.
- Write the intermediate progress with Write-Verbose and add [CmdletBinding()] to the function. That alone makes it appear only when the caller specifies -Verbose. If you want it on permanently, set $VerbosePreference = 'Continue' at the top of the script. In the same way, Write-Debug is controlled from the caller with -Debug and Write-Warning with -WarningAction. Rather than inventing your own log-level variable, riding on PowerShell's standard mechanisms conveys your intent far better when somebody else reads the code.
- How can I capture everything — verbose output, warnings and all — into a file?
- There are three approaches depending on what you need. To simply dump every stream to a file, redirect with *>. To keep a complete record of what appeared on screen as evidence of the run, Start-Transcript is the easy option. If you want to analyse the output programmatically later, writing a structured log of one JSON object per line from your own logging function is the reliable choice — and even then it is worth running a transcript alongside it as a safety net.
- Can Write-Progress output be kept in a log file?
- No. Progress display is a host display feature and is handled separately from the redirectable data streams. In an unattended run there is nowhere for it to be displayed, so treat progress as something distinct from the information you keep in logs. In unattended runs, setting $ProgressPreference = 'SilentlyContinue' to stop progress display altogether can make things visibly faster in some environments. If you do want progress in the log, it is more practical to write only the milestones — "50 of 100 complete" — with Write-Verbose.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links