Stop Using Write-Host — PowerShell Output Streams and Log Design

· · 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-Host writes to the information stream. That means it can be redirected with 6> and captured with -InformationVariable. Before that, it could be neither captured nor suppressed.2
  • Write-Host is exclusively for “display shown to a human”. You cannot use it to return values. Values passed down the pipeline go through Write-Output (or a bare expression).23
  • A function returns every object emitted inside it. Whether or not there is a return is irrelevant. The standard move is to discard unwanted output with $null = ....4
  • Progress goes to Write-Progress; the course of processing goes to Write-Verbose. Progress display is not a redirectable data stream.5
  • A function with [CmdletBinding()] automatically gains the common parameters such as -Verbose, -Debug and -InformationAction. Putting verbosity in the caller’s hands is the right answer.67
  • The defaults are worth remembering. $VerbosePreference, $DebugPreference and $InformationPreference are SilentlyContinue; $WarningPreference and $ErrorActionPreference are 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-Transcript alongside 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-Host writes 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 use Write-Verbose / Write-Information to 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-Transcript alongside 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.

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.

References

  1. 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 with n>&1; and redirecting all streams with *> 2

  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

  3. 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

  4. 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

  5. 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

  6. 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

  7. 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

  8. 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

  9. 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

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Back to the Blog