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.

27 min readWindows PowerShell 5.1 vs PowerShell 7Objects, properties, Get-MemberCmdlets and aliasesparam(), functions and their outputGet-Content and Set-ContentWhere-Object, ForEach-Object, Group-Objectbash → PowerShell cheat sheet

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 5.1 – powershellWindows onlypart of Windows – always therefrozen: no new featuresPowerShell 7 – pwshWindows, Linux and macOSinstalled separately – on Windows toodeveloped further, UTF-8 by defaultOn Windows the Start menu finds the left one first. Everything in this course is written for the right one.
Fig. 1 · Two programs, one name. They live side by side on Windows – installing version 7 doesn't replace 5.1.
Windows PowerShellPowerShell
Version5.1 – the last one7.x
Started withpowershellpwsh
Runs onWindows onlyWindows, Linux, macOS
Installationpart of Windowsseparate – on Windows too
Icon and window titleblue, "Windows PowerShell"black, "PowerShell 7"
$PSEditionDesktopCore

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 from

Your 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. echo is 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 .ps1 is required. Execute permission is not – inside PowerShell every .ps1 can 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 after chmod +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, $NAME and -EQ all 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
ValueMeaning
Restrictedno script may run, only typed commands
RemoteSignedscripts written on this machine run; downloaded ones only if signed (recommended)
Unrestrictedeverything 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).

bash – text flows through the pipels -l-rw-r--r-- 1 anna staff 34 Nov 11 numbers_mixed.txtawk '{ print $5 }'one line of characters – the size is “the fifth column”PowerShell – objects flow through the pipeGet-ChildItema file objectNamenumbers_mixed.txtLength34LastWriteTime2026-11-11 15:40Extension.txtWhere-Object Length -gt 30asks for a property by name –no columns to count
Fig. 2 · Same question, two kinds of pipe. In bash you describe where the value sits in the text; in PowerShell you name it.

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

Every 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 text

You 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 object

Cmdlets: 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 alias

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

ls | Where-Object Length -gt 10Windowslsalias of Get-ChildItemfile objectsLength = size in byteslists the files larger than 10 bytesLinux, macOSlsthe real /bin/lslines of textLength = length of the namelists the files with long namesGet-ChildItem – and the aliases dir and gci – return file objects on every system.
Fig. 3 · The same line gives two different answers, because ls is two different commands.

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      # Int32

Because 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. $HOME exists 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).

"Size: $file.Length"Size: /home/anna/numbers_mixed.txt.Length$file is replaced by the whole object – .Length is left over as plain text"Size: $($file.Length)"Size: 34$( ) evaluates the expression first, then inserts its result
Fig. 4 · A property inside a string needs $( ). 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-three

Two operators test what a string looks like, and they are easy to mix up:

OperatorPattern languageExample
-likewildcards: * any text, ? one character. The whole string must fit.$name -like '*.txt'
-matcha regular expression. Matches anywhere unless anchored with ^ and $.$line -match '^[0-9]+$'
-notlike, -notmatchthe negated forms$line -notmatch '^#'
-replacea regular expression and a replacement; every match is replaced$s -replace '[0-9]+', ''
-splita 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 c in 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
OperatorsMeaning
-eq, -neequal, not equal
-lt, -le, -gt, -geless, less or equal, greater, greater or equal
-and, -or, -not (or !)combine and negate conditions
-like, -matchwildcard pattern, regular expression
-contains, -inis 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):

bash: case – the first match only*.txt)runsapple*)never looked atPowerShell: switch – every match'*.txt'runs'apple*'runs tooValue: apple.txt. With exact values such as '-a' and '-d' only one branch can match, so the difference never shows.
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 pipeline

Script 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. .Count says 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 4

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

function Get-SquareWrite-Host "calculating…"Write-Output "calculating…"return $N * $Nthe screennot part of the result$square = Get-Square 6“calculating…” and 36 – an array of twoInside a function, progress messages go through Write-Host; only the result goes to the output.
Fig. 6 · Whatever a function writes to the output is part of its result. 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 36

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

CmdletGoes toUse it for
Write-Outputthe output streamresults. A bare value does the same: "text" on its own line is printed.
Write-Hostthe screen onlyprogress notes that must not end up in a pipeline or a variable
Write-Warningthe warning streamsomething was skipped or looks wrong, but the script goes on
Write-Errorthe error streamthe 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 calculate

A 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 array

A 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 newline

Real 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$
$
100

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

the trapbashPowerShell, with Get-Contentno newline after the last line|| [ -n "$line" ]handled for youWindows line endings (CRLF)line=${line%$'\r'}handled for youempty lines[ -z "$line" ] && continueif ($line -eq '') { continue }spaces and tabs around the valuetr -d '[:space:]'$line = $line.Trim()Two traps are gone. Skipping empty lines and trimming stay your job – in every script that reads a file.
Fig. 7 · The four file traps: 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 number

Writing

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 pipeline

When 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 last

Writing 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 $File

Encoding and line endings

PowerShell 7Windows PowerShell 5.1
reading, by defaultUTF-8the system's legacy code page – accented letters break
Set-Content, Add-Content by defaultUTF-8the system's legacy code page
>, >>, Out-File by defaultUTF-8UTF-16 – Unix tools show it as garbage
with -Encoding UTF8UTF-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:

CmdletWhat 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-Objectsorts – by a property (-Property Length) or a computed value, -Descending for largest first, -Unique to drop repeats
Select-Objectkeeps the first or last few (-First 3, -Last 3), skips some (-Skip 1), or keeps only certain properties
Group-Objectputs equal values into groups; each group has a Name and a Count
Measure-Objectcounts, 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 first

A 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 1
Count Name                      Group
----- ----                      -----
    3 headache                  {headache, headache, headache}
Get-Content14 lines of textWhere-Objectmatch ,2023-10-ForEach-Object($_ -split ',')[2]Group-Objectgroups and countsSort | Selectby Count, -First 1Anna…,headacheMark…,coldDaniel…,stomach…Lili…,leg pain… 14 in allAnna…,headachePeter…,headacheEva…,headacheMark…,coldJulia…,coldheadacheheadacheheadachecoldcoldheadacheCount = 3coldCount = 2headacheCount = 3Text up to the third stage, objects from Group-Object on: the result has a Name and a Count property,so there are no leading spaces to strip and nothing to split – $top.Name, $top.Count.
Fig. 8 · Lines of text go in; from 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 1

And 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      # 42

Coming 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

bashPowerShell
assignmentname="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 fieldsIFS=',' 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
ifif [ … ]; then … elif … else … fiif (…) { … } elseif (…) { … } else { … }
casecase "$x" in a) … ;; *) … ;; esacswitch ($x) { 'a' { … } default { … } }
counting loopfor (( i=0; i<3; i++ )); do … donefor ($i = 0; $i -lt 3; $i++) { … }
loop over a listfor x in a b c; do … doneforeach ($x in 'a', 'b', 'c') { … }
arguments$1, $#, "$@", $0param(), $PSBoundParameters.Count, $args, $MyInvocation.MyCommand.Name
function resultprinted with echo, captured with $( )written to the output or returned, assigned directly
local variablelocal x=…automatic inside a function
error messageecho "…" >&2Write-Error "…"
read from the keyboardread -r -p "Name: " name$name = Read-Host "Name"
arraya=(x y z), ${#a[@]}, ${a[i]}$a = 'x', 'y', 'z', $a.Count, $a[$i]
key–value storedeclare -A c, ${c[$k]}, "${!c[@]}"$c = @{}, $c[$k], $c.Keys
discard output> /dev/null> $null or | Out-Null

Commands and their aliases

bashCmdletAlias everywhereAlias on Windows only
lsGet-ChildItemdir, gcils
catGet-Contentgc, typecat
cdSet-Locationcd, chdir
pwdGet-Locationpwd
cpCopy-Itemcopy, cpicp
mvMove-Itemmove, mimv
rmRemove-Itemdel, ri, rdrm, rmdir
mkdirNew-Item -ItemType Directorymd
touchNew-Itemni
echoWrite-Outputecho
manGet-Helphelpman
whichGet-Commandgcm
psGet-Processgpsps
killStop-Processsppskill
clearClear-Hostclsclear
curlInvoke-WebRequestiwrcurl, wget (5.1 only)

Filters

bashPowerShell
grep 'pattern' fileGet-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 +2Select-Object -First 3, -Last 3, -Skip 1
cut -d ',' -f 1ForEach-Object { ($_ -split ',')[0] }
sort, sort -n, sort -r, sort -uSort-Object, Sort-Object { [int]$_ }, -Descending, -Unique
sort | uniq -cGroup-Object
tr '[:lower:]' '[:upper:]'ForEach-Object { $_.ToUpper() }
sed 's/a/b/g'ForEach-Object { $_ -replace 'a', 'b' }
a loop or awk for a sumMeasure-Object -Sum
find . -name '*.txt'Get-ChildItem -Recurse -Filter *.txt
command | tee out.txtcommand | 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

WrongRightWhy
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 4With 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 9Two 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 $fWithout 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 Stopcatch only sees a cmdlet's error with -ErrorAction Stop.
Write-Output "…" for a progress note in a functionWrite-Host "…"Everything a function writes to the output becomes part of its result.
ls | Where-Object Length -gt 1000 on LinuxGet-ChildItem | Where-Object Length -gt 1000ls is an alias only on Windows; elsewhere the real ls runs and returns text.
param() in the middle of the scriptparam() as the first statementOnly comments may stand before it.
./script.ps1 on Windows: running scripts is disabledSet-ExecutionPolicy RemoteSigned -Scope CurrentUserThe 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 a Usage: … message
  • error messages go through Write-Error, and the script stops with exit 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 exist
Approach

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

Task 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): 3
Approach

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 1

Summary

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-Content handles either line ending and a missing final newline; trimming and skipping empty lines are yours.
  • Where-Object filters, ForEach-Object transforms, Group-Object counts – and what comes out has named properties.

Further reading