Files, Paths and Globbing
Working with files in scripts: globbing, safe handling of names with spaces, find, and Get-ChildItem, Test-Path and Join-Path in PowerShell.
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 *.loggives 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-rfcannot 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 |