Skip to content

03 · Cross-Platform PowerShell

PowerShell 7 runs on Windows, Linux, and macOS from the same executable — but "runs everywhere" isn't the same as "behaves identically everywhere." Paths, line endings, case sensitivity, and even which aliases exist all differ by platform, and scripts that don't account for that break the moment they leave the machine they were written on.

Detecting the platform

$PSVersionTable.PSVersion
$IsWindows, $IsLinux, $IsMacOS
Major Minor Patch PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7     6     4

False
False
True

$IsWindows, $IsLinux, $IsMacOS are automatic boolean variables available in PowerShell 7+ (they don't exist in Windows PowerShell 5.1, where everything is implicitly Windows) — the standard way to branch platform-specific logic without shelling out to uname or parsing $PSVersionTable.OS.

if ($IsWindows) {
    $configDir = "$env:APPDATA\MyApp"
} else {
    $configDir = "$HOME/.config/myapp"
}

The trap: hardcoded backslash paths

$badPath = "C:\Users\me\data.txt"          # breaks on Linux/macOS entirely
$goodPath = Join-Path (Join-Path $HOME "data") "data.txt"
"Good path: $goodPath"
Good path: /Users/bhanuja/data/data.txt
[System.IO.Path]::DirectorySeparatorChar
Join-Path "folder" "file.txt"
/
folder/file.txt

Join-Path (and [System.IO.Path] methods generally) always emit the correct separator for the platform actually running the script. $HOME is populated on all three platforms — prefer it over $env:USERPROFILE (Windows-only) or $env:HOME (Unix-only) directly. Any literal \ or / typed into a path string is a portability bug waiting to surface the first time the script runs somewhere else.

The trap: line endings

$text = "line1`r`nline2`nline3"
($text -split '\r?\n').Count
3

Windows-authored text files traditionally use \r\n; Linux/macOS use \n. A file downloaded or generated on one platform and processed with a line-ending assumption baked in (-split "n"alone, missing files with\r\n) silently mis-splits or leaves stray\rcharacters trailing each line.-split '\r?\n'` handles both correctly regardless of where the text came from — worth using by default for any text you didn't generate yourself in the same script.

The trap: filesystem case sensitivity varies by OS and by filesystem

New-Item -ItemType Directory -Path "/tmp/psl/CaseTest" -Force | Out-Null
"Hello" | Out-File "/tmp/psl/CaseTest/File.txt"
Test-Path "/tmp/psl/CaseTest/file.txt"
True

That returned True on this machine (macOS, APFS, case-insensitive by default) — the same script on a typical Linux filesystem (ext4, case-sensitive) would return False for the same input, because file.txt and File.txt are different files there. Windows (NTFS) is case-insensitive like macOS by default. The trap: code that "works" on your Mac or Windows box because the filesystem quietly tolerates a case mismatch can fail the moment it runs on Linux CI or a Linux server — always write paths with the exact case you mean, and never rely on the filesystem to paper over a typo.

Native command exit codes: $LASTEXITCODE

& ls /nonexistent-path-xyz 2>$null
"Exit code: $LASTEXITCODE"
Exit code: 1

$LASTEXITCODE reflects the exit code of the last native (external) command run — not a PowerShell cmdlet's success/failure, which is $?/exceptions instead. Any script that shells out to a native tool (git, docker, curl, platform package managers) and needs to detect failure has to check $LASTEXITCODE explicitly; PowerShell doesn't throw automatically just because the external process returned nonzero.

The trap: aliases that only exist on some platforms

Get-Alias ls -ErrorAction SilentlyContinue
Get-Command ls -All | Select-Object CommandType, Source
CommandType Source
----------- ------
Application /bin/ls

On this machine, ls resolves straight to the real Unix /bin/ls binary — there's no PowerShell alias named ls shadowing it here. On Windows PowerShell, ls is an alias for Get-ChildItem, which accepts different parameters entirely (-Force means something different to each). A script that calls ls -la assuming the Unix binary, or calls ls -Recurse assuming the Get-ChildItem alias, is making a platform assumption that silently breaks on the other kind of machine — this is exactly why scripts meant to be portable should call Get-ChildItem directly rather than ls, dir, or any alias whose target varies.

Cheat sheet

Concern Cross-platform fix
Which OS is this? $IsWindows / $IsLinux / $IsMacOS
Building a path Join-Path, never a literal \ or /
Home directory $HOME (works everywhere; avoid $env:USERPROFILE/$env:HOME directly)
Splitting text into lines -split '\r?\n', not just "n"or"rn"`
Filesystem case sensitivity assume case-sensitive always, regardless of what your dev machine tolerates
Native command success/failure check $LASTEXITCODE, not $?
ls, dir, other aliases call Get-ChildItem directly in portable scripts
Environment variables $env:PATH uses [System.IO.Path]::PathSeparator to split (: vs ;)

How It Actually Works

pwsh achieves genuine cross-platform behavior because the engine itself — parser, AST interpreter, object pipeline, type system — is written entirely in managed .NET code with no Windows API dependency; the parts that do differ by OS (file system semantics, path separators, process launching) are isolated behind .NET's own cross-platform abstraction layer (System.IO, System.Diagnostics.Process), not reimplemented per platform inside PowerShell itself. This is why the core language behaves identically everywhere, while a comparatively small set of cmdlets (Get-WmiObject, Get-EventLog, most of ActiveDirectory) are Windows-only: they wrap Windows-specific COM/WMI/registry APIs that simply have no equivalent surface on macOS/Linux, not because the engine treats those platforms as second-class.

Aliases that "only exist on some platforms" are a direct consequence of pwsh's compatibility aliasing for interactive convenience: on Windows, ls, cp, rm, cat are PowerShell aliases pointing at Get-ChildItem/Copy-Item/Remove-Item/Get-Content — but on Linux/ macOS, pwsh deliberately does not register some of these aliases (ls, cat, etc.) at startup specifically because a real Unix binary of the same name already exists on PATH, and shadowing it would be surprising for users mixing PowerShell with native shell tools. This means a script relying on the alias ls behaves differently by platform not due to any semantic difference in PowerShell itself, but due to a startup-profile decision about which aliases to pre-register — using the full cmdlet name sidesteps the whole issue.

Path handling differences (/ vs \, case sensitivity) trace back to the underlying filesystem's own semantics exposed through .NET's Path/ FileSystemInfo APIs — NTFS is case-preserving-but-insensitive by default while ext4/APFS (in typical configurations) are case-sensitive, so Test-Path './Foo.txt' and Test-Path './foo.txt' can genuinely return different answers on Linux for files that would be indistinguishable on Windows; Join-Path/[System.IO.Path]::Combine normalizing separators correctly for the current platform is what lets script logic stay platform-agnostic despite this.

Exercise

Take a script that assumes Windows (uses $env:USERPROFILE, backslash paths, and dir for listing files) and rewrite it to run identically on Windows, Linux, and macOS: swap in $HOME, Join-Path, Get-ChildItem, and an explicit $IsWindows/$IsLinux/$IsMacOS branch anywhere platform-specific behavior is unavoidable (like a config directory path). Run it with pwsh -File and confirm it produces sensible output on whatever platform you have available.