Why scripts cross platforms

Kestrel Energy runs Windows on the desktops and the domain controllers, Linux on the web and database servers, and a few Macs in the portal team. Ravi Kumar's deployment script is tested on his Mac, runs in a Linux container in the CI pipeline and is started by Amara from Windows. A script that only works on the machine it was written on causes "works for me" tickets. This page covers what changes between systems and how to write for more than one, or how to choose deliberately not to.

Two PowerShells

"PowerShell" names two different products, and much advice online does not say which one it means.

  • Windows PowerShell is the original PowerShell, built on the Windows-only .NET Framework. Its last version, 5.1, is part of Windows: it is started with powershell.exe, is supported as long as the Windows version it ships with, and gets security fixes but no new features.
  • PowerShell 7 is the open-source, cross-platform successor, built on modern .NET. It runs on Windows, Linux and macOS, is started with pwsh, and is installed separately, next to 5.1. As of October 2026 the current long-term support (LTS) release is 7.6, supported until 14 November 2028; 7.4 (LTS) and 7.5 reach end of support on 10 November 2026.
Windows PowerShell 5.1 PowerShell 7
Executable powershell.exe pwsh (pwsh.exe on Windows)
Runs on Windows only Windows, Linux, macOS
$PSVersionTable.PSEdition Desktop Core
Default file encoding Varies per cmdlet (ANSI, UTF-16) UTF-8 without byte order mark everywhere
Language additions — && and || between commands, ternary ? :, ?? and ??=, ForEach-Object -Parallel
Not in 7 *-EventLog, Get-WmiObject, workflows; the ISE does not support 7 Use Get-WinEvent, Get-CimInstance, VS Code
Error display Long NormalView Short ConciseView

Most scripts run in both, but write down which one a script needs: #Requires -Version 7.4 at the top makes 5.1 refuse it with a clear message instead of failing halfway on a ??. Some Windows modules still only work in 5.1; for those, PowerShell 7 offers a compatibility layer that loads them in a hidden 5.1 session.

PowerShell on Linux and macOS

PowerShell 7 on Linux is the same language, but the system under it is different. Scripts should ask where they run instead of assuming. The automatic variables $IsWindows, $IsLinux and $IsMacOS exist only in PowerShell 6 and later; in Windows PowerShell 5.1 they are simply empty, which is why the check below also tests the edition:

"Edition:   $($PSVersionTable.PSEdition)"
"Version:   $($PSVersionTable.PSVersion)"
"IsWindows: $IsWindows  IsLinux: $IsLinux  IsMacOS: $IsMacOS"

# Works in Windows PowerShell 5.1 too, where $IsWindows does not exist:
$onWindows = $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows
$logDir = if ($onWindows) { Join-Path -Path $env:ProgramData -ChildPath 'Kestrel' } else { '/var/log/kestrel' }
"Log folder: $logDir"
"Separator: $([System.IO.Path]::DirectorySeparatorChar)"
"Joined:    $(Join-Path -Path 'reports' -ChildPath 'meters.csv')"
Edition:   Core
Version:   7.6.6
IsWindows: False  IsLinux: True  IsMacOS: False
Log folder: /var/log/kestrel
Separator: /
Joined:    reports/meters.csv

Join-Path uses the right separator for the system, and cmdlets accept both / and \ in paths on every platform, so forward slashes are the portable choice in scripts. Use $HOME rather than $env:USERPROFILE (Windows only), and [System.IO.Path]::GetTempPath() rather than $env:TEMP.

Aliases that are not there

On Windows, ls, cat, sort, rm and a dozen more are aliases for PowerShell cmdlets. On Linux and macOS Microsoft removed them so the native programs can run, so the same word does something else:

foreach ($name in 'ls', 'cat', 'sort', 'gci', 'select') {
    $cmd = Get-Command -Name $name | Select-Object -First 1
    '{0,-7} {1,-12} {2}' -f $name, $cmd.CommandType, $(if ($cmd.CommandType -eq 'Alias') { $cmd.Definition } else { $cmd.Source })
}
ls      Application  /usr/bin/ls
cat     Application  /usr/bin/cat
sort    Application  /usr/bin/sort
gci     Alias        Get-ChildItem
select  Alias        Select-Object

ls | Where-Object Length -gt 1MB works on Windows and silently finds nothing on Linux, because /usr/bin/ls sends text, not file objects. This is one more reason to write full cmdlet names in scripts (see PowerShell style).

Case-sensitive file names

Windows file systems ignore case; Linux file systems do not. PowerShell follows the file system, so a path must be spelled exactly. Wildcard patterns, on the other hand, stay case-insensitive:

Set-Content -Path ./Readings.csv -Value 'M-1042;4521'
"readings.csv exists: $(Test-Path -Path ./readings.csv)"
"Readings.csv exists: $(Test-Path -Path ./Readings.csv)"
"Wildcard *.CSV finds: $((Get-ChildItem -Path ./*.CSV).Name)"
readings.csv exists: False
Readings.csv exists: True
Wildcard *.CSV finds: Readings.csv

What does not exist off Windows

A whole class of administration is Windows-specific and has no cmdlets on Linux or macOS: services (Get-Service, Start-Service), the registry provider, CIM and WMI (Get-CimInstance), the event log, Task Scheduler, access control lists (Get-Acl) and Enable-PSRemoting. Execution policy is ignored (Get-ExecutionPolicy always says Unrestricted), and SecureString values are not encrypted. A cross-platform script calls the native tool on each side instead: systemctl on Linux, Get-Service on Windows.

Encoding is one thing that got easier: PowerShell 7 writes UTF-8 without a byte order mark and ends lines with the system's line ending, which is what Linux tools expect:

Set-Content -Path ./note.txt -Value 'Café'
'Bytes: ' + ((Get-Content -Path ./note.txt -AsByteStream | ForEach-Object { '{0:X2}' -f $_ }) -join ' ')
Bytes: 43 61 66 C3 A9 0A

C3 A9 is the é in UTF-8, 0A a Linux line feed, and there is no EF BB BF at the start. Windows PowerShell 5.1 would have written this file in the system's ANSI code page.

Bash on Windows and macOS

Three common ways to run Bash scripts on a Windows machine:

Option What it is Good for
WSL 2 A real Linux distribution in a lightweight virtual machine Anything that must behave exactly as on a Linux server
Git Bash Bash and common tools from MSYS2, shipped with Git for Windows Small scripts around Git; quick Unix tools on a Windows desktop
Cygwin A larger POSIX compatibility layer with its own packages Older setups; mostly replaced by WSL

WSL (Windows Subsystem for Linux) runs real Linux distributions on Windows. Version 2, the default, runs a genuine Linux kernel in a lightweight virtual machine that starts in seconds, and integrates with Windows: the Windows drives appear under /mnt/c, and Windows programs can be started from Bash. One command installs it, with Ubuntu as the default distribution:

wsl --install                   # as administrator; reboot when asked
wsl -d Ubuntu -- bash ./check-disk.sh 90

Git Bash is lighter: Git for Windows includes a Bash emulation based on MSYS2 with the usual tools (grep, sed, awk, ssh). It is fine for small scripts, but it is not Linux: paths look like /c/Users/amara, there is no package manager for system software, and some tools behave differently.

On macOS, /bin/bash is version 3.2, first released in 2006 (newer versions are under the GPLv3 license, which Apple does not ship), and the default login shell has been zsh since macOS Catalina (10.15). Bash 3.2 lacks associative arrays (declare -A), mapfile and ${var,,}. Install a current Bash with Homebrew and start scripts with #!/usr/bin/env bash, which uses the first bash in the PATH instead of the old one. macOS also ships BSD versions of the core tools, which differ from the GNU tools on Linux in details: sed -i '' instead of sed -i, date -j -f instead of date -d, and no grep -P on many systems (see quoting and portability).

Line endings

A line ending is the invisible character sequence that ends each line of a text file: a line feed (LF, \n) on Linux and macOS, a carriage return plus line feed (CRLF, \r\n) by Windows convention. Bash treats the carriage return as part of the line. A script saved with CRLF on Windows and copied to a Linux server fails in confusing ways: the shebang names an interpreter /bin/bash\r that does not exist, and variables get an invisible \r at the end:

printf '#!/bin/bash\r\nhost=web01\r\necho "checking [$host]"\r\n' > check.sh
chmod +x check.sh
./check.sh
bash check.sh | cat -A
shellcheck -f gcc check.sh | head -n 1
sed -i 's/\r$//' check.sh          # convert CRLF to LF
./check.sh
example.sh: line 3: ./check.sh: cannot execute: required file not found
checking [web01^M]^M$
check.sh:1:12: error: Literal carriage return. Run script through tr -d '\r' . [SC1017]
checking [web01]

cat -A shows the carriage return as ^M. Older Bash versions report the first error as bad interpreter: No such file or directory, which sounds like a missing Bash, not a line-ending problem. Convert with sed -i 's/\r$//' or dos2unix, and prevent it at the source: a .gitattributes file in the repository tells Git to check shell scripts out with LF on every system, whatever the user's core.autocrlf setting:

*.sh   text eol=lf
*.ps1  text

PowerShell reads scripts with either line ending, so .ps1 files need no special rule. Editors such as VS Code show the line ending in the status bar and can switch it.

Portable shell: POSIX sh

POSIX sh is the shell language defined by the POSIX standard: the common core that every Unix-like system's /bin/sh must understand. On Debian and Ubuntu /bin/sh is dash, a small, fast shell with little beyond that core; in Alpine Linux containers it is BusyBox ash; on RHEL and macOS it is Bash in POSIX mode. A script starting with #!/bin/sh therefore gets a different shell on different systems, and only POSIX features are safe.

A bashism is a Bash-only feature used in a script that claims to be POSIX sh: [[ ]], arrays, echo -e, source, function name, read -p, {1..10}. In Bash it works; under dash it fails, sometimes without stopping the script:

cat > meters.sh <<'END'
if [[ $1 == M-* ]]; then echo "meter $1"; fi
END
bash meters.sh M-1042
sh meters.sh M-1042
echo "sh exit status: $?"
meter M-1042
meters.sh: 1: [[: not found
sh exit status: 0

dash looked for a command called [[, did not find it, treated the condition as false and finished with status 0: the script "succeeded" without doing its work. ShellCheck finds bashisms when the shebang says sh, with the SC3xxx codes:

cat > bashisms.sh <<'END'
#!/bin/sh
servers=(web01 web02)
if [[ -n $1 ]]; then echo -e "checking\t${servers[0]}"; fi
END
shellcheck -f gcc bashisms.sh
bashisms.sh:2:9: warning: In POSIX sh, arrays are undefined. [SC3030]
bashisms.sh:3:4: warning: In POSIX sh, [[ ]] is undefined. [SC3010]
bashisms.sh:3:27: warning: In POSIX sh, echo flags are undefined. [SC3037]
bashisms.sh:3:41: warning: In POSIX sh, array references are undefined. [SC3054]

The portable version uses case for pattern matching, printf instead of echo -e, and runs the same in both shells:

cat > portable.sh <<'END'
#!/bin/sh
case $1 in
  M-[0-9][0-9][0-9][0-9]) printf 'meter %s\n' "$1" ;;
  *) printf 'not a meter id: %s\n' "$1" >&2; exit 2 ;;
esac
END
sh portable.sh M-1042
bash portable.sh M-1042
shellcheck portable.sh && echo "shellcheck (POSIX sh): no findings"
meter M-1042
meter M-1042
shellcheck (POSIX sh): no findings

Write POSIX sh only when you must: for minimal containers without Bash, for installers that run on unknown systems, or for very old or embedded devices. Otherwise use #!/bin/bash (or #!/usr/bin/env bash) honestly and enjoy arrays and [[ ]]. The latest POSIX edition (2024) added set -o pipefail and $'…' quoting, but older shells may not support them yet.

Choosing the right tool

Bash and PowerShell are both available everywhere now, so the choice is about the job, not the operating system alone:

Situation Good choice Why
Glue on Linux servers, containers, CI steps Bash (sh in minimal images) Always installed; the tools you call are Unix tools
Windows, Active Directory, Microsoft 365, Exchange, Azure PowerShell The official modules are PowerShell modules
Structured data (JSON, CSV, APIs) on any platform PowerShell 7 or Python Objects instead of text parsing
Long programs, complex logic, reusable libraries Python Better data structures, testing and packaging
The same change on many servers Ansible or similar Idempotent, inventory-driven (see fan-out)

A useful rule from the Google Shell Style Guide: once a shell script grows past about 100 lines or gets complicated control flow, rewrite it in a more structured language. Mixing is fine, too: a short Bash wrapper that calls pwsh -File or python3, or a PowerShell script that calls wsl, uses each tool where it is strongest.