Collections in scripts

Admin scripts constantly deal with groups of things: the servers to check, the tariff per time of day, the readings of a night's export. The two basic shapes are the same in every language: an array, an ordered list you access by position, and a hash table, which looks values up by key. What differs between the shells is what can go inside:

Need Bash PowerShell
Ordered list Indexed array: servers=(web01 db01) Array: $servers = 'web01', 'db01'
Growing list Indexed array with += [System.Collections.Generic.List[string]]
Lookup by key Associative array: declare -A tariff Hash table: $tariff = @{}
Lookup that keeps key order Not available (sort the keys) [ordered]@{}
Record with named fields Not available (one line of text, or several arrays) [PSCustomObject]@{}
Values can be Strings only Any .NET object, including other collections

Indexed arrays in Bash

An indexed array is a Bash variable that holds a list of strings, each stored under a whole number starting at 0. You create it with parentheses, read one element with ${name[index]}, and all elements with "${name[@]}". The braces are required: without them, $servers[1] means "the first element, followed by the text [1]".

servers=(192.0.2.10 192.0.2.11 "web 01")

echo "count:    ${#servers[@]}"
echo "first:    ${servers[0]}"
echo "last:     ${servers[-1]}"
echo "slice:    ${servers[@]:1:2}"
echo "indices:  ${!servers[@]}"

servers+=(db01)
echo "after +=: ${#servers[@]} servers"
echo "\$servers alone gives only the first: $servers"
count:    3
first:    192.0.2.10
last:     web 01
slice:    192.0.2.11 web 01
indices:  0 1 2
after +=: 4 servers
$servers alone gives only the first: 192.0.2.10
  • ${#servers[@]} is the number of elements, ${servers[-1]} the last one (negative indexes work for reading since Bash 4.2, and for assigning since 4.3), and ${servers[@]:1:2} two elements starting at index 1.
  • servers+=(db01) appends; without the parentheses, servers+=db01 would glue the text onto the first element.
  • $servers without an index is the first element only, a silent bug that ShellCheck reports as SC2128.
  • Quote the expansion when you loop: "${servers[@]}" keeps "web 01" as one element (see Loops › for in Bash).

To fill an array from a file or a command, use mapfile -t (also called readarray), which stores one line per element. The tempting servers=($(cat servers.txt)) splits at every space and expands wildcards (ShellCheck SC2207). Elements can also be removed with unset, which leaves a gap in the indexes:

printf '%s\n' web01 "db 01" web02 > servers.txt

mapfile -t servers < servers.txt
echo "mapfile: ${#servers[@]} elements"

unset 'servers[1]'
echo "after unset: ${#servers[@]} elements, indices ${!servers[*]}"
for i in "${!servers[@]}"; do
  echo "  $i: ${servers[$i]}"
done
mapfile: 3 elements
after unset: 2 elements, indices 0 2
  0: web01
  2: web02

Bash arrays have firm limits: elements are always strings, an array cannot contain another array, and arrays cannot be exported to child processes. A plain POSIX sh has no arrays at all (see Cross-Platform Scripting). To copy an array, write copy=("${servers[@]}"); that makes an independent copy.

Associative arrays in Bash

An associative array in Bash maps string keys to string values, the shell's version of the general associative array. It exists since Bash 4.0 and must be declared with declare -A before use:

declare -A tariff=([day]=0.28 [night]=0.22)
tariff[weekend]=0.19

echo "night rate:   ${tariff[night]}"
echo "holiday rate: ${tariff[holiday]:-unknown}"
if [[ -v tariff[weekend] ]]; then
  echo "a weekend tariff exists"
fi
unset 'tariff[weekend]'

mapfile -t names < <(printf '%s\n' "${!tariff[@]}" | sort)
for name in "${names[@]}"; do
  echo "$name -> ${tariff[$name]} EUR/kWh"
done
night rate:   0.22
holiday rate: unknown
a weekend tariff exists
day -> 0.28 EUR/kWh
night -> 0.22 EUR/kWh
  • ${tariff[holiday]:-unknown} supplies a default for a missing key; [[ -v tariff[weekend] ]] tests whether a key exists, even when its value is empty.
  • "${!tariff[@]}" gives the keys and "${tariff[@]}" the values, in an internal order that has nothing to do with the order you added them. Sort the keys when the output order matters, as the loop above does.
  • Put quotes around unset 'tariff[weekend]', so the shell does not treat the brackets as a wildcard pattern.
  • The values 0.28 and 0.22 are strings. Bash arithmetic cannot multiply them; hand them to awk for calculations.
rate[day]=0.28
rate[night]=0.22
echo "day rate: ${rate[day]}"
declare -p rate
day rate: 0.22
declare -a rate=([0]="0.22")

declare -p shows what a variable really is, a quick check when an array behaves strangely. The classic use of an associative array is counting: how many lines per log level, how many tickets per server.

cat > app.log <<'EOF'
2026-10-06 09:00:01 INFO export started
2026-10-06 09:00:07 ERROR meter M-1042 timeout
2026-10-06 09:00:09 WARN meter M-1043 slow
2026-10-06 09:00:12 ERROR meter M-1044 timeout
EOF

declare -A per_level
while read -r _ _ level _; do
  per_level[$level]=$(( ${per_level[$level]:-0} + 1 ))
done < app.log

for level in ERROR WARN INFO; do
  echo "$level: ${per_level[$level]}"
done
ERROR: 2
WARN: 1
INFO: 1

read -r _ _ level _ splits each line into words: _ is a throwaway name for the date and time, and the last _ takes the rest of the line. The same count with standard tools is a one-liner, cut -d' ' -f3 app.log | sort | uniq -c (see Text Processing in Scripts).

Arrays and lists in PowerShell

A PowerShell array is a .NET array of objects (System.Object[]). Any comma-separated list creates one, and @( … ) does too. Indexes start at 0, negative indexes count from the end, and a range of indexes returns a slice:

$servers = @('192.0.2.10', '192.0.2.11', 'web 01')

"count:   $($servers.Count)"
"first:   $($servers[0])"
"last:    $($servers[-1])"
"slice:   $($servers[1..2] -join ', ')"
"index 5: [$($servers[5])]"

$servers += 'db01'
"after +=: $($servers.Count) servers, type $($servers.GetType().Name)"
count:   3
first:   192.0.2.10
last:    web 01
slice:   192.0.2.11, web 01
index 5: []
after +=: 4 servers, type Object[]

Reading past the end gives $null, silently. A .NET array has a fixed size, so += does not append: it creates a new array with one more element and copies everything over. For a few dozen items that does not matter; in a loop over 50,000 log lines it makes the script crawl. Collect the output of a loop instead (see Collecting results), or use a generic list, which grows in place:

$tickets = [System.Collections.Generic.List[string]]::new()
foreach ($n in 20419..20421) {
    $tickets.Add("INC-$n")
}
"list: $($tickets -join ', ') ($($tickets.Count) items)"
$tickets.Remove('INC-20420') | Out-Null
"after Remove: $($tickets -join ', ')"
list: INC-20419, INC-20420, INC-20421 (3 items)
after Remove: INC-20419, INC-20421

Making sure you have an array

A command that returns one object gives you that object, not an array of one; a command that returns nothing gives $null. Code that expects an array then misbehaves on the day only one file matches. Wrapping the command in @( … ), the array subexpression operator, always produces an array:

New-Item -ItemType Directory -Path logs | Out-Null
Set-Content -Path logs/app.log -Value 'INFO export started'

$one  = Get-ChildItem -Path logs -Filter *.log
$none = Get-ChildItem -Path logs -Filter *.csv
"one match:  type $($one.GetType().Name)"
"no match:   null? $($null -eq $none)"

$sure = @(Get-ChildItem -Path logs -Filter *.log)
"with @( ):  type $($sure.GetType().Name), count $($sure.Count)"
one match:  type FileInfo
no match:   null? True
with @( ):  type Object[], count 1

Since PowerShell 3.0, every object, and even $null, also has a .Count (1 and 0), which hides most of these cases; @( ) makes the intent explicit and also gives you indexing and += that work as expected.

Arrays are references

Assigning an array (or a hash table, or an object) to a second variable does not copy it: both names point to the same reference type in memory. A change through one name shows up in the other. Make a real copy with .Clone() when you need one:

$a = @(4521, 312)
$b = $a
$b[0] = 99
"a[0] after changing b[0]:      $($a[0])"

$c = $a.Clone()
$c[0] = 1
"a[0] after changing the clone: $($a[0]) (unchanged)"
a[0] after changing b[0]:      99
a[0] after changing the clone: 99 (unchanged)

Bash has no references: copy=("${servers[@]}") always copies the strings.

Hash tables in PowerShell

A hash table in PowerShell, written @{ key = value; … }, is a .NET Hashtable: a collection of key-value pairs with fast lookup by key. Keys are usually strings and are compared case-insensitively; values can be anything. You read and write entries with $h['key'] or $h.key:

$tariff = @{ Day = 0.28; Night = 0.22 }
$tariff['Weekend'] = 0.19
$tariff.Holiday = 0.15

"night rate:  $($tariff.Night)"
"NIGHT rate:  $($tariff['NIGHT'])  (keys ignore case)"
"has Weekend: $($tariff.ContainsKey('Weekend'))"
"missing key: [$($tariff['Summer'])]"

$tariff.Remove('Holiday')
$tariff.GetEnumerator() | Sort-Object -Property Name |
    ForEach-Object { '{0,-8} {1} EUR/kWh' -f $_.Key, $_.Value }
night rate:  0.22
NIGHT rate:  0.22  (keys ignore case)
has Weekend: True
missing key: []
Day      0.28 EUR/kWh
Night    0.22 EUR/kWh
Weekend  0.19 EUR/kWh

A missing key returns $null without an error, just like a Bash associative array returns an empty string; use .ContainsKey() when the difference matters. To loop over the entries, use .GetEnumerator(), which gives objects with Key and Value, or loop over .Keys.

Counting per key looks almost like the Bash version:

Set-Content -Path app.log -Value @(
    '2026-10-06 09:00:01 INFO export started'
    '2026-10-06 09:00:07 ERROR meter M-1042 timeout'
    '2026-10-06 09:00:09 WARN meter M-1043 slow'
    '2026-10-06 09:00:12 ERROR meter M-1044 timeout'
)

$perLevel = @{}
foreach ($line in Get-Content -Path app.log) {
    $level = ($line -split ' ')[2]
    $perLevel[$level] += 1
}
foreach ($level in 'ERROR', 'WARN', 'INFO') {
    "${level}: $($perLevel[$level])"
}
ERROR: 2
WARN: 1
INFO: 1

$perLevel[$level] += 1 works for a new key because $null + 1 is 1. For counting objects by a property, Group-Object does the same in one step (see The Pipeline: Text versus Objects).

Order: [ordered]

A hash table does not remember the order in which you added the keys, and in PowerShell 7 the order is not even the same from one run to the next. The same script, run twice:

$plain = @{ zeta = 1; alpha = 2; mid = 3; Day = 0.28; Night = 0.22 }
$plain.Keys -join ', '
Night, zeta, mid, alpha, Day
mid, Day, zeta, Night, alpha

For reports and configuration files, where order matters, use an ordered dictionary: write [ordered] in front of the hash table literal, and you get a System.Collections.Specialized.OrderedDictionary that keeps the keys in insertion order, with the same lookup by key (and by number):

$settings = [ordered]@{ zeta = 1; alpha = 2; mid = 3 }
$settings.Day = 0.28
"keys:  $($settings.Keys -join ', ')"
"type:  $($settings.GetType().Name)"
"[0]:   $($settings[0])"
"alpha: $($settings['alpha'])"
keys:  zeta, alpha, mid, Day
type:  OrderedDictionary
[0]:   1
alpha: 2

The same rule applies in Bash, whose associative arrays also have their own internal order: when output order matters, sort the keys or keep a separate indexed array with the order you want.

Records: PSCustomObject

A meter reading has a meter ID, a date and a value: three fields that belong together. In PowerShell such a record is a PSCustomObject, an object whose properties you define on the spot by writing [PSCustomObject] in front of a hash table literal. Unlike a hash table, it keeps the properties in the order you wrote them, and PowerShell displays, sorts, filters and exports it like the output of any cmdlet:

$reading = [PSCustomObject]@{
    MeterId = 'M-1042'
    Date    = '2026-10-06'
    Kwh     = 4521
}
$reading | Format-Table
"meter $($reading.MeterId) used $($reading.Kwh) kWh"

$reading.Kwh = 4530
$reading | Add-Member -NotePropertyName Unit -NotePropertyValue 'kWh'
"properties now: $($reading.PSObject.Properties.Name -join ', ')"
MeterId Date        Kwh
------- ----        ---
M-1042  2026-10-06 4521

meter M-1042 used 4521 kWh
properties now: MeterId, Date, Kwh, Unit

Properties are read and changed with a dot; a new property is added with Add-Member. Objects with up to four properties are shown as a table by default, objects with more as a list. A collection of such records is where PowerShell scripts shine:

$readings = @(
    [PSCustomObject]@{ MeterId = 'M-1042'; Kwh = 4521 }
    [PSCustomObject]@{ MeterId = 'M-1043'; Kwh = 312 }
    [PSCustomObject]@{ MeterId = 'M-1044'; Kwh = 9870 }
)
$readings | Where-Object Kwh -gt 1000 | Sort-Object -Property Kwh -Descending |
    Format-Table
"total:  $(($readings | Measure-Object -Property Kwh -Sum).Sum) kWh"
"meters: $($readings.MeterId -join ', ')"
MeterId  Kwh
-------  ---
M-1044  9870
M-1042  4521

total:  14703 kWh
meters: M-1042, M-1043, M-1044

Because Kwh holds numbers, not text, -gt 1000 and the sort compare numerically. $readings.MeterId collects one property from every object in the array (member enumeration). The same objects go straight into Export-Csv or ConvertTo-Json, and Import-Csv and ConvertFrom-Json turn files back into objects like these (see JSON, CSV and APIs). The Format-Table calls are only there because this example mixes objects and text in one output; normally you let PowerShell format at the end (see The Pipeline: Text versus Objects).

Bash has no records. The usual approach is one line of text per record, with fields separated by spaces or commas, processed with text tools that you tell which field is which:

readings=("M-1042 4521" "M-1043 312" "M-1044 9870")

printf '%s\n' "${readings[@]}" | awk '$2 > 1000' | sort -k2,2nr

total=0
for record in "${readings[@]}"; do
  read -r _ kwh <<< "$record"
  total=$(( total + kwh ))
done
echo "total: $total kWh"
M-1044 9870
M-1042 4521
total: 14703 kWh

This works well for simple, flat data. As soon as fields can contain spaces or commas, or records contain lists of their own, text processing gets fragile, and an object-based language (PowerShell or Python) is the better tool.

Choosing a structure

Task Bash PowerShell
Create a list servers=(web01 db01) $servers = @('web01', 'db01')
Count ${#servers[@]} $servers.Count
Last element ${servers[-1]} $servers[-1]
Append servers+=(web02) $list.Add('web02') on a List[string]
All elements "${servers[@]}" $servers
Lookup table declare -A rate=([day]=0.28) $rate = @{ Day = 0.28 }
Key exists? [[ -v rate[day] ]] $rate.ContainsKey('Day')
Keys "${!rate[@]}" (unordered) $rate.Keys (unordered; [ordered] keeps order)
Record A line of text: "M-1042 4521" [PSCustomObject]@{ MeterId = 'M-1042'; Kwh = 4521 }

A practical rule: Bash arrays are fine for a list of servers, file names or arguments, and associative arrays for a small lookup table or a count. When a script starts to need records, nested data, decimals or JSON, that is the moment to write it in PowerShell or Python instead of stretching Bash.