Session 8 · Computer Systems
PowerShell: objects, cmdlets and scripts
PowerShell from the first script to file processing and pipelines: variables and types, comparisons, if and switch, loops, parameters, functions, arrays and hashtables. The idea that sets it apart gets the most room – a command returns objects with named properties, not text – and a cheat sheet maps everything to its bash form.
PowerShell is a shell and a scripting language. You type commands into it, or you put them into a file and run the file – the same two uses a Unix shell has. It comes with Windows, and its current version runs on Linux and macOS too.
If you have written bash scripts, most of the ideas here are old friends: variables, conditions, loops, functions, pipes. They are spelled differently, and one thing is truly new. A bash command prints text, and the next command picks that text apart. A PowerShell command returns objects, and the next command asks them for what it needs by name. This session covers the language from the first script to file processing and pipelines.
The one thing to remember: PowerShell is a language of objects. A command returns values that have named properties; a pipe hands those values on unchanged; a filter, a sort or a comparison asks for a property by name. Text only appears at the very end, when something is shown on the screen.
Downloads
Example scripts
One commented script per topic, in the order of the sections below, and the two data files the last two read. Each script also notes the bash form of what it shows. Read them next to the text, run them, change them – inside PowerShell, from the folder you saved them to: ./1_first_script.ps1, ./8_read_file.ps1 numbers_mixed.txt.
1_first_script.ps12_script_with_parameters.ps13_if_statement.ps14_strings.ps15_loops.ps16_functions.ps17_arrays_hashtables.ps18_read_file.ps19_pipeline.ps1numbers_mixed.txtpatients.txt
Or all at once, from the terminal – the files land in the current directory:
Linux, macOS, the course server
curl -O "https://laszlonemes.com/files/computer-systems/powershell-basics/{1_first_script.ps1,2_script_with_parameters.ps1,3_if_statement.ps1,4_strings.ps1,5_loops.ps1,6_functions.ps1,7_arrays_hashtables.ps1,8_read_file.ps1,9_pipeline.ps1,numbers_mixed.txt,patients.txt}"Windows (PowerShell, cmd)
curl.exe -O "https://laszlonemes.com/files/computer-systems/powershell-basics/{1_first_script.ps1,2_script_with_parameters.ps1,3_if_statement.ps1,4_strings.ps1,5_loops.ps1,6_functions.ps1,7_arrays_hashtables.ps1,8_read_file.ps1,9_pipeline.ps1,numbers_mixed.txt,patients.txt}"Look at the data files freely, but don't save them from an editor: their line endings and last bytes are part of the exercise, and an editor may "fix" them.
Two programs called PowerShell
There are two programs with this name, and it matters which one you start (Fig. 1). Windows PowerShell 5.1 is part of Windows and no longer gets new features. PowerShell 7 is the one being developed: it is open source, runs on Windows, Linux and macOS, and reads and writes files as UTF-8 by default.
| Windows PowerShell | PowerShell | |
|---|---|---|
| Version | 5.1 – the last one | 7.x |
| Started with | powershell | pwsh |
| Runs on | Windows only | Windows, Linux, macOS |
| Installation | part of Windows | separate – on Windows too |
| Icon and window title | blue, "Windows PowerShell" | black, "PowerShell 7" |
$PSEdition | Desktop | Core |
Use PowerShell 7 – pwsh – on every system, Windows included. A script written for it behaves the same everywhere. Everything on these pages is written for version 7; the example scripts also run in 5.1, but a few things behave differently there (see encoding).
installing PowerShell 7
winget install --id Microsoft.PowerShell --source winget # Windows
brew install powershell/tap/powershell # macOS
sudo snap install powershell --classic # Linux (Ubuntu)The official install guide covers every other system. On Linux and macOS, PowerShell is just another program: you enter it from your usual terminal with pwsh and leave it with exit.
pwsh # enter PowerShell (the prompt starts with PS)
$PSVersionTable.PSVersion # the version: 7.x
$PSEdition # Core = PowerShell 7, Desktop = Windows PowerShell 5.1
$IsWindows; $IsLinux; $IsMacOS # which system is this? (these exist only in version 7)
exit # back to the shell you came fromYour first script
A PowerShell script is a text file whose name ends in .ps1. This is 1_first_script.ps1, without its comments:
1_first_script.ps1
Write-Output "Hello PowerShell!"
$name = "Anna" # the $ is there in the assignment too; spaces around = are fine
$age = 21
Write-Output "Name: $name, age: $age" # Name: Anna, age: 21
Write-Output 'Name: $name, age: $age' # Name: $name, age: $age
$nextYear = $age + 1 # a number is a number: arithmetic needs no special syntax
Write-Output "In ten years: $($age + 10)" # $( ) puts any expression into a string
Write-Output "Uppercase: $($name.ToUpper()), length: $($name.Length)"Write-Output- Prints its argument to the output.
echois another name for the same command. $name = "Anna"- A variable. The
$is part of its name everywhere – in the assignment too – and spaces around=are fine. "…"- Double quotes: variables inside are replaced by their value.
'…'- Single quotes: everything stays as written.
$( … )- Inside a string: evaluate this expression and insert the result.
# …- A comment. Several lines at once go between
<#and#>.
To run a script, give its path. For a script in the current directory that means ./ in front – PowerShell doesn't look for commands in the current directory by itself.
./1_first_script.ps1 # inside PowerShell
pwsh ./1_first_script.ps1 # from bash or zsh: hand the file to the pwsh program- The extension
.ps1is required. Execute permission is not – inside PowerShell every.ps1can be run. - No first line is needed. The examples still begin with
#!/usr/bin/env pwsh: on Linux and macOS this shebang lets you start them straight from bash afterchmod +x. For PowerShell it is only a comment. - Write paths with
/. PowerShell's own commands accept\too, but external programs on Linux and macOS don't. - Commands, variables and operators ignore upper and lower case:
write-output,$NAMEand-EQall work.
The execution policy (Windows only)
Windows PowerShell 5.1 refuses to run scripts by default: ./script.ps1 answers running scripts is disabled on this system. This is the execution policy, a guard against starting a downloaded script by accident. PowerShell 7 on Windows normally installs with a policy that lets your own scripts run. If you do meet the error, change the policy for your own user – in the PowerShell you actually use, because the two keep separate settings:
Get-ExecutionPolicy # Windows PowerShell 5.1 default: Restricted
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # your own scripts may run| Value | Meaning |
|---|---|
Restricted | no script may run, only typed commands |
RemoteSigned | scripts written on this machine run; downloaded ones only if signed (recommended) |
Unrestricted | everything runs, with a warning for downloaded scripts |
A script you downloaded – the examples above, say – is unblocked with Unblock-File -Path script.ps1, after you have read what it does. Linux and macOS have no execution policy.
Commands return objects
An object is a value that carries named pieces of data – its properties – and things it can do – its methods. Both are reached with a dot. Get-ChildItem, the command that lists a directory, doesn't produce lines of text: it returns one object per file, and each of them knows its Name, its size (Length) and the time it was last changed (LastWriteTime).
That is the whole difference from a Unix shell (Fig. 2). There, "the size of a file" is the fifth column of a line that ls -l printed, and a file name with a space in it shifts the columns. Here it is a property with a name, and the next command can use it directly:
Get-ChildItem | Select-Object Name, Length
Get-ChildItem | Where-Object Length -gt 1000 | Sort-Object Length -DescendingEvery value is an object – a string and a number as well:
$name = "Anna"
$name.Length # 4 – a property
$name.ToUpper() # ANNA – a method
$file = Get-Item ./numbers_mixed.txt
$file.Name # numbers_mixed.txt
$file.Length # 34 – the size in bytes
$file.LastWriteTime # the time of the last change – a date object, not textYou don't have to remember what an object offers. Pipe it into Get-Member and it lists its properties and methods:
"some text" | Get-Member # every property and method of a string
Get-ChildItem | Get-Member -MemberType Property # only the properties of a file objectCmdlets: Verb-Noun
PowerShell's built-in commands are called cmdlets ("command-lets"). Every name is a verb, a dash and a noun – Get-Content, Set-Content, Get-ChildItem, Copy-Item, Remove-Item, Test-Path – so a name you haven't seen yet can often be guessed. Options are whole words with one dash: Get-ChildItem -Recurse -Force. And the shell documents itself:
Get-Help Get-ChildItem -Examples # how a command is used, with examples (-Online opens the browser)
Get-Command *Content* # every command with "Content" in its name
Get-Command -Verb Get -Noun *Item* # search by verb and by noun
Get-Alias dir # which cmdlet is behind an aliasAliases, and where they stop working
Long names are easy to read and slow to type, so many cmdlets have a short second name, an alias: gci and dir for Get-ChildItem, gc for Get-Content, echo for Write-Output, cd for Set-Location. Use them freely at the prompt. In a script, write the full name: it is easier to read, and it means the same on every system – which is not true of every alias.
Variables, types and quoting
A variable holds an object, and so it has a type: text, a whole number, a decimal number, true/false, an array, a file. PowerShell picks the type from the value. You can also state it; the value is then converted, or the assignment fails:
[int]$count = "42" # the text becomes a number
[int]$count = "abc" # ERROR: Cannot convert value "abc" to type "System.Int32"
$number = [int]"42" # a conversion inside an expression
$number.GetType().Name # Int32Because a number really is a number, arithmetic needs no special syntax. One thing to know: division keeps the fraction.
7 + 2 * 3 # 13
7 / 2 # 3.5 – division doesn't drop the fraction
[math]::Truncate(7 / 2) # 3 – this does
[int](7 / 2) # 4 – a conversion rounds
7 % 2 # 1 – the remainder$null- The value "nothing" – what an unset variable or a missing hashtable key gives.
$true,$false- The two logical values. Comparisons and yes/no functions return them.
$env:PATH- An environment variable.
$HOMEexists as an ordinary variable too. `n,`t,`$- Special characters in a double-quoted string. The escape character is the backtick, not the backslash.
${name}- The variable name in braces, for when a letter or a colon follows it directly.
Putting values into a string
Inside double quotes, $name is replaced by the value of the variable. That replacement covers the variable only – not a property after it (Fig. 4).
$( ). Without it you get the object, then the leftover text.Working with strings
A string is an object with methods of its own, and PowerShell adds a set of text operators on top. Between them they cover what takes tr, cut and sed elsewhere. The script 4_strings.ps1 runs through all of these:
$word = "PowerShell"
$word.Length # 10 the number of characters
$word.Substring(0, 5) # Power five characters from position 0
$word[0]; $word[-1] # P l one character; a negative index counts from the end
" Hello World ".Trim() # Hello World spaces and tabs cut from both ends
$word.ToUpper() # POWERSHELL .ToLower() is its pair
$word -replace 'Shell', 'Point' # PowerPoint every occurrence is replaced
$name, $visits, $date = "Kiss Lajos, 12, 2023-11-13" -split ','
$name.Trim() # Kiss Lajos each field in its own variable
"one", "two", "three" -join '-' # one-two-threeTwo operators test what a string looks like, and they are easy to mix up:
| Operator | Pattern language | Example |
|---|---|---|
-like | wildcards: * any text, ? one character. The whole string must fit. | $name -like '*.txt' |
-match | a regular expression. Matches anywhere unless anchored with ^ and $. | $line -match '^[0-9]+$' |
-notlike, -notmatch | the negated forms | $line -notmatch '^#' |
-replace | a regular expression and a replacement; every match is replaced | $s -replace '[0-9]+', '' |
-split | a regular expression that marks where to cut; the result is an array | $line -split ',' |
After a successful -match, the automatic variable $Matches holds what matched. The parts in parentheses are available by number:
if ("2023-11-13" -match '^([0-9]{4})-([0-9]{2})-([0-9]{2})$') {
Write-Output "Year: $($Matches[1]), month: $($Matches[2])" # Year: 2023, month: 11
}Regular expressions here
- The syntax is the familiar one:
^,$,[0-9],+,*,?,{2},( | ),\.. - Write the pattern in quotes, preferably single ones, so nothing in it is replaced.
- Matching ignores case. The case-sensitive forms have a
cin front:-cmatch,-clike,-ceq. - The shorthand classes are
\d(a digit),\s(whitespace) and\p{L}(a letter, accented ones included).
Comparing and deciding
Comparison operators are words with a dash in front, and the same ones work for numbers, text and every other type. They return $true or $false:
5 -gt 3 # True
"apple" -eq "APPLE" # True – comparing text ignores case
"apple" -ceq "APPLE" # False – the c prefix makes it case-sensitive
"report.txt" -like '*.txt' # True – a wildcard pattern
"2026" -match '^[0-9]+$' # True – a regular expression
3 -in 1, 2, 3 # True
(5 -gt 3) -and (2 -gt 3) # False
-not (Test-Path ./nope) # True| Operators | Meaning |
|---|---|
-eq, -ne | equal, not equal |
-lt, -le, -gt, -ge | less, less or equal, greater, greater or equal |
-and, -or, -not (or !) | combine and negate conditions |
-like, -match | wildcard pattern, regular expression |
-contains, -in | is a value in an array: $array -contains $x, $x -in $array |
if
The condition goes between parentheses, each branch between braces. The script 3_if_statement.ps1 has the longer version.
if ($number -gt 0) {
Write-Output "positive"
} elseif ($number -lt 0) {
Write-Output "negative"
} else {
Write-Output "zero"
}Questions about files are answered by a cmdlet, Test-Path. It returns true or false, so it goes straight into the condition: -PathType Leaf asks for a file, -PathType Container for a directory, and without -PathType anything that exists will do.
if (Test-Path -Path $Value -PathType Leaf) {
Write-Output "'$Value' is a file"
} elseif (Test-Path -Path $Value -PathType Container) {
Write-Output "'$Value' is a directory"
}switch
When one value is compared with several fixed alternatives, switch is clearer than a chain of elseifs. The branch called default catches everything else.
switch ($option) {
'-a' { Write-Output "add" }
'-d' { Write-Output "delete" }
default { Write-Output "unknown" }
}With -Wildcard the alternatives are wildcard patterns, with -Regex regular expressions. Patterns can overlap – and then switch does something a case statement doesn't (Fig. 5):
case stops at the first matching branch; switch runs every branch that matches.switch -Wildcard ('apple.txt') {
'*.txt' { Write-Output "text file" } # runs
'apple*' { Write-Output "starts with apple" } # runs too – add break above to stop at the first
}Loops
The loops are the classic ones; the body always goes between braces. 5_loops.ps1 shows each of them running.
while (…) { }- Repeats as long as the condition is true. The condition is checked first.
do { } until (…)- Checks at the end, so the body runs at least once; repeats until the condition becomes true.
do { } while (…)is its mirror image. for (…; …; …) { }- The counting loop: start; condition; step.
foreach ($x in …) { }- Walks the elements of an array – or of anything a command returns.
1..5- A range: the numbers from 1 to 5, ready for
foreach. break,continue- Leave the loop; skip to the next round.
$i = 1
while ($i -le 3) {
Write-Output "i = $i"
$i++ # or: $i += 1
}
foreach ($n in 1..10) { # the numbers 1 to 10
if ($n % 2 -eq 0) { continue } # skip the even numbers
if ($n -gt 7) { break } # stop above 7
Write-Output $n # 1 3 5 7
}
foreach ($file in Get-ChildItem -Path . -Filter *.txt) { # every .txt file here
Write-Output "$($file.Name): $($file.Length) bytes" # file objects, not names
}
1..3 | ForEach-Object { Write-Output "$_ doubled is $($_ * 2)" } # a loop inside a pipelineScript parameters
A script declares what it expects in a param() block at its top. Every parameter has a name and, if you give one, a type and a default value. This is 2_script_with_parameters.ps1 in short:
param(
[string]$Name,
[int]$Times = 1 # the default when it isn't given
)
if (-not $Name) {
Write-Error "Usage: $($MyInvocation.MyCommand.Name) <name> [times]"
exit 1
}
for ($i = 1; $i -le $Times; $i++) {
Write-Output "$i. Hello $Name!"
}The caller can pass the values in order, or by name – then the order doesn't matter:
calling it
./2_script_with_parameters.ps1 Anna 3 # by position
./2_script_with_parameters.ps1 -Name Anna -Times 3 # by name
./2_script_with_parameters.ps1 -Times 3 -Name Anna # by name the order doesn't matter
./2_script_with_parameters.ps1 Anna # Times is 1
./2_script_with_parameters.ps1 Anna abc # stopped before it starts: "abc" is not an [int]The last line shows that a type is a check as well: the text abc can't become an [int], so PowerShell stops with an error before the first line of the script runs. If you want the error message to be your own, declare the parameter as [string] and test it with a regular expression – Parameters, error handling and text files compares the two ways.
$PSBoundParameters- The parameters that were actually passed.
.Countsays how many;.ContainsKey('Times')whether one was given. $args- Extra values that match no declared parameter. In a script without a
param()block, all parameters:$args[0],$args[1],$args.Count. $MyInvocation.MyCommand.Name- The name of the running script, for usage messages.
$?,$LASTEXITCODE- Whether the last command succeeded (true/false); the exit code of the last external program.
Functions
A function is declared like a small script: a name – by convention Verb-Noun again – and a param() block. 6_functions.ps1 has the examples of this section.
function Get-Sum {
param(
[int]$A,
[int]$B
)
return $A + $B
}
$result = Get-Sum 3 4The second trap is the result. A function doesn't return only what stands after return: its result is everything it writes to the output, and return merely adds one last value and ends the function (Fig. 6).
Write-Host bypasses the output.function Get-Square {
param([int]$N)
Write-Host "(calculating the square of $N)" # to the screen only – not part of the result
return $N * $N
}
$square = Get-Square 6 # prints the note; $square is 36
# with Write-Output instead of Write-Host, $square would be an array: the text AND 36A function that answers a yes/no question returns $true or $false and goes straight into an if. Such functions are usually named Test-…:
function Test-Even {
param([int]$N)
return ($N % 2 -eq 0) # the comparison is already $true or $false
}
if (Test-Even 4) { Write-Output "4 is even" }Output, input and errors
PowerShell keeps several output channels – streams – apart. What a script writes to the output stream is its result: it can be piped on or stored in a variable. Messages for the person at the keyboard go elsewhere.
| Cmdlet | Goes to | Use it for |
|---|---|---|
Write-Output | the output stream | results. A bare value does the same: "text" on its own line is printed. |
Write-Host | the screen only | progress notes that must not end up in a pipeline or a variable |
Write-Warning | the warning stream | something was skipped or looks wrong, but the script goes on |
Write-Error | the error stream | the reason the script can't do its job – followed by exit 1 |
Read-Host | – | asking the user for a line of input |
$name = Read-Host "Name" # prints "Name: " and waits – it adds the colon itself
[int]$number = Read-Host "Number" # Read-Host returns text; convert before you calculateA script ends with an exit code like any other program: exit 0 (or reaching the end) for success, exit 1 for failure. To throw output away, redirect it to $null or pipe it into Out-Null.
try and catch
An error can be caught: when something fails inside the try block, execution jumps to the catch block, where the error is in the variable $_.
try {
$result = $a / $b # dividing integers by zero is an error
$lines = Get-Content -Path $file -ErrorAction Stop
} catch {
Write-Error "Something went wrong: $($_.Exception.Message)"
exit 1
}Arrays and hashtables
An array is a numbered list of values. Many commands return one – the lines of a file, the files of a directory – and you make your own with commas. 7_arrays_hashtables.ps1 is the example script.
$fruits = "apple", "pear", "apple" # an array: values separated by commas
$empty = @() # an empty array
$fruits.Count # 3
$fruits[0] # apple
$fruits[-1] # apple – a negative index counts from the end
$fruits += "plum" # append an element
$fruits -contains "plum" # True
1..5 # the numbers 1 2 3 4 5
"one two three" -split '\s+' # the words of a line, as an arrayA hashtable stores values under names instead of numbers: each key – any text – has one value. It is the tool for "how many of each" and "the total per …" questions. A key that isn't in the table yet gives $null, and $null + 1 is 1, so counting needs no special case for the first occurrence:
$counts = @{}
foreach ($word in "apple", "pear", "apple") {
$counts[$word] = $counts[$word] + 1 # a missing key is $null, and $null + 1 is 1
}
foreach ($key in ($counts.Keys | Sort-Object)) { # the order of the keys isn't defined – sort them
Write-Output "${key}: $($counts[$key])" # apple: 2, pear: 1
}$counts = @{}- An empty hashtable.
$counts['apple']- The value stored under a key – to read it or to assign it.
$counts.Keys- All the keys, in no particular order. Sort them before printing.
$counts.Count- The number of keys.
$counts.ContainsKey($k)- Whether a key is in the table.
Files as objects and hashtables uses hashtables on real data, with a maximum search over the keys.
Reading and writing files
Reading: Get-Content
Get-Content returns the lines of a file, one string per line. Pipe them on, walk them with a loop, or keep them in a variable as an array:
Get-Content -Path numbers_mixed.txt # prints the file
$lines = Get-Content -Path numbers_mixed.txt # an array: $lines[0] is the first line
$lines.Count # 10 – wc -l would say 9: there is no final newlineReal files are rarely tidy. numbers_mixed.txt is a deliberately bad one; this is what cat -et shows of it – $ marks a newline, ^M a carriage return (the extra character of a Windows line ending), ^I a tab – and the last line has no newline at all:
cat -et numbers_mixed.txt
15$
-4$
$
7 ^M$
12^M$
abc$
^I3$
1.5$
$
100Reading and writing files; line endings shows what each of these does to a line-by-line loop in bash. Get-Content takes two of the four problems off your hands (Fig. 7): it cuts off either kind of line ending, and it returns the last line whether or not a newline follows it.
Get-Content solves the two about line endings; the two about content are yours.The pattern to start from
What remains is to trim each line and to skip the empty ones. Start every script that reads a file from this loop:
foreach ($line in Get-Content -Path $File -Encoding UTF8) {
$line = $line.Trim() # cut spaces and tabs from both ends
if ($line -eq '') { # skip empty lines
continue
}
# --- process the line ---
Write-Output $line
}Before the loop come two guards – the parameter was given, and the file exists:
param(
[string]$File
)
if (-not $File) {
Write-Error "Usage: $($MyInvocation.MyCommand.Name) <file>"
exit 1
}
if (-not (Test-Path -Path $File -PathType Leaf)) {
Write-Error "File '$File' does not exist"
exit 1
}8_read_file.ps1 is this skeleton with a few lines of processing: it adds up the whole numbers of a file and warns about everything else.
PS> ./8_read_file.ps1 numbers_mixed.txt
WARNING: Line 6: 'abc' is not an integer, skipped
WARNING: Line 8: '1.5' is not an integer, skipped
6 valid number(s), sum: 133
Report written to 'numbers_report.txt'Splitting a line into fields. -split cuts the line at a separator and returns an array; assigning that array to several variables at once gives each field a name. Trim the fields, and convert the ones you calculate with:
$name, $count, $date = $line -split ',' # "Kiss Lajos, 12, 2023-11-13"
$name = $name.Trim() # "Kiss Lajos" – the space inside the name stays
[int]$count = $count.Trim() # 12, as a numberWriting
Set-Content -Path out.txt -Value "first line" # creates the file, or overwrites it
Add-Content -Path out.txt -Value "second line" # appends to its end
Set-Content -Path out.txt -Value $lines # an array: one element per line
Get-Content in.txt | Sort-Object | Set-Content -Path sorted.txt # the output of a pipelineWhen a script builds its output line by line, the simplest way is to collect the lines in an array and write the array once at the end. Nothing has to be emptied first, and running the script twice gives the same file:
$result = @()
foreach ($line in Get-Content -Path $File -Encoding UTF8) {
$result += $line.ToUpper()
}
Set-Content -Path $OutFile -Value $result -Encoding UTF8 # one element per line, newline after the lastWriting back to the file you read. Put the reading in parentheses. ( ) makes Get-Content read the whole file into memory first; only then does the writing start. Without them, the file is opened for writing while it is still being read.
(Get-Content -Path $File) -replace 'apple', 'peach' | Set-Content -Path $FileEncoding and line endings
| PowerShell 7 | Windows PowerShell 5.1 | |
|---|---|---|
| reading, by default | UTF-8 | the system's legacy code page – accented letters break |
Set-Content, Add-Content by default | UTF-8 | the system's legacy code page |
>, >>, Out-File by default | UTF-8 | UTF-16 – Unix tools show it as garbage |
with -Encoding UTF8 | UTF-8 (same as the default) | UTF-8, with a marker (BOM) at the start of the file |
That is why the example scripts write -Encoding UTF8 on every read and write: in PowerShell 7 it changes nothing, and in the old Windows PowerShell it keeps accented text intact. For the same reason they don't write files with >. A file PowerShell writes gets the line endings of the system it runs on – CRLF on Windows, LF on Linux and macOS; when reading, either is fine.
Pipelines
The | sends the output of one command to the next, one object at a time. Inside the braces of a pipeline cmdlet, the object that is just passing through is called $_. Six cmdlets do most of the work:
| Cmdlet | What it does |
|---|---|
Where-Object { … } | lets through the objects for which the condition is true |
ForEach-Object { … } | replaces each object by whatever the block outputs |
Sort-Object | sorts – by a property (-Property Length) or a computed value, -Descending for largest first, -Unique to drop repeats |
Select-Object | keeps the first or last few (-First 3, -Last 3), skips some (-Skip 1), or keeps only certain properties |
Group-Object | puts equal values into groups; each group has a Name and a Count |
Measure-Object | counts, and with -Sum, -Average, -Maximum, -Minimum does the arithmetic |
Get-Content patients.txt | Where-Object { $_ -match ',2023-10-' } # keep the lines that match
Get-Content patients.txt | ForEach-Object { ($_ -split ',')[0] } # turn every line into its first field
Get-Content patients.txt | Select-Object -First 3 # the first three lines
Get-ChildItem | Sort-Object -Property Length -Descending # file objects, largest firstA worked example: the most common value
patients.txt has one patient per line: name, date of the visit, illness. Which illness was the most common in October? Keep the October lines, cut out the third field, group equal illnesses, sort the groups by size, keep the first:
Get-Content patients.txt |
Where-Object { $_ -match ',2023-10-' } |
ForEach-Object { ($_ -split ',')[2] } |
Group-Object |
Sort-Object -Property Count -Descending |
Select-Object -First 1Count Name Group
----- ---- -----
3 headache {headache, headache, headache}Group-Object on, objects with a Name and a Count travel down the pipe.The result is not a line of text to take apart again but one object, whose two values already have names (Fig. 8):
$top = Get-Content patients.txt | … | Select-Object -First 1
Write-Output "Most common illness in October: $($top.Name) ($($top.Count) patient(s))"9_pipeline.ps1 builds this up step by step. Typed at the prompt, with aliases, the same pipeline shrinks to one line – fine for trying things out, too terse for a script:
gc patients.txt | ? { $_ -match ',2023-10-' } | % { ($_ -split ',')[2] } | group | Sort-Object Count -Descending | select -First 1And when the question is a number – how many, how much in total, the largest – end the pipeline with Measure-Object:
$stats = 4, 8, 15, 16, 23, 42 | Measure-Object -Sum -Average -Maximum -Minimum
$stats.Sum # 108
$stats.Maximum # 42Coming from bash: a cheat sheet
If you already write bash, this is the quickest way in: the same things, side by side. The bash column is explained in Shell scripts, Shell scripting in practice and Text processing.
The language
| bash | PowerShell | |
|---|---|---|
| assignment | name="Anna" | $name = "Anna" |
| arithmetic | $(( a + b )) | $a + $b |
| length of a string | ${#s} | $s.Length |
| substring | ${s:0:5} | $s.Substring(0, 5) |
| replace | ${s//a/b}, sed 's/a/b/g' | $s -replace 'a', 'b' |
| split into fields | IFS=',' read -r a b <<< "$s" | $a, $b = $s -split ',' |
| wildcard test | [[ $s == *apple* ]] | $s -like '*apple*' |
| regex test | [[ $s =~ ^[0-9]+$ ]] | $s -match '^[0-9]+$' |
| and, or, not | &&, ||, ! | -and, -or, -not |
| file / directory exists | [ -f "$p" ], [ -d "$p" ] | Test-Path $p -PathType Leaf / Container |
| if | if [ … ]; then … elif … else … fi | if (…) { … } elseif (…) { … } else { … } |
| case | case "$x" in a) … ;; *) … ;; esac | switch ($x) { 'a' { … } default { … } } |
| counting loop | for (( i=0; i<3; i++ )); do … done | for ($i = 0; $i -lt 3; $i++) { … } |
| loop over a list | for x in a b c; do … done | foreach ($x in 'a', 'b', 'c') { … } |
| arguments | $1, $#, "$@", $0 | param(), $PSBoundParameters.Count, $args, $MyInvocation.MyCommand.Name |
| function result | printed with echo, captured with $( ) | written to the output or returned, assigned directly |
| local variable | local x=… | automatic inside a function |
| error message | echo "…" >&2 | Write-Error "…" |
| read from the keyboard | read -r -p "Name: " name | $name = Read-Host "Name" |
| array | a=(x y z), ${#a[@]}, ${a[i]} | $a = 'x', 'y', 'z', $a.Count, $a[$i] |
| key–value store | declare -A c, ${c[$k]}, "${!c[@]}" | $c = @{}, $c[$k], $c.Keys |
| discard output | > /dev/null | > $null or | Out-Null |
Commands and their aliases
| bash | Cmdlet | Alias everywhere | Alias on Windows only |
|---|---|---|---|
ls | Get-ChildItem | dir, gci | ls |
cat | Get-Content | gc, type | cat |
cd | Set-Location | cd, chdir | |
pwd | Get-Location | pwd | |
cp | Copy-Item | copy, cpi | cp |
mv | Move-Item | move, mi | mv |
rm | Remove-Item | del, ri, rd | rm, rmdir |
mkdir | New-Item -ItemType Directory | md | |
touch | New-Item | ni | |
echo | Write-Output | echo | |
man | Get-Help | help | man |
which | Get-Command | gcm | |
ps | Get-Process | gps | ps |
kill | Stop-Process | spps | kill |
clear | Clear-Host | cls | clear |
curl | Invoke-WebRequest | iwr | curl, wget (5.1 only) |
Filters
| bash | PowerShell |
|---|---|
grep 'pattern' file | Get-Content file | Where-Object { $_ -match 'pattern' } |
grep -v 'pattern' | Where-Object { $_ -notmatch 'pattern' } |
grep -c 'pattern' file | @(Get-Content file | Where-Object { $_ -match 'pattern' }).Count |
wc -l < file | @(Get-Content file).Count |
head -n 3, tail -n 3, tail -n +2 | Select-Object -First 3, -Last 3, -Skip 1 |
cut -d ',' -f 1 | ForEach-Object { ($_ -split ',')[0] } |
sort, sort -n, sort -r, sort -u | Sort-Object, Sort-Object { [int]$_ }, -Descending, -Unique |
sort | uniq -c | Group-Object |
tr '[:lower:]' '[:upper:]' | ForEach-Object { $_.ToUpper() } |
sed 's/a/b/g' | ForEach-Object { $_ -replace 'a', 'b' } |
a loop or awk for a sum | Measure-Object -Sum |
find . -name '*.txt' | Get-ChildItem -Recurse -Filter *.txt |
command | tee out.txt | command | Tee-Object -FilePath out.txt |
And the loop that every file-processing script is built on, in both languages – the two lines bash needs for the line endings are simply gone:
bash
while IFS= read -r line || [ -n "$line" ]; do
line=${line%$'\r'} # drop a Windows \r
if [ -z "$line" ]; then # skip empty lines
continue
fi
# --- process the line ---
echo "$line"
done < "$file"PowerShell
foreach ($line in Get-Content -Path $File -Encoding UTF8) {
$line = $line.Trim() # cut spaces and tabs from both ends
if ($line -eq '') { # skip empty lines
continue
}
# --- process the line ---
Write-Output $line
}Common mistakes
| Wrong | Right | Why |
|---|---|---|
name = "Anna" | $name = "Anna" | The $ is part of the name in the assignment too. |
if ($a > 5) | if ($a -gt 5) | > is a redirection: a file called 5 appears and the condition is false. |
if ($a = 5), if ($a == 5) | if ($a -eq 5) | = assigns; there is no == operator. |
Get-Sum(3, 4) | Get-Sum 3 4 | With parentheses you pass one array parameter. |
"Size: $file.Length" | "Size: $($file.Length)" | A property inside a string needs $( ). |
"$key: $value" | "${key}: $value" | The colon makes PowerShell read the variable name differently. |
'Hello $name' | "Hello $name" | Nothing is replaced inside single quotes. |
$line -match '[0-9]+' for "only a number" | $line -match '^[0-9]+$' | Without anchors asd2 matches too. |
$word -match 'apple', expecting case to matter | -cmatch | -match, -like and -eq ignore case. |
"10" -gt "9" | [int]"10" -gt 9 | Two strings are compared as text; the left side decides the type. |
$n = Read-Host "Number", then $n + 1 | [int]$n = Read-Host "Number" | Read-Host returns text: "5" + 1 is 51. |
7 / 2, expecting a whole number | [math]::Truncate(7 / 2) | The result is 3.5; [int](7 / 2) rounds, to 4. |
$lines = Get-Content $f, then $lines[0] | $lines = @(Get-Content $f) | For a one-line file you get a string, and [0] is its first character. |
Get-Content $f | … | Set-Content $f | (Get-Content $f) | … | Set-Content $f | Without the parentheses the writing starts while the file is still being read: an error on Windows, a damaged file elsewhere. |
try { Get-Content $f } catch { … } | Get-Content $f -ErrorAction Stop | catch only sees a cmdlet's error with -ErrorAction Stop. |
Write-Output "…" for a progress note in a function | Write-Host "…" | Everything a function writes to the output becomes part of its result. |
ls | Where-Object Length -gt 1000 on Linux | Get-ChildItem | Where-Object Length -gt 1000 | ls is an alias only on Windows; elsewhere the real ls runs and returns text. |
param() in the middle of the script | param() as the first statement | Only comments may stand before it. |
./script.ps1 on Windows: running scripts is disabled | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser | The execution policy of Windows PowerShell 5.1 forbids scripts by default. |
Practice
Seven small scripts, one or two per topic. Work inside pwsh, in the folder with the files of this page. For every task:
- parameters come through a
param()block; a missing one gets aUsage: …message - error messages go through
Write-Error, and the script stops withexit 1 - a missing file or directory is an error too – check with
Test-Path - full cmdlet names in the script, no aliases
Task 1
Greeting
The script takes a name as a parameter and greets that person: Hello <name>!. Without a parameter it uses the name of the logged-in user.
PS> ./greeting.ps1 Anna
Hello Anna!
PS> ./greeting.ps1
Hello student!Approach
One [string] parameter; -not $Name is true when it wasn't given. [Environment]::UserName works on every system – $env:USERNAME exists only on Windows, $env:USER only on Linux and macOS.
if (-not $Name) {
$Name = [Environment]::UserName
}Task 2
Calculator
The script takes two numbers with an operator between them – ./calc.ps1 6 '*' 7 – and prints the result. Choose the operation with switch. An unknown operator and a division by zero end with an error message.
PS> ./calc.ps1 6 '*' 7
6 * 7 = 42
PS> ./calc.ps1 7 / 2
7 / 2 = 3.5
PS> ./calc.ps1 7 / 0
Write-Error: Division by zero
PS> ./calc.ps1 7 x 2
Write-Error: Unknown operator 'x' (use +, -, * or /)Approach
Take all three as [string], check $PSBoundParameters.Count -ne 3, validate the two numbers with the pattern '^[+-]?[0-9]*\.?[0-9]+$', then convert them to [double]. Test the divisor yourself: dividing a decimal number by zero is not an error in PowerShell – the result is infinity.
[double]$a = $First
[double]$b = $Second
switch ($Operator) {
'+' { $result = $a + $b }
'/' {
if ($b -eq 0) {
Write-Error "Division by zero"
exit 1
}
$result = $a / $b
}
default {
Write-Error "Unknown operator '$Operator' (use +, -, * or /)"
exit 1
}
}Task 3
File, directory, or nothing?
The script decides whether the path it gets is a file, a directory, or doesn't exist. For a file it prints the size, for a directory the number of entries in it.
PS> ./path_type.ps1 numbers_mixed.txt
'numbers_mixed.txt' is a file (34 bytes)
PS> ./path_type.ps1 .
'.' is a directory (11 entries)
PS> ./path_type.ps1 nope
'nope' does not existApproach
Two Test-Path tests – -PathType Leaf, then -PathType Container – and an else. The size is a property of the file object, not a separate command. Put the expressions into the output string with $( ).
$file = Get-Item -Path $Path # a file object
$file.Length # its size in bytes
$entries = @(Get-ChildItem -Path $Path) # @( ) keeps it an array, even with 0 or 1 entry
$entries.CountTask 4
Even numbers up to a limit
The script prints the even numbers from 1 to the number it gets as a parameter, one per line – for 10: 2, 4, 6, 8, 10. Check with a regular expression that the parameter is a positive whole number.
Approach
Take the parameter as [string] so the error message is yours. ^[1-9][0-9]*$ rules out 0, -3, abc and 1.5 in one go.
if ($Limit -notmatch '^[1-9][0-9]*$') {
Write-Error "'$Limit' is not a positive integer"
exit 1
}
for ([int]$i = 1; $i -le $Limit; $i++) { … } # or: 1..$Limit | Where-Object { $_ % 2 -eq 0 }Task 5
Sum until zero
The script keeps asking the user for numbers until a 0 arrives, then prints the sum of the numbers and how many there were. For input that isn't a number it warns and asks again.
Enter a number (0 to stop): 5
Enter a number (0 to stop): abc
WARNING: 'abc' is not an integer, try again
Enter a number (0 to stop): -2
Enter a number (0 to stop): 0
Sum of the 2 number(s): 3Approach
while ($true) with a break on zero and a continue on bad input. Read-Host returns text – validate it as text, then convert. Don't call your variable $input: that name is an automatic variable of PowerShell.
while ($true) {
$text = Read-Host "Enter a number (0 to stop)"
if ($text -notmatch '^-?[0-9]+$') {
Write-Warning "'$text' is not an integer, try again"
continue
}
[int]$number = $text
if ($number -eq 0) {
break
}
…
}Task 6
Patients of a month
The script takes the patient file and a month as two digits (01–12), prints the names of that month's patients and then how many there were. Anything that isn't a valid month is refused; a month without patients gets its own message. Input: patients.txt.
PS> ./patients_of_month.ps1 patients.txt 10
Anna Kovacs
Peter Szabo
Eva Toth
Mark Varga
Julia Balogh
5 patient(s) in month 10
PS> ./patients_of_month.ps1 patients.txt 02
No patients in month 02
PS> ./patients_of_month.ps1 patients.txt 13
Write-Error: '13' is not a month (01-12)Approach
Two [string] parameters – as text, 02 keeps its leading zero. Check the month with a pattern of two alternatives, then the two guards and the reading loop from above. Split each line into its three fields and cut the month out of the date.
if ($Month -notmatch '^(0[1-9]|1[0-2])$') {
Write-Error "'$Month' is not a month (01-12)"
exit 1
}
…
$name, $date, $illness = $line -split ','
if ($date.Substring(5, 2) -eq $Month) { # "2023-10-13" -> "10"
Write-Output $name
$count++
}Task 7
Directory statistics
For a directory given as a parameter (default: the current one), print how many files it has, their total size, and which one is the largest. Freshly downloaded, the folder of this page gives:
PS> ./dir_stats.ps1
Directory: .
Files: 11
Total size: 24719 bytes
Largest file: 9_pipeline.ps1 (3583 bytes)Approach
A default value in the param() block: [string]$Directory = ".". Get-ChildItem -File returns only files, as objects; Measure-Object sums a property, and sorting by Length puts the largest first. Handle the empty directory before measuring.
$files = @(Get-ChildItem -Path $Directory -File)
$stats = $files | Measure-Object -Property Length -Sum # $stats.Count, $stats.Sum
$largest = $files | Sort-Object -Property Length -Descending | Select-Object -First 1Summary
- pwsh
- PowerShell 7: cross-platform, UTF-8 by default – the one to use
- cmdlet
- a built-in PowerShell command, named Verb-Noun:
Get-Content,Test-Path - object
- a value with named properties and methods; what commands return and pipes carry
- property
- a named piece of data on an object:
$file.Length,$name.Length - alias
- a short second name of a cmdlet; the ones named after Unix commands exist only on Windows
$_- the object currently passing through a pipeline
- hashtable
@{}– values stored under keys- execution policy
- the Windows setting that decides whether scripts may run
- Use PowerShell 7 (
pwsh) everywhere, and start scripts with parameters from inside it. - Commands return objects: ask for a property by name instead of cutting a column out of text.
- Comparisons are
-eq,-gt,-like,-match… and work on every type;>is a redirection,=an assignment. - Text operators ignore case, and a regular expression is written in quotes.
- Parameters get names and types in
param(), which must be the first statement. - Call functions without parentheses and commas; everything they write to the output is their result.
Get-Contenthandles either line ending and a missing final newline; trimming and skipping empty lines are yours.Where-Objectfilters,ForEach-Objecttransforms,Group-Objectcounts – and what comes out has named properties.
Further reading
- The official PowerShell documentation
- PowerShell 101 – a free introductory book
- What differs between PowerShell 7 and Windows PowerShell 5.1
- Built into the terminal:
Get-Help <command> -Examples,Get-Command,Get-Member - regex101 – try regular expressions; choose the ".NET (C#)" flavour for PowerShell