Globbing in scripts

Globbing is matching file names with wildcards such as *, ? and [ab]. The basics are the same at the prompt and in a script; this page is about what changes when nobody is watching the result. The first difference between the shells is who expands the pattern.

In Bash, the shell itself replaces *.log with the matching names, sorted, before the command starts. The command never sees the asterisk. With the C.UTF-8 locale used here, sorting is by byte value, so uppercase names come before lowercase ones and meter 10.log comes before meter 9.log:

touch A.log C.log a.log b.log 'meter 10.log' 'meter 9.log' notes.txt
printf '%s\n' *.log
echo "---"
printf '%s\n' [ab].log
A.log
C.log
a.log
b.log
meter 10.log
meter 9.log
---
a.log
b.log

In PowerShell, the shell passes *.log to the cmdlet as a string, and the cmdlet (here Get-ChildItem) does the matching. PowerShell wildcards are case-insensitive, even on Linux where the file names themselves are case-sensitive, so [ab].log also matches A.log. The order in which Get-ChildItem returns files is not something to rely on; sort explicitly:

'A.log', 'C.log', 'a.log', 'b.log', 'meter 10.log', 'meter 9.log', 'notes.txt' |
    ForEach-Object { New-Item -ItemType File -Path $_ } | Out-Null

Get-ChildItem -Path *.log | Sort-Object Name -CaseSensitive | ForEach-Object Name
'---'
Get-ChildItem -Path '[ab].log' | Sort-Object Name -CaseSensitive | ForEach-Object Name
a.log
A.log
b.log
C.log
meter 10.log
meter 9.log
---
a.log
A.log
b.log

ForEach-Object Name prints only the names. The default view of Get-ChildItem also shows owner, size and modification time, which is fine at the prompt but useless as script output. Even sorted, the order differs from Bash: Sort-Object compares names the way a dictionary does (a.log next to A.log), while Bash compares bytes.

When nothing matches

A Bash glob that matches nothing is left as it is. A loop over exports/*.csv in an empty folder therefore runs once, with the literal text exports/*.csv as the "file name". The shell option nullglob makes a pattern without matches expand to nothing instead, so the loop does not run at all:

mkdir exports
for f in exports/*.csv; do
  echo "Processing: $f"
done
echo "--- with nullglob"
shopt -s nullglob
for f in exports/*.csv; do
  echo "Processing: $f"
done
echo "done"
Processing: exports/*.csv
--- with nullglob
done

Without nullglob, the usual guard is a test inside the loop: [[ -e $f ]] || continue. A stricter alternative is shopt -s failglob, which makes an unmatched pattern an error. Switch these options on near the top of the script, not halfway: nullglob also affects commands such as ls *.csv, which then run with no argument and list the whole folder.

PowerShell has no such surprise. Get-ChildItem with a wildcard that matches nothing returns nothing and reports no error, and @( … ) turns the result into an array whose Count is reliable even for zero or one file:

New-Item -ItemType Directory -Path exports | Out-Null
$files = @(Get-ChildItem -Path exports -Filter *.csv)
"Found $($files.Count) export(s)"
foreach ($file in $files) { "Processing: $($file.Name)" }
'done'
Found 0 export(s)
done

Brace expansion is not a glob

Brace expansion is a Bash feature that generates words from a pattern such as {2025,2026} or {1..4}, whether or not files with those names exist. That is the difference with globbing, which only produces names that exist. It is handy for creating a folder structure or a backup copy in one line (Linux brace expansion has more examples):

mkdir -p reports/{2025,2026}/{q1,q2}
find reports -type d | sort
touch app.conf
cp app.conf{,.bak}     # expands to: cp app.conf app.conf.bak
ls app.conf*
reports
reports/2025
reports/2025/q1
reports/2025/q2
reports/2026
reports/2026/q1
reports/2026/q2
app.conf
app.conf.bak

PowerShell has no brace expansion; a loop over an array or a range (1..4) does the same job and is easier to read for colleagues who do not know the syntax:

foreach ($year in 2025, 2026) {
    foreach ($quarter in 'q1', 'q2') {
        New-Item -ItemType Directory -Path "reports/$year/$quarter" -Force | Out-Null
    }
}
Get-ChildItem -Path reports -Directory -Recurse |
    ForEach-Object { [System.IO.Path]::GetRelativePath($PWD, $_.FullName) } |
    Sort-Object
reports/2025
reports/2025/q1
reports/2025/q2
reports/2026
reports/2026/q1
reports/2026/q2

File names with spaces and other surprises

Linux allows almost any character in a file name: spaces, quotes, a leading dash, even a newline. A Bash script that handles file names as unquoted words breaks on the first space, because the shell splits the value into separate words (see Variables, Quoting and Expansion). The rules that keep scripts safe:

  • Loop over a glob, never over the output of ls: for f in *.log gives each name as one word, spaces and all.
  • Quote every expansion: "$f", "${files[@]}".
  • Put -- before file names in commands, to mark the end of the options, so a file called -rf cannot be read as an option.

The script below renames meter logs with spaces in their names. The broken version is shown first, so you can see what goes wrong:

touch 'meter 10.log' 'meter 9.log'
echo "Wrong: for f in \$(ls)"
for f in $(ls *.log); do echo "  [$f]"; done
echo "Right: for f in *.log"
for f in *.log; do
  mv -- "$f" "${f// /_}"
done
printf '  %s\n' *.log
Wrong: for f in $(ls)
  [meter]
  [10.log]
  [meter]
  [9.log]
Right: for f in *.log
  meter_10.log
  meter_9.log

${f// /_} is parameter expansion that replaces every space with an underscore. To count files, put the glob in an array and use its length: files=(*.log); echo "${#files[@]}". ls | wc -l counts lines, and a name with a newline in it would be counted twice.

Literal paths in PowerShell

PowerShell passes a file name as one string, so spaces cause no word splitting. Its trap is different: -Path treats *, ? and [ ] as wildcards. A file called report[1].txt is not found through -Path, because [1] is read as "the character 1", which matches report1.txt. A literal path is a path taken exactly as written, without wildcard interpretation; in PowerShell you ask for one with -LiteralPath:

Set-Content -LiteralPath 'report[1].txt' -Value 'INC-20419 notes'
"With -Path:        $(Test-Path -Path 'report[1].txt')"
"With -LiteralPath: $(Test-Path -LiteralPath 'report[1].txt')"
With -Path:        False
With -LiteralPath: True

In scripts that process names from elsewhere (a CSV file, user input, another system), prefer -LiteralPath whenever you do not need wildcards. Objects from Get-ChildItem already pass their exact path along the pipeline, so Get-ChildItem | Remove-Item is safe.

Finding files recursively

For a whole folder tree, Bash scripts use find. The safe way to pass its results to a loop is null-delimited output: -print0 ends every name with a null byte instead of a newline. The null byte is the only character that cannot appear anywhere in a Linux path, so names with spaces or newlines arrive intact. read -d '' reads up to the next null byte, and sort -z sorts null-delimited data, because find returns names in directory order, which is not alphabetical:

mkdir -p exports/2026-09 exports/2026-10
touch exports/2026-09/readings.csv 'exports/2026-10/readings north.csv' exports/2026-10/notes.txt
find exports -type f -name '*.csv' -print0 | sort -z |
  while IFS= read -r -d '' file; do
    printf 'Found: %s\n' "$file"
  done
Found: exports/2026-09/readings.csv
Found: exports/2026-10/readings north.csv

When the work for each file is a single command, -exec … {} + is simpler and needs no loop: find exports -name '*.csv' -exec gzip -- {} + compresses every match, passing as many names per gzip run as fit. Find actions covers -exec and -delete.

PowerShell uses Get-ChildItem -Recurse. -File or -Directory limits the result to one kind, and -Filter passes the pattern to the file system, which is faster than -Include on large trees:

New-Item -ItemType Directory -Path exports/2026-09, exports/2026-10 | Out-Null
'exports/2026-09/readings.csv', 'exports/2026-10/readings north.csv', 'exports/2026-10/notes.txt' |
    ForEach-Object { New-Item -ItemType File -Path $_ } | Out-Null

Get-ChildItem -Path exports -Recurse -File -Filter *.csv |
    Sort-Object FullName |
    ForEach-Object { "Found: $($_.Directory.Name)/$($_.Name) ($($_.Length) bytes)" }
Found: 2026-09/readings.csv (0 bytes)
Found: 2026-10/readings north.csv (0 bytes)

Each result is a file object, so its size (Length), folder (Directory) and dates (LastWriteTime) are available without a second command. In Bash you would ask stat or find -printf for those.

Building and taking apart paths

In Bash, a path is just a string: you build it with "$dir/$file" and take it apart with basename and dirname, or with parameter expansion, which avoids starting a separate program:

path=/var/log/kestrel/billing.log
echo "basename: $(basename "$path")"
echo "no .log:  $(basename "$path" .log)"
echo "dirname:  $(dirname "$path")"
echo "--- with parameter expansion"
echo "file:      ${path##*/}"
echo "folder:    ${path%/*}"
echo "extension: ${path##*.}"
dir=/var/log/kestrel
echo "joined:    $dir/portal.log"
basename: billing.log
no .log:  billing
dirname:  /var/log/kestrel
--- with parameter expansion
file:      billing.log
folder:    /var/log/kestrel
extension: log
joined:    /var/log/kestrel/portal.log

Join-Path is the PowerShell cmdlet that combines path parts with the separator of the current platform, so the same script produces logs/app.log on Linux and logs\app.log on Windows. Split-Path takes a path apart, and a file object from Get-Item has BaseName and Extension properties:

$path = '/var/log/kestrel/billing.log'
"Leaf:      $(Split-Path -Path $path -Leaf)"
"Parent:    $(Split-Path -Path $path -Parent)"
"Joined:    $(Join-Path -Path '/var/log' -ChildPath 'kestrel' -AdditionalChildPath 'portal.log')"
"Separator: $([System.IO.Path]::DirectorySeparatorChar)"

New-Item -ItemType File -Path 'meter 10.log' | Out-Null
$item = Get-Item -LiteralPath 'meter 10.log'
"BaseName:  $($item.BaseName)"
"Extension: $($item.Extension)"
Leaf:      billing.log
Parent:    /var/log/kestrel
Joined:    /var/log/kestrel/portal.log
Separator: /
BaseName:  meter 10
Extension: .log

Paths in PowerShell cmdlets accept both / and \ as separators on every platform, but native programs and other systems do not, which is one more reason to let Join-Path decide. Cross-Platform Scripting covers the other differences.

Paths relative to the script

A relative path such as config/settings.json is resolved against the current directory, which is wherever the user (or cron, or Task Scheduler) happened to start the script. To find files next to the script itself, start from the script's own location: in PowerShell the automatic variable $PSScriptRoot holds the script's folder; in Bash, "$(dirname "$0")" gives the folder as it was used to call the script, and BASH_SOURCE does the same inside sourced files.

"Script folder: $PSScriptRoot"
"Config file:   $(Join-Path -Path $PSScriptRoot -ChildPath 'config/settings.json')"
Script folder: /home/amara/scripts
Config file:   /home/amara/scripts/config/settings.json
#!/bin/bash
# Run from anywhere: work relative to the folder this script lives in
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
config="$script_dir/config/settings.json"

Testing before you act

A script that copies, moves or deletes should first check that its input exists and is what it expects: a file and not a folder, readable, not empty. Bash uses test operators inside [[ ]] (the full list is on Shell Scripting); PowerShell uses Test-Path, with -PathType Leaf for a file and Container for a folder:

mkdir -p backups
printf 'M-1042,4521\n' > readings.csv
for p in readings.csv backups missing.csv; do
  if [[ -f $p ]]; then
    echo "$p: file, $(wc -c < "$p") bytes"
  elif [[ -d $p ]]; then
    echo "$p: folder"
  else
    echo "$p: does not exist"
  fi
done
readings.csv: file, 12 bytes
backups: folder
missing.csv: does not exist
New-Item -ItemType Directory -Path backups -Force | Out-Null
'M-1042,4521' | Set-Content -Path readings.csv
foreach ($p in 'readings.csv', 'backups', 'missing.csv') {
    if (Test-Path -LiteralPath $p -PathType Leaf) {
        "${p}: file, $((Get-Item -LiteralPath $p).Length) bytes"
    } elseif (Test-Path -LiteralPath $p -PathType Container) {
        "${p}: folder"
    } else {
        "${p}: does not exist"
    }
}
readings.csv: file, 12 bytes
backups: folder
missing.csv: does not exist

"${p}: …" needs braces in PowerShell, because $p: would be read as a variable with a scope or drive prefix. Creating a folder that may already exist is a classic source of errors in scripts that run again and again: mkdir -p backups and New-Item -ItemType Directory -Force succeed whether or not the folder is there. Writing Robust and Safe Scripts builds on that idea.

PowerShell providers: more than files

A PowerShell provider presents a data store as a drive with paths, so the same cmdlets (Get-ChildItem, Get-Item, Test-Path, New-Item, Remove-Item) work on it. The FileSystem provider is the one you use most; others expose environment variables (Env:), variables, aliases and functions. On Windows there are also the registry (HKLM:, HKCU:) and the certificate store (Cert:).

Get-PSProvider | Sort-Object Name | ForEach-Object Name
'---'
"Env:TZ exists: $(Test-Path -Path Env:TZ)"
"Value:         $((Get-Item -Path Env:TZ).Value)"
Alias
Environment
FileSystem
Function
Variable
---
Env:TZ exists: True
Value:         UTC

That is why Test-Path HKLM:\SOFTWARE\Kestrel works on Windows to check for a registry key. Bash has nothing comparable: environment variables are variables, and other stores have their own commands.

Side by side

Task Bash PowerShell
Files matching a pattern for f in logs/*.log Get-ChildItem logs -Filter *.log
Who matches the wildcard The shell, case-sensitive The cmdlet, case-insensitive
No match Pattern stays literal (or nullglob) Empty result
Recursive search find dir -name '*.csv' -print0 Get-ChildItem dir -Recurse -Filter *.csv
Name exactly as written Quote it: "$f", plus -- -LiteralPath $f
Join paths "$dir/$file" Join-Path $dir $file
File name, folder ${p##*/}, ${p%/*} Split-Path -Leaf, -Parent
Is it a file / a folder? [[ -f $p ]], [[ -d $p ]] Test-Path $p -PathType Leaf / Container
Create folder if missing mkdir -p dir New-Item -ItemType Directory dir -Force
Script's own folder $(dirname "${BASH_SOURCE[0]}") $PSScriptRoot