Mert Özen Further With Every Line
DevOps PowerShell Scripting — #02

Variables, Types and Collections: [type] Casts, Arrays, Hashtables, PSCustomObject, Splatting and Strings

Mert Özen Nov 15, 2026 18 min 5 views
Variables, Types and Collections: [type] Casts, Arrays, Hashtables, PSCustomObject, Splatting and Strings

In PowerShell the result of a comparison depends on the type of the value on the left, and Import-Csv reads every column as a string, so data has to be converted to real types up front. Building a publisher report from Bookstore's book CSV, we cover putting $null on the left, arrays and +=, List, hashtables, PSCustomObject, splatting, strings, number and date traps under a Turkish culture, and how a [decimal] cast gives a different result on PowerShell 7.7 because of .NET 11.

Version note: The examples in this post were run with PowerShell 7.6.10 (LTS) and PowerShell 7.7.0 on Ubuntu 24.04; the places where the two versions give different results are shown separately. Cmdlets and their output are the same on Windows; only paths start with C:\ and use \ as the separator. Information about Windows PowerShell 5.1 comes from the Microsoft Learn documentation and was not run.

Guess First

The catalog team of Bookstore, the online bookstore used as the shared example across this site's series, hands over the book list as data/books.csv: ISBN, title, author, publisher, price, stock and edition date. The first job is to find the three cheapest books and those priced under 8:

$books = Import-Csv ./data/books.csv
$books | Sort-Object Price | Select-Object -First 3 Title, Price
$books | Where-Object Price -lt 8 | Select-Object Title, Price

The cheapest books in the list are 6.99 (Pride and Prejudice) and 7.99 (Emma). Which three books does the first command return? How many rows does the second return? A hint: there is no such thing as a type in a CSV file.

The previous post built the skeleton of a script. This post looks at the data that goes into it: variables, types, arrays, hashtables, PSCustomObject, splatting and strings. At the end we write a script that builds a per-publisher stock report from Bookstore's book CSV. Along the way we also run into a behaviour change that PowerShell 7.7 brings with .NET 11 and that directly affects price calculations.

Variables, Types and the Left Side of a Comparison

The answer:

PS> $books = Import-Csv ./data/books.csv
PS> $books.Count
12
PS> $books | Sort-Object Price | Select-Object -First 3 Title, Price

Title                      Price
-----                      -----
The Hobbit                 10.99
Middlemarch                11.50
The Fellowship of the Ring 11.99
PS> $books | Where-Object Price -lt 8 | Select-Object Title, Price

Title                      Price
-----                      -----
Dune                       12.99
Emma                       7.99
The Hobbit                 10.99
Middlemarch                11.50
Pride and Prejudice        6.99
Crime and Punishment       17.00
Norwegian Wood             16.95
The Fellowship of the Ring 11.99
PS> $books[0].Price.GetType().FullName
System.String

Import-Csv reads every column as a string. Sort-Object Price sorted the strings alphabetically: "10.99" comes before "6.99". Where-Object Price -lt 8 returned eight rows, including 17.00. The reason is PowerShell's comparison rule: the value on the right is converted to the type of the value on the left. With a string on the left, 8 also became the string "8", and "17.00" -lt "8" is alphabetically true. This rule is the basis of everything:

PS> $stock = 14
PS> $stock.GetType().Name
Int32
PS> $stock = 'fourteen'
PS> $stock.GetType().Name
String
PS> [int]$count = 14
PS> $count = 'fourteen'
MetadataError: Cannot convert value "fourteen" to type "System.Int32". Error: "The input string 'fourteen' was not in a correct format."
PS> $count = '15'
PS> $count.GetType().Name
Int32
PS> [decimal]'12.99' * 3
38.97
PS> [datetime]'2026-11-15' | Get-Date -Format 'yyyy-MM-dd dddd'
2026-11-15 Sunday
PS> [System.IO.Path]::Combine('releases', '1.4.2', 'Bookstore.Api.dll')
releases/1.4.2/Bookstore.Api.dll
PS> '12.99' -lt 8
True
PS> 8 -gt '12.99'
False
PS> [decimal]'12.99' -lt 8
False

In PowerShell a variable's type is the type of the value it holds, and it can change with every assignment. Declare a variable with a type, like [int]$count, and the variable gets a type constraint: every assignment is first converted to int and fails if it cannot be. That is why the string '15' was stored as the number 15. Square brackets also give direct access to .NET types: [datetime], [decimal], static methods like [System.IO.Path]::Combine, everything you know from C# is available. The last three lines sum up the comparison rule: the same two values give different results depending on which one is on the left. Convert data to its type before comparing it.

The same rule affects $null comparisons. With an array on the left, -eq does not compare, it acts like a filter and returns the matching elements:

PS> $publishers = @('Ace', $null, 'Vintage', $null)
PS> ($publishers -eq $null).Count
2
PS> if ($publishers -eq $null) { 'publishers is null' } else { 'publishers has values' }
publishers is null
PS> if ($null -eq $publishers) { 'publishers is null' } else { 'publishers has values' }
publishers has values
PS> $publishers = @('Ace', $null, 'Vintage')
PS> if ($publishers -eq $null) { 'publishers is null' } else { 'publishers has values' }
publishers has values
PS> 'Dune' -eq 'DUNE'
True
PS> 'Dune' -ceq 'DUNE'
False
PS> $books = Import-Csv ./data/books.csv
PS> $books.Publisher -contains 'penguin classics'
True
PS> 'Vintage' -in $books.Publisher
True
PS> $books.Title -like '*dune*'
Dune
Children of Dune

With an array holding two $nulls, $publishers -eq $null returned a two-element array and if treated it as true; with a single $null left, the returned one-element array counted as false. The same line makes a different decision depending on the data. Always put $null on the left: $null -eq $publishers asks whether the array itself is null. PSScriptAnalyzer catches this with its PSPossibleIncorrectComparisonWithNull rule; we will turn it on in the last post of the series. The last lines show another default: -eq, -like, -contains and -in are case-insensitive. For a case-sensitive comparison, prefix them with c: -ceq, -clike, -ccontains.

Arrays: @(), += and List

A PowerShell array is .NET's object[], and its size is fixed. += does not grow the existing array; it creates a new array and points the variable at it:

PS> $isbns = @('9780441172719', '9780141439587')
PS> $copy = $isbns
PS> $isbns += '9780547928227'
PS> "isbns: $($isbns.Count), copy: $($copy.Count)"
isbns: 3, copy: 2
PS> $isbns.GetType().Name
Object[]

$copy still points at the old two-element array. The cost of this behaviour is the source of an old piece of PowerShell advice: do not use += in a loop. Microsoft's performance documentation says += was improved in PowerShell 7.5; to see how much, here is a small measurement that does the same job three ways:

foreach ($n in 10000, 20000, 40000) {
    $plus = (Measure-Command { $a = @(); foreach ($i in 1..$n) { $a += "book-$i" } }).TotalMilliseconds
    $list = (Measure-Command {
        $l = [System.Collections.Generic.List[string]]::new()
        foreach ($i in 1..$n) { $l.Add("book-$i") }
    }).TotalMilliseconds
    $direct = (Measure-Command { $r = foreach ($i in 1..$n) { "book-$i" } }).TotalMilliseconds
    '{0,6}  +=: {1,8:N0} ms  List: {2,4:N0} ms  foreach: {3,4:N0} ms' -f $n, $plus, $list, $direct
}
PS> ./lab/measure-add.ps1
 10000  +=:       49 ms  List:   18 ms  foreach:    7 ms
 20000  +=:      481 ms  List:    8 ms  foreach:    3 ms
 40000  +=:    2,052 ms  List:   18 ms  foreach:    8 ms

On PowerShell 7.7.0 the numbers are in the same range: 37, 523 and 2,146 ms. When the element count doubles, the time for += roughly quadruples, while List and foreach stay almost flat. So the 7.5 improvement closed the gap for small arrays, but above a few thousand elements the old advice still holds. According to the documentation the improvement does not exist before 7.5, so not in Windows PowerShell 5.1 either. The cleanest way is the third: assign the loop's output directly to a variable. If you also need to remove items while collecting, use a generic List:

PS> $l = [System.Collections.Generic.List[string]]::new()
PS> $l.Add('Dune')
PS> $l.Add(42)
PS> $l
Dune
42
PS> $l.Remove('Dune')
True
PS> $l.Count
1

When methods like Add and Remove return a value (here Remove's True), that value ends up in the output. Inside a script you have to swallow it with $null = $list.Remove('Dune'); we come back to this in the fifth post, where we discuss function output. The 42 given to List[string] was converted to a string as well; the type constraint works here too.

Hashtables, [ordered] and PSCustomObject

A hashtable holds key-value pairs, similar to a Dictionary in C#. The default hashtable has two properties: keys are case-insensitive and order is not preserved. If order matters, use [ordered]:

PS> $stock = @{ Dune = 14; Emma = 3; 'The Hobbit' = 0 }
PS> $stock['emma']
3
PS> $stock.Dune
14
PS> $stock.Keys
The Hobbit
Emma
Dune
PS> $ordered = [ordered]@{ Dune = 14; Emma = 3; 'The Hobbit' = 0 }
PS> $ordered.Keys
Dune
Emma
The Hobbit
PS> $ordered[0]
14
PS> $book = [pscustomobject]@{ Isbn = '9780441172719'; Title = 'Dune'; Price = 12.99d; Stock = 14 }
PS> $book

Isbn          Title Price Stock
----          ----- ----- -----
9780441172719 Dune  12.99    14
PS> $book.Price.GetType().Name
Decimal
PS> $book | Add-Member -NotePropertyName StockValue -NotePropertyValue ($book.Price * $book.Stock)
PS> $book.StockValue
181.86
PS> $book | Get-Member -MemberType NoteProperty | Select-Object Name, Definition

Name       Definition
----       ----------
Isbn       string Isbn=9780441172719
Price      decimal Price=12.99
Stock      int Stock=14
StockValue decimal StockValue=181.86
Title      string Title=Dune

$stock['emma'] found the Emma key. Keys did not come back in the order we added them, while [ordered] kept the order and can also be accessed by index. For a record, something with fixed fields like a "book", use [pscustomobject] instead of a hashtable. Casting a literal hashtable to [pscustomobject] keeps the field order, the result looks right as a table, and it works as you expect with Export-Csv and ConvertTo-Json. The d suffix in 12.99d makes the number a decimal directly; that detail will matter shortly.

Splatting

When you pass many parameters to a cmdlet, you can collect them in a hashtable and pass them with @. This is called splatting:

PS> $request = @{ Uri = 'http://127.0.0.1:5080/api/books'; TimeoutSec = 5; Headers = @{ Accept = 'application/json' } }
PS> $catalog = Invoke-RestMethod @request
PS> $catalog.Count
5
PS> $catalog[0].price.GetType().Name
Double
PS> $request.TimeoutSec = 2
PS> Invoke-RestMethod @request -Uri http://127.0.0.1:5098/api/books
Invoke-RestMethod: Connection refused
PS> Invoke-RestMethod $request
Invoke-RestMethod: Cannot bind parameter 'Uri'. Cannot convert the "System.Collections.Hashtable" value of type "System.Collections.Hashtable" to type "System.Uri".

The hashtable's keys are parameter names. You can change values based on a condition and reuse the same call. Since PowerShell 7.1 you can override a splatted parameter by writing it explicitly in the command; the -Uri line shows that. The last line shows a common mistake: written as $request, the hashtable went as a single value to the first positional parameter, -Uri. Splatting requires @. There is array splatting too: $dotnetArgs = @('test', '--nologo') and dotnet @dotnetArgs; we will use it in the build script in the fourth post. Also note that price comes back as Double; we will return to that shortly.

Strings: Quotes, $(), Here-Strings and -f

PS> $book = [pscustomobject]@{ Title = 'Dune'; Price = 12.99d; Stock = 14 }
PS> 'Title: $book.Title'
Title: $book.Title
PS> "Title: $book.Title"
Title: @{Title=Dune; Price=12.99; Stock=14}.Title
PS> "Title: $($book.Title), stock value: $($book.Price * $book.Stock)"
Title: Dune, stock value: 181.86
PS> '{0,-12}{1,8:N2}{2,6}' -f $book.Title, $book.Price, $book.Stock
Dune           12.99    14

Everything inside single quotes stays as is. In double quotes variables are expanded, but only the variable name: "$book.Title" converted $book to a string and appended .Title. For a property, a method call or a calculation, use a $() subexpression. The -f operator is .NET's String.Format: {0,-12} is 12 characters left-aligned, {1,8:N2} is 8 characters right-aligned with two decimals. For multi-line text there is the here-string; @" and "@ must be on their own lines:

PS> Get-Content ./lab/here.ps1
$release = '1.4.2'
$notes = @"
Bookstore.Api $release
Built: $(Get-Date -Format 'yyyy-MM-dd')
Path:  C:\deploy\$release
"@
$notes
PS> ./lab/here.ps1
Bookstore.Api 1.4.2
Built: 2026-11-15
Path:  C:\deploy\1.4.2

@" expands, @' does not. The \ in a Windows path is not an escape character in PowerShell (the escape character is the backtick), so C:\deploy\$release works fine.

Culture: Numbers and Dates under Turkish Regional Settings

Readers work on Windows, and on most Turkish developers' machines the regional setting is Turkish. To see what that does to a script, let's set the session's culture to tr-TR:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> (Get-Culture).Name
tr-TR
PS> $price = 12.99d
PS> "interpolation: $price"
interpolation: 12.99
PS> 'format: {0:N2}' -f $price
format: 12,99
PS> "ToString: $($price.ToString())"
ToString: 12,99
PS> [decimal]'12.99'
12,99
PS> [decimal]::Parse('12.99')
1299
PS> [decimal]::Parse('12.99', [cultureinfo]::InvariantCulture)
12,99
PS> 'title' -eq 'TİTLE'
False
PS> 'title'.ToUpper()
TİTLE
PS> 'title'.ToUpperInvariant()
TITLE

There are three different behaviours here. String interpolation ("$price") and PowerShell casts ([decimal]'12.99') do not depend on the culture; they use what the documentation calls the invariant culture. -f, ToString() and the output written to the screen use the current culture, which is why it became 12,99. The most dangerous are the .NET method overloads that take no culture: under Turkish settings [decimal]::Parse('12.99') treated the dot as a thousands separator and returned 1299. No error, no warning, just a price that is wrong by a factor of a hundred. The last lines show the Turkish İ issue: since -eq compares with the invariant culture, 'title' -eq 'TİTLE' is false, while ToUpper() follows the Turkish rule and produces TİTLE.

Dates are even more surprising:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> ([datetime]'06.05.2003').ToString('yyyy-MM-dd')
2003-06-05
PS> (Get-Date -Date '06.05.2003').ToString('yyyy-MM-dd')
2003-05-06
PS> ([datetime]'2003-05-06').ToString('yyyy-MM-dd')
2003-05-06
PS> 'title' -eq 'TITLE'
True

The same string '06.05.2003' became 5 June with a cast and 6 May through the Get-Date -Date parameter. According to the documentation the invariant culture uses US-style, month-first dates, while cmdlets do culture-sensitive conversion during parameter binding. In scripts and data files always keep dates as yyyy-MM-dd; the third line gives the same result both ways.

There is also a version difference. PowerShell 7.7 runs on .NET 11, and .NET 11's list of breaking changes says that converting double to decimal no longer rounds to 15 significant digits. Since PowerShell's cast from a string to [decimal] goes through that conversion, the same line gives different results on the two versions:

PS> $PSVersionTable.PSVersion.ToString()
7.6.10
PS> [decimal]'12.99'
12.99
PS> [decimal]'6.99' * 21
146.79
PS> [decimal]::Parse('6.99', [cultureinfo]::InvariantCulture) * 21
146.79
PS> 6.99d * 21
146.79
PS> $PSVersionTable.PSVersion.ToString()
7.7.0
PS> [decimal]'12.99'
12.990000000000000213162820728
PS> [decimal]'6.99' * 21
146.79000000000000447641923529
PS> [decimal]::Parse('6.99', [cultureinfo]::InvariantCulture) * 21
146.79
PS> 6.99d * 21
146.79

The value on 7.7 is the exact value of the double 12.99; in other words the cast reads the string as a double first, and .NET 11 removed the rounding in between. [decimal]::Parse converts the string directly to decimal and is correct on both versions. In the fourth post of the Terminal and Command Line series we suggested converting double prices from JSON with [decimal]; on 7.6 that gives the correct result, but on 7.7 you need to round with [math]::Round(..., 2) after the conversion. The rule for money: [decimal]::Parse(..., [cultureinfo]::InvariantCulture) from strings, the d suffix for literals.

Scopes: $script:, $global: and $using:

Inside a function you can read a variable from the script, but when you assign to it, the function creates a new variable with the same name in its own scope:

$outOfStock = 0

function Test-Stock($book) {
    if ($book.Stock -eq 0) { $outOfStock++ }
}

function Test-StockScript($book) {
    if ($book.Stock -eq 0) { $script:outOfStock++ }
}

$books = Import-Csv ./data/books.csv
foreach ($book in $books) { Test-Stock $book }
"after Test-Stock: $outOfStock"
foreach ($book in $books) { Test-StockScript $book }
"after Test-StockScript: $outOfStock"
PS> ./lab/scope.ps1
after Test-Stock: 0
after Test-StockScript: 1
PS> $limit = 8
PS> 1..3 | ForEach-Object -Parallel { "item $_, limit: $limit" }
item 1, limit: 
item 2, limit: 
item 3, limit: 
PS> 1..3 | ForEach-Object -Parallel { "item $_, limit: $using:limit" }
item 1, limit: 8
item 2, limit: 8
item 3, limit: 8

Test-Stock incremented its own copy of $outOfStock, and the counter in the script stayed at 0. $script:outOfStock explicitly changed the variable in the script scope. $global: writes to the whole session; it stays after the script ends and can affect the next run, so do not use it in scripts. The cleaner way is for the function to return its result and for the caller to keep the counter; that is how we will write functions in the fifth post.

$using: carries a local value into code that runs in another runspace. ForEach-Object -Parallel blocks run in separate runspaces, so they could not see $limit; with $using:limit the value was copied. The same applies to Invoke-Command in remoting. We will see the details in parallel work in the next post and remoting in the seventh.

Bookstore: A Publisher Report from the Book CSV

Now let's put it all together. The top of the CSV file:

Isbn,Title,Author,Publisher,Price,Stock,Published
9780441172719,Dune,Frank Herbert,Ace,12.99,14,1990-09-01
9780141439587,Emma,Jane Austen,Penguin Classics,7.99,3,2003-05-06
9780547928227,The Hobbit,J.R.R. Tolkien,Mariner,10.99,0,2012-09-18

The script converts the rows to real types once, with an explicit culture, then groups them by publisher and returns one PSCustomObject per publisher:

#!/usr/bin/env pwsh
#Requires -Version 7.6

<#
.SYNOPSIS
    Summarizes the Bookstore book list per publisher.

.DESCRIPTION
    Reads a book CSV (Isbn, Title, Author, Publisher, Price, Stock, Published),
    converts the columns to real types and returns one object per publisher:
    number of titles, total stock, stock value, average price and newest edition.

.PARAMETER Path
    The book CSV. Defaults to data/books.csv in the repo.

.PARAMETER OutFile
    Optional. Writes the report as a semicolon-separated CSV for Excel.

.EXAMPLE
    ./Get-BookstorePublisherReport.ps1 | Format-Table

.EXAMPLE
    ./Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report.csv
#>
param(
    [string] $Path = (Join-Path -Path $PSScriptRoot -ChildPath '..', 'data', 'books.csv'),
    [string] $OutFile
)

$invariant = [cultureinfo]::InvariantCulture

# CSV columns are strings: convert them once, here, with an explicit culture.
# Price is parsed directly as decimal; a [decimal] cast goes through double on 7.7.
$books = foreach ($row in Import-Csv -Path $Path) {
    [pscustomobject]@{
        Isbn      = $row.Isbn
        Title     = $row.Title
        Publisher = $row.Publisher
        Price     = [decimal]::Parse($row.Price, $invariant)
        Stock     = [int]$row.Stock
        Published = [datetime]::ParseExact($row.Published, 'yyyy-MM-dd', $invariant)
    }
}

$report = foreach ($group in $books | Group-Object Publisher) {
    $stock = 0
    $stockValue = 0d
    $priceSum = 0d
    foreach ($book in $group.Group) {
        $stock += $book.Stock
        $stockValue += $book.Price * $book.Stock
        $priceSum += $book.Price
    }
    $newest = $group.Group | Sort-Object Published -Descending | Select-Object -First 1

    [pscustomobject]@{
        Publisher  = $group.Name
        Titles     = $group.Count
        Stock      = $stock
        StockValue = $stockValue
        AvgPrice   = [math]::Round($priceSum / $group.Count, 2)
        Newest     = $newest.Published.ToString('yyyy-MM-dd', $invariant)
    }
}
$report = $report | Sort-Object StockValue -Descending

if ($OutFile) {
    $csvOptions = @{
        Path      = $OutFile
        Delimiter = ';'
        Encoding  = 'utf8BOM'
        UseQuotes = 'AsNeeded'
    }
    $report | Export-Csv @csvOptions
    Write-Output "Report written to $OutFile ($(@($report).Count) publishers)"
}
else {
    $report
}

There are a few decisions here. The default file path is built from $PSScriptRoot, so the script finds data/books.csv whichever folder it is called from. Money totals are kept in the loop instead of Measure-Object, because the Sum and Average results of Measure-Object are double. The script does not print a table, it returns objects; formatting is left to the caller:

PS> ./scripts/Get-BookstorePublisherReport.ps1 | Select-Object -First 2

Publisher  : Penguin Classics
Titles     : 4
Stock      : 39
StockValue : 320.67
AvgPrice   : 8.87
Newest     : 2003-05-06

Publisher  : Ace
Titles     : 2
Stock      : 19
StockValue : 231.81
AvgPrice   : 11.49
Newest     : 2008-07-01
PS> ./scripts/Get-BookstorePublisherReport.ps1 | Format-Table

Publisher        Titles Stock StockValue AvgPrice Newest
---------        ------ ----- ---------- -------- ------
Penguin Classics      4    39     320.67     8.87 2003-05-06
Ace                   2    19     231.81    11.49 2008-07-01
Signet Classic        1    18     179.82     9.99 1961-01-01
Harper Perennial      1    11     103.29     9.39 2002-01-01
Vintage               2     6     101.90    16.98 2000-09-12
Mariner               2     7      83.93    11.49 2012-09-18
PS> $report = ./scripts/Get-BookstorePublisherReport.ps1
PS> $report[0].StockValue.GetType().Name
Decimal
PS> ($report | Measure-Object Titles -Sum).Sum
12
PS> ./scripts/Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report.csv
Report written to ./publisher-report.csv (6 publishers)
PS> Get-Content ./publisher-report.csv -TotalCount 3
Publisher;Titles;Stock;StockValue;AvgPrice;Newest
Penguin Classics;4;39;320.67;8.87;2003-05-06
Ace;2;19;231.81;11.49;2008-07-01

An object with six properties is printed as a list by default (for objects without a format definition PowerShell picks a table up to four properties and a list from five on); a caller who wants a table adds Format-Table. The real gain of returning objects is on the third line: the report can be stored in a variable and processed again with Measure-Object, and StockValue is still a Decimal. -OutFile writes a semicolon-separated UTF-8 CSV with a BOM for Excel; the CSV post of the I/O in .NET series explains why Turkish Excel wants both. When the same command runs under the Turkish culture:

PS> [cultureinfo]::CurrentCulture = 'tr-TR'
PS> ./scripts/Get-BookstorePublisherReport.ps1 -OutFile ./publisher-report-tr.csv
Report written to ./publisher-report-tr.csv (6 publishers)
PS> Get-Content ./publisher-report-tr.csv -TotalCount 3
Publisher;Titles;Stock;StockValue;AvgPrice;Newest
Penguin Classics;4;39;320,67;8,87;2003-05-06
Ace;2;19;231,81;11,49;2008-07-01
PS> Format-Hex ./publisher-report-tr.csv -Count 16

   Label: /home/user/bookstore/publisher-report-tr.csv

          Offset Bytes                                           Ascii
                 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
          ------ ----------------------------------------------- -----
0000000000000000 EF BB BF 50 75 62 6C 69 73 68 65 72 3B 54 69 74 Publisher;Tit

Export-Csv wrote the numbers with the current culture: 320,67. Right for Turkish Excel, but a surprise for another script reading the same file. The first three bytes of the file (EF BB BF) are the BOM.

In Production

A file produced by a script should not depend on the culture of the machine the script runs on. A nightly report produces two different files when it runs with an English culture on the server and a Turkish culture on a developer's machine. For data between machines, pick a culture-independent format: JSON, ISO 8601 dates (yyyy-MM-dd), a dot as the decimal mark. For an Excel file meant for people, choose the culture on purpose and pin it at the top of the script with [cultureinfo]::CurrentCulture.

When reading data, decide its type once, at the boundary. As in the report, convert rows coming from a CSV to real types right at the start; the rest of the script should work with decimal, int and datetime, not strings. That way the sorting and filtering errors from Guess First never happen. Use decimal for money and [decimal]::Parse when converting from strings; the 7.7 change can make a script that is correct on 7.6 quietly produce a different total.

Memory matters with large data too. If Import-Csv collects the whole file into an array, an order file with a million rows may not fit in memory. Processing rows one at a time in the pipeline (Import-Csv ... | ForEach-Object { ... }) keeps memory flat; that is the subject of the next post.

Common Pitfalls

A single result is not an array. When a command returns one result, PowerShell does not wrap it in an array:

PS> $books = Import-Csv ./data/books.csv
PS> $titles = $books | Where-Object Publisher -eq 'Signet Classic' | ForEach-Object Title
PS> $titles
1984
PS> $titles.GetType().Name
String
PS> $titles[0]
1
PS> $titles.Count
1
PS> $titles = @($books | Where-Object Publisher -eq 'Signet Classic' | ForEach-Object Title)
PS> $titles[0]
1984
PS> $none = $books | Where-Object Publisher -eq 'Tor'
PS> $null -eq $none
True
PS> @($none).Count
0

Signet Classic has a single book, so $titles was not an array but the string "1984", and $titles[0] returned its first character, 1. Count saying 1 is misleading: PowerShell gives a single value a Count as well. If you are going to use the result as an array, wrap it in @(). With no result at all the variable is $null; wrapped in @() it becomes an empty array.

-eq acting as a filter on arrays. We saw it with $null above; the same happens in lines like if ($books.Publisher -eq 'Ace'). The result is an array, and if evaluates it by whether it is empty. To check for a single value, use -contains or -in; they always return True or False.

Case. -eq and hashtable keys are case-insensitive by default, which is unexpected for someone coming from C#. Convenient where case does not matter, like an ISBN, but a risk when comparing file names or tokens. On Linux the file system is case-sensitive, so two file names that -eq considers equal can be two different files; use -ceq when needed.

Passing a hashtable as a single value. In splatting you write @, not $. Invoke-RestMethod $request failed, but had it landed on a parameter that accepts a hashtable, it could have run wrongly without any error.

A Small Experiment

★☆☆ Convert the books from Import-Csv with [decimal]::Parse and run the two commands from Guess First again. Then write the same filter as Where-Object { [decimal]$_.Price -lt 8 } and explain why the result is still correct on 7.6.

★★☆ Add a -Culture parameter to the report script (default tr-TR); when -OutFile is given, it should write the file with that culture and restore the session's culture when the script ends. What do you see when you open a file produced with -Culture en-US in Turkish Excel?

★★★ Calculate the total stock value of the books from /api/books with Invoke-RestMethod, separately on PowerShell 7.6 and 7.7. Compare the two results, then write a conversion that gives the same result on 7.7 as on 7.6. Hint: the price in the JSON already arrives as a double.

The report works, but we used foreach and Group-Object without explaining them yet. The next post moves on to conditions, loops and using the pipeline in scripts: switch, the difference between foreach and ForEach-Object, begin/process/end blocks, and checking Bookstore's 200 book covers at the same time with ForEach-Object -Parallel.