Arrays, Hash Tables and Objects
Storing collections in scripts: indexed and associative arrays in Bash, and arrays, hash tables and PSCustomObject in PowerShell.
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+=db01would glue the text onto the first element. -
$serverswithout 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
awkfor 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.