Output in Bash: echo and printf

echo is the command everybody learns first (see echo and printf), and for a fixed message it is fine. For anything else it has two problems. It treats a first argument that looks like an option as an option, so a variable that happens to contain -n prints nothing. And its handling of backslashes differs between shells: Bash needs -e for \t, dash (/bin/sh on Debian and Ubuntu) interprets it without.

option="-n"
echo "$option"
echo "---"
printf '%s\n' "$option"
echo -e "web01\tOK"
sh -c 'echo "web01\tOK"'
---
-n
web01	OK
web01	OK

printf is the command that prints text according to a format string: placeholders such as %s (string), %d (whole number) and %.2f (number with two decimals) are filled with the arguments that follow, and nothing is added that you did not ask for, not even a newline. It is a Bash builtin and a standard command, and it behaves the same in every shell, which is why POSIX (the Portable Operating System Interface standard) itself recommends it over echo for new scripts.

printf '%s kWh\n' 4521 312
printf '%-8s|%6d\n' M-1042 4521 M-1043 312
printf '%.2f EUR\n' 1265.879
printf 'INC-%05d\n' 42
printf -v ticket 'INC-%05d' 20419
echo "Ticket: $ticket"
4521 kWh
312 kWh
M-1042  |  4521
M-1043  |   312
1265.88 EUR
INC-00042
Ticket: INC-20419
  • If there are more arguments than placeholders, printf repeats the format: that is how one line prints a whole table.
  • %-8s pads to 8 characters, left-aligned; %6d pads a number to 6, right-aligned; %05d pads with zeros.
  • printf -v name (Bash only) stores the result in a variable instead of printing it.
printf 'Meter %s: %d kWh\n' M-1042 abc
echo "exit status: $?"
printf 'Day %d\n' 08
echo "exit status: $?"
example.sh: line 1: printf: abc: invalid number
Meter M-1042: 0 kWh
exit status: 1
example.sh: line 3: printf: 08: invalid octal number
Day 0
exit status: 1

Use %s for values you only display, and validate input before you use it as a number (see Reading input). In Bash arithmetic, 10#$day forces base 10.

Formatting in PowerShell

PowerShell's counterpart of printf is the format operator -f: placeholders in braces, numbered from 0, with an optional width (negative for left-aligned) and a format such as N2 (number with thousands separators and two decimals) or D5 (whole number, five digits).

'{0,-8}|{1,6}' -f 'M-1042', 4521
'{0,-8}|{1,6}' -f 'M-1043', 312
'{0:N2} EUR' -f 1265.879
'INC-{0:D5}' -f 42
'{0} has {1} open tickets' -f 'Amara', 3
M-1042  |  4521
M-1043  |   312
1,265.88 EUR
INC-00042
Amara has 3 open tickets

Number and date formats follow the culture of the session. The examples here run with the invariant culture, which writes 1,265.88; on a Dutch Windows machine the same line prints 1.265,88. For files that other programs read, use a fixed format and culture; for people, the local one is friendlier.

Output in PowerShell

PowerShell has a rule that surprises everyone coming from Bash: anything a line produces that is not captured or redirected is output. A string on its own line, the result of a calculation, the objects a cmdlet returns: all of it goes to the success stream, and from there down the pipeline or, at the end, to the screen. Write-Output is the cmdlet that sends objects to that success stream explicitly (its alias is echo); writing a bare value does the same.

The consequence: a function returns everything it outputs, not just the value after return. A status message meant for the user ends up in the result:

function Get-Usage {
  "Checking disk"
  91
}
$u = Get-Usage
"Captured $($u.Count) values: $($u -join ', ')"

function Get-Total {
  Write-Host "Calculating..."
  Write-Output 42
}
$t = Get-Total
"Total: $t"
Captured 2 values: Checking disk, 91
Calculating...
Total: 42

Get-Usage was meant to return 91 and returned an array of two values. Get-Total does it right. Write-Host writes text for the person at the screen: it is not part of the function's output, so $t holds only 42, while "Calculating..." still appears. Write-Host can also color text, which makes it the natural choice for progress and status messages in interactive scripts.

In Bash the same split exists, but by convention: data goes to standard output, and a message such as "Calculating..." belongs on standard error (echo "Calculating..." >&2), so that t=$(get_total) captures only the number.

Output streams

A Bash script has the three standard streams of every program: standard input (0), standard output (1) and standard error (2). Results go to standard output, errors and messages to standard error, so that a pipe or a redirection carries only the data. Inside a script you send a line to standard error with >&2:

check() {
  echo "web01: OK"
  echo "db01: no response" >&2
}
check > report.txt 2> errors.txt
echo "report.txt: $(cat report.txt)"
echo "errors.txt: $(cat errors.txt)"
check 2>/dev/null
report.txt: web01: OK
errors.txt: db01: no response
web01: OK

PowerShell has six numbered output streams, each with its own cmdlet, and the redirection operators use the same numbers:

No. Stream Written by Shown by default
1 Success Bare values, Write-Output Yes (it is the output)
2 Error Write-Error, failing cmdlets Yes
3 Warning Write-Warning Yes, prefixed WARNING:
4 Verbose Write-Verbose No; with -Verbose
5 Debug Write-Debug No; with -Debug
6 Information Write-Information, Write-Host Write-Host yes, Write-Information no

The Information stream is stream 6, added in PowerShell 5.0 for messages meant for the user rather than for the pipeline. Since then Write-Host is a wrapper around Write-Information that is always displayed, which means its text can be suppressed with 6>$null or captured with 6>&1.

Write-Host "Starting check" 6>$null
Write-Host "Visible on screen"
Write-Information "Not shown by default"
Write-Information "Shown" -InformationAction Continue
Write-Verbose "Only with -Verbose"
Write-Verbose "Forced" -Verbose
Visible on screen
Shown
VERBOSE: Forced

Redirection works per stream, and *> takes all of them. Because PowerShell streams carry objects, what you capture are records, not lines of text:

function Test-Server {
  "web01: OK"
  Write-Warning "db01: slow response"
  Write-Error "db01: no response"
}
Test-Server 2>$null 3>$null
"---"
$result = Test-Server *>&1
$result | ForEach-Object { $_.GetType().Name }
web01: OK
---
String
WarningRecord
ErrorRecord
A Bash script writes to two output streams, 1 standard output and 2 standard error. A PowerShell script writes to six: 1 Success, 2 Error, 3 Warning, 4 Verbose, 5 Debug and 6 Information. Only Success flows on through the pipeline or into a variable; the others go to the screen or are hidden. Bash script 1 stdout data: pipe, file, $(…) 2 stderr errors and messages: screen, log PowerShell script 1 Success objects: pipeline, variable, return value 2 Error · 3 Warning shown 4 Verbose · 5 Debug · 6 Info hidden unless asked
Only the success stream is a function's result. Everything else is for people, logs and error handling. (Write-Host is the exception on stream 6: always shown.)

Reading input

Scripts that run unattended should take their input from arguments, files or environment variables, never from a prompt. For interactive tools, both shells can ask. In Bash, read reads one line of standard input into variables; always give it -r so backslashes stay as they are:

printf 'C:\\Logs\\new\n' > paths.txt
read line < paths.txt
echo "Without -r: $line"
read -r line < paths.txt
echo "With -r:    $line"
Without -r: C:Logsnew
With -r:    C:\Logs\new

-p "Prompt: " shows a prompt, but only when the input comes from a terminal. When the script is fed through a pipe or a file, as in the next example, Bash shows no prompt at all, which is what you want in automation. Everything a user types is untrusted text, so the script checks the reading before calculating with it:

cat > ask.sh <<'X'
#!/bin/bash
read -rp "Name: " name
read -rp "Reading in kWh: " kwh
if [[ ! $kwh =~ ^[0-9]{1,6}$ ]]; then
  echo "Not a whole number: '$kwh'" >&2
  exit 2
fi
echo "Thanks, $name. Usage since the last reading: $(( 10#$kwh - 4171 )) kWh"
X
printf 'Amara\n4521\n' | bash ask.sh
printf 'Amara\nlots\n' | bash ask.sh
echo "exit status: $?"
Thanks, Amara. Usage since the last reading: 350 kWh
Not a whole number: 'lots'
exit status: 2

Read-Host is PowerShell's cmdlet for reading a line typed by the user, with an optional prompt to which it adds ": ". It always returns a string, whatever the user types. The same check in PowerShell, first with valid input, then with invalid input (the runner echoes the typed text after the prompt, as a console would):

$name = Read-Host "Name"
$kwh = Read-Host "Reading in kWh"
if ($kwh -notmatch '^\d{1,6}$') {
  Write-Error "Not a whole number: '$kwh'"
  exit 2
}
"Thanks, $name. Usage since the last reading: $([int]$kwh - 4171) kWh"
Name: Amara
Reading in kWh: 4521
Thanks, Amara. Usage since the last reading: 350 kWh
$name = Read-Host "Name"
$kwh = Read-Host "Reading in kWh"
if ($kwh -notmatch '^\d{1,6}$') {
  Write-Error "Not a whole number: '$kwh'"
  exit 2
}
"Thanks, $name. Usage since the last reading: $([int]$kwh - 4171) kWh"
Name: Amara
Reading in kWh: lots
Write-Error: Not a whole number: 'lots'

Without the [int], $kwh - 4171 would still work (the minus operator converts), but $kwh + 350 would append text. In real PowerShell tools, a mandatory parameter with a validation attribute is usually better than Read-Host: PowerShell then prompts for a missing value itself and rejects invalid ones before your code runs (see Functions and Parameters).

read -rsp "API token: " token; echo
$token = Read-Host "API token" -AsSecureString

Writing output to files

Bash writes files with redirection: > replaces the file, >> appends, and tee writes to a file and the screen at once.

echo "web01: OK" > report.txt
echo "db01: OK" >> report.txt
echo "app01: OK" | tee -a report.txt
echo "---"
cat report.txt
app01: OK
---
web01: OK
db01: OK
app01: OK

PowerShell has the same operators, plus cmdlets: Out-File writes what you would see on screen, Set-Content and Add-Content write the values as text, and Tee-Object works like tee.

"web01: OK" | Out-File report.txt
"db01: OK" | Out-File report.txt -Append
Add-Content report.txt "app01: OK"
Get-Content report.txt
(Get-Content report.txt -AsByteStream)[0..2] -join ' '
web01: OK
db01: OK
app01: OK
119 101 98

The last line shows the first three bytes of the file: 119, 101, 98 are "w", "e", "b" in UTF-8, with no byte order mark in front. That is the default in PowerShell 7. Windows PowerShell 5.1 is different: its Out-File and > write UTF-16 with a byte order mark, which grep and many Linux tools read as text full of zero bytes. When a file must be read by another program, choose the encoding explicitly: Set-Content -Encoding utf8.

Side by side

Task Bash PowerShell
Output a value printf '%s\n' "$x", echo "$x" $x, Write-Output $x
Message for the user echo "…" >&2 Write-Host, Write-Verbose
Error message echo "…" >&2 plus exit 1 Write-Error, throw
Warning echo "WARNING: …" >&2 Write-Warning
Format a number printf '%.2f' 3.14159 '{0:N2}' -f 3.14159
Read a line read -rp "Name: " name $name = Read-Host "Name"
Write / append a file > / >>, tee -a Set-Content / Add-Content, Tee-Object
Discard output >/dev/null 2>&1 *>$null, | Out-Null