Skip to content
shellmap

sh (POSIX) vs PowerShell

A POSIX shell script and a PowerShell script are not two dialects of one language — they are two languages. `sh` pipes bytes; PowerShell pipes typed .NET objects. This is the compare you need before porting a `/bin/sh` install script to a Windows installer, or asking "why does my Linux one-liner do nothing in pwsh?".

Summary

`sh` is the POSIX 2017 specification — a 30-year-old text-stream shell language whose grammar predates Unicode. Every Linux server, every Docker image, every Dockerfile `RUN` line ultimately speaks sh. On Debian/Ubuntu it is `dash`; on Alpine, `busybox ash`; on macOS, `bash --posix`; on RHEL, `bash --posix`. The common subset is what runs in CI when nobody looked.

PowerShell (Windows PowerShell 5.1 + the cross-platform `pwsh` 7) is a typed .NET shell that Microsoft introduced in 2006 explicitly to NOT be sh. Pipes carry `[PSObject]` instances with named properties; commands are verb-noun cmdlets (`Get-ChildItem`, `Set-Content`) with named, validated parameters; loops, conditionals, and functions use C-style braces, not the `then`/`fi`/`done` keyword pairs of POSIX.

Translating between them is rarely line-by-line. A `grep -E "ERROR" log | awk '{print $1}' | sort | uniq -c` pipeline becomes `Get-Content log | Select-String "ERROR" | ForEach-Object { ($_ -split " ")[0] } | Group-Object | Select-Object Count, Name` — and the object pipeline hands you `Count` and `Name` as properties directly. The shape is the same; the syntax shares almost nothing.

Syntax & semantic differences

  • Pipe contents

    sh (POSIX)

    Bytes of text. Each command writes lines to stdout; the next command parses them itself. `ls -l | awk '$5>1000000'` works because awk re-tokenises the line.

    PowerShell

    Typed .NET objects with properties and methods. `Get-ChildItem | Where-Object Length -gt 1MB` works because each item IS a `FileInfo` with a `Length` property — no parsing needed.

    Porting a sh pipeline to PowerShell, the win is dropping `awk`/`cut`/`sed` parsing entirely — call `.Length` / `.Name` / `.LastWriteTime` on the object instead. The cost: legacy `.exe` programs in a PowerShell pipeline receive strings (the host's formatted view), not objects.

  • Variable assignment

    sh (POSIX)

    `NAME=value` — no spaces around `=`. Read as `$NAME` or `${NAME}`. Strings only — every value is bytes. Export with `export NAME=value`.

    PowerShell

    `$Name = value` — spaces around `=` are fine, and the left side has a `$`. Holds any object (`[int]`, `[string]`, `[hashtable]`, `[System.IO.FileInfo]`). Type-coerce with `[int]$x = "5"`. Export to environment with `$env:NAME = "value"`.

    `NAME=value` in PowerShell parses as a command invocation and errors with "The term 'NAME=value' is not recognised". Conversely, `$Name = value` is a syntax error in sh. The two assignment forms cannot be cross-copied at all.

  • Conditionals

    sh (POSIX)

    `if [ -f file ]; then …; fi` — `[` is actually a command (synonym for `test`); `then`/`fi` are keywords; `;` before `then` is mandatory when `then` is on the same line. No `else if` — use `elif`.

    PowerShell

    `if (Test-Path file) { … }` — `(…)` for the condition, `{…}` for the body, no `then`/`fi`. Use `elseif` (one word). Comparison operators are `-eq`/`-ne`/`-lt`/`-gt`/`-match`/`-like` — NOT `==`/`!=`/`<`/`>` (those are redirection / output operators).

    `if ($a == $b)` is a silent bug in PowerShell — `==` is not a comparison operator, and the line just evaluates `$b` and discards it. Always `-eq`. Likewise `>` inside a PowerShell condition is a file-redirection token, not "greater than".

  • AND / OR chaining between commands

    sh (POSIX)

    `cmd1 && cmd2`, `cmd1 || cmd2`. POSIX since forever. Chains on exit code (0 = success).

    PowerShell

    In PowerShell 7+ (`pwsh`): `cmd1 && cmd2` and `cmd1 || cmd2` work, evaluating `$LASTEXITCODE` (external commands) or `$?` (cmdlets). In Windows PowerShell 5.1: `&&`/`||` do NOT exist — use `if ($LASTEXITCODE -eq 0) { cmd2 }` or `cmd1; if (-not $?) { cmd2 }`.

    Inside an `if` condition the logical operators are `-and`/`-or`, not `&&`/`||`. Between two statements, pwsh 7+ added `&&`/`||` in late 2020; the 5.1 that ships pre-installed on Windows Server 2019 / 2022 has nothing equivalent. Targeting 5.1 means `;`-and-`if` chains.

  • Command substitution

    sh (POSIX)

    `$(cmd)` or backticks `` `cmd` `` (legacy). `result=$(date +%Y)`. Output captured as a string with trailing newlines stripped.

    PowerShell

    `$(expr)` inside a double-quoted string (subexpression operator); `(cmd)` outside strings captures the cmdlet's object output. `$result = (Get-Date).Year` returns an `[int]`, not a string. Backticks in PowerShell are the escape / line-continuation character — NOT command substitution.

    Copy-pasting a sh one-liner with backticks into a PowerShell script is one of the deepest traps in the language: `` `n`` is a newline, `` `t`` is a tab, `` `$`` escapes the dollar sign. The surrounding command silently corrupts rather than erroring.

  • Here-strings (multi-line input)

    sh (POSIX)

    `<<<` (here-string) is a bash extension — NOT POSIX. Pure POSIX uses a here-doc: `cmd <<EOF\ntext\nEOF`. The `<<-` form strips leading tabs for indented heredocs.

    PowerShell

    Here-strings are `@"…"@` (interpolating) or `@'…'@` (literal). The opening `@"` must end the line; the closing `"@` must start its line; the body sits in between. `$body = @"\nLine one\nLine two\n"@`. Used heavily for multi-line config templates and SQL.

  • Exit status

    sh (POSIX)

    `$?` is the previous command's exit code — an integer, 0 = success. Check after every command: `cmd; if [ $? -ne 0 ]; then …; fi`.

    PowerShell

    `$LASTEXITCODE` is the exit code of the last *external* program (`.exe` or script). `$?` is a *boolean* — `$true` if the last *cmdlet* succeeded, `$false` otherwise. Completely different beasts.

    Porting `[ $? -ne 0 ]` straight to PowerShell as `if ($? -ne 0)` is wrong twice: `$?` is bool not int; and for external programs you want `$LASTEXITCODE`. Correct: `if ($LASTEXITCODE -ne 0)` for `.exe`; `if (-not $?)` for cmdlets.

  • Loops

    sh (POSIX)

    `for f in *.log; do echo "$f"; done` — the shell expands the glob into words before the loop sees them. C-style `for ((i=0;i<10;i++))` is bash-only, not POSIX.

    PowerShell

    `foreach ($f in Get-ChildItem *.log) { Write-Output $f.Name }` or the pipeline form `Get-ChildItem *.log | ForEach-Object { $_.Name }`. C-style `for ($i=0; $i -lt 10; $i++) { … }` uses PowerShell comparison operators, NOT `<`.

  • Functions

    sh (POSIX)

    `my_fn() { echo "$1"; }` — positional args `$1 $2 ... $@`, no parameter declaration. Caller: `my_fn arg1 arg2`.

    PowerShell

    `function My-Fn { param([string]$Name, [int]$Count = 1) Write-Output ($Name * $Count) }` — named, typed, defaultable parameters with validation attributes. Caller: `My-Fn -Name foo -Count 3`. Positional binding works but named is idiomatic.

  • String interpolation

    sh (POSIX)

    Double quotes interpolate `$var` and `$(cmd)`. Single quotes are literal. `\$var` escapes the dollar.

    PowerShell

    Double quotes interpolate `$var`, `$($expr)` (subexpression), and `$env:NAME` (environment). Single quotes are literal. The escape character is the backtick: `` "`$var" `` is a literal `$var`.

  • Globbing

    sh (POSIX)

    The shell expands `*` / `?` / `[abc]` BEFORE the command sees them — `rm *.log` passes the expanded filenames to `rm`. No match: depends on the shell (sh leaves the literal pattern, bash `failglob` aborts).

    PowerShell

    `*` / `?` are passed AS-IS by the shell; each cmdlet decides what to do (`Get-ChildItem *.log` expands; `Remove-Item *.log` expands; an arbitrary `.exe` does not). For explicit string-pattern matching use `-like` (wildcards) or `-match` (regex).

  • Aliases

    sh (POSIX)

    `alias ll='ls -la'` — text substitution. Interactive shells only; scripts need `shopt -s expand_aliases` (bash) or are not POSIX-compatible at all (alias is interactive-only in POSIX).

    PowerShell

    `Set-Alias ll Get-ChildItem` — maps one name to ONE cmdlet only; cannot include parameters. For parameterised aliases, define a function: `function ll { Get-ChildItem -Force @args }`. PowerShell ships `ls`, `cat`, `cp`, `mv`, `rm`, `ps` as aliases — Unix-style flags like `ls -la` ERROR.

  • Script extension and execution policy

    sh (POSIX)

    `.sh` is convention only — POSIX ignores file extensions; the shebang `#!/bin/sh` picks the interpreter. `chmod +x script.sh; ./script.sh` runs it.

    PowerShell

    `.ps1`. Will NOT run by double-click without configuring execution policy: `Set-ExecutionPolicy RemoteSigned` (administrator) or run as `pwsh -File script.ps1` / `powershell -ExecutionPolicy Bypass -File script.ps1`. Unsigned `.ps1` from the internet is blocked by default — a defining PowerShell security stance with no sh analogue.

Gotchas when porting between them

  • **Windows PowerShell 5.1 and PowerShell 7+ (`pwsh`) are different products.** 5.1 ships with every Windows since Windows 10 / Server 2016 and is .NET Framework-based — frozen feature set. 7+ is cross-platform .NET Core and adds `&&`/`||` chaining, ternary `?:`, null-coalescing `??`, and pipeline parallelism (`ForEach-Object -Parallel`). A script that uses `&&` will fail on the 5.1 pre-installed everywhere. Either target 5.1 (skip the niceties) or require `pwsh` in install instructions.
  • **Backticks mean opposite things in sh and PowerShell.** sh: `` `cmd` `` is legacy command substitution. PowerShell: `` `n`` is a newline literal, `` `t`` is a tab, `` `$`` escapes the dollar sign, a trailing backtick on a line is line-continuation. Paste sh `` `date` `` into a PowerShell string and you get the characters `d`, `a`, `t`, `e` re-escaped through PowerShell's escape rules — silent corruption rather than a syntax error.
  • **PowerShell `ls`, `cat`, `cp`, `rm`, `ps` are aliases for cmdlets, NOT the Unix binaries.** They accept PowerShell parameter syntax — `Get-ChildItem -Force`, NOT `ls -la`. `ls -la` in PowerShell errors with "A parameter cannot be found that matches parameter name 'l'". On Linux `pwsh`, the aliases are removed by default so the real Unix tools win — adding a per-system surprise where the same incantation does different things on Windows vs Linux pwsh.
  • **Exit-code conventions clash.** sh: 0 = success, non-zero = error, `$?` is the integer. PowerShell cmdlets: `$?` is a boolean (`$true`/`$false`); the integer `$LASTEXITCODE` is only set by external `.exe`. A sh script ending `cmd; exit $?` translates to PowerShell as `cmd; exit $LASTEXITCODE` for an external command, or `cmd; if ($?) { exit 0 } else { exit 1 }` for a cmdlet — getting it wrong silently sets the wrong CI exit code.
  • **Quoting and escaping are unrelated.** sh: `\$var` escapes the dollar; PowerShell: `` `$var`` escapes it. sh `"text with \"quotes\""` becomes PowerShell `"text with `"quotes`""` or `'text with "quotes"'`. The two languages' escape characters (`\` vs `` ` ``) collide whenever sh code is pasted into a PowerShell string literal — common failure mode when storing shell commands as PowerShell variables for `Invoke-Expression`.

Full references

Other comparisons