Skip to content
shellmap

sh (POSIX) vs cmd.exe

You are porting a `/bin/sh` install script — `set -e`, `$(curl …)`, `for f in *; do …; done` — to a `.bat` / `.cmd` installer you can double-click on Windows. The two are both "text-mode shells", and that's where the similarity ends: variable syntax, conditionals, loops, command substitution, arithmetic, line endings, escape characters, and exit-code conventions all change.

Summary

`sh` is the POSIX 2017 specification — the lowest-common-denominator Unix shell language. Every Dockerfile `RUN` line, every Linux init script, every `curl … | sh` installer runs against some `/bin/sh` binary (dash on Debian, busybox ash on Alpine, `bash --posix` on macOS / RHEL). The grammar predates Unicode and pipes raw bytes.

`cmd.exe` is the Windows Command Processor — the descendant of `COMMAND.COM` from MS-DOS, present on every Windows install since NT 3.1 (1993). It also pipes bytes, but the surface language is completely separate from sh: variables wrap in percent signs, control flow uses `IF` / `FOR` / `GOTO` keywords (often uppercased by convention), there is no shebang line, and the line ending in scripts must be CRLF.

A `.sh` script does not become a `.cmd` script by editing line by line — the two are different languages that share a job (orchestrate a few external programs into an installer). For anything bigger than a 5-line `.sh`, the realistic options are: ship a separate `.cmd` rewrite, ship a PowerShell script (`.ps1`) which is more expressive, or have users install Git Bash / WSL and run the `.sh` unchanged. Pure cmd.exe is the lowest-friction install target (no extra software) but the highest porting cost.

Syntax & semantic differences

  • Pipe contents and line endings

    sh (POSIX)

    Bytes of text, LF-terminated lines. `ls -l | grep "^d"` works because `ls` emits LF-separated rows.

    cmd.exe

    Bytes of text, CRLF-terminated lines (the carriage return + line feed convention inherited from DOS). External programs in a cmd pipeline that emit LF-only output may confuse downstream tools that expect CRLF. The pipe itself is implemented by writing the upstream command's output to a temp file and feeding the next command from it — NOT a true streaming pipe.

    A consequence of the temp-file implementation: `cmd1 | cmd2` waits for `cmd1` to finish before `cmd2` starts in older cmd versions; modern Windows 10+ cmd does streaming. Either way, a `.cmd` script reading its own pipeline line-by-line should normalise CRLF to LF (or vice versa) at the boundary.

  • Variable syntax (set, expand, scope)

    sh (POSIX)

    `VAR=value` to set (no spaces around `=`). `$VAR` or `${VAR}` to expand. `export VAR=value` to send into child processes. Variables are stringy and globally scoped to the current shell unless `local` is used inside a function.

    cmd.exe

    `SET VAR=value` to set (no spaces around `=` either, or the spaces become part of the name / value). `%VAR%` to expand at parse time. `!VAR!` to expand at *runtime* (only after `setlocal EnableDelayedExpansion`). `SETX VAR value` writes to the persistent user environment (not the current cmd session). Scope is the whole `cmd` session unless `SETLOCAL` opens a child scope and `ENDLOCAL` closes it.

    The parse-time vs runtime distinction has no sh analogue. In `IF "%X%"=="1" SET Y=hello & ECHO %Y%`, the `%Y%` is expanded BEFORE the `IF` runs — so `%Y%` is whatever Y was *before* the line, not "hello". The fix is `!Y!` plus `setlocal EnableDelayedExpansion` at the top of the script.

  • Conditionals

    sh (POSIX)

    `if [ -f "$file" ]; then …; fi` — `[` is a command (synonym for `test`); `then` / `fi` / `elif` / `else` are keywords; the `;` before `then` is mandatory when both are on the same line. Comparison operators inside `[`: `-eq` / `-ne` / `-lt` / `-gt` (numeric), `=` / `!=` (string), `-f` / `-d` / `-z` (file / empty tests).

    cmd.exe

    `IF EXIST file.txt …`, `IF NOT EXIST file.txt …`, `IF DEFINED VAR …`, `IF "%X%"=="Y" …`, `IF /I "%X%"=="y" …` (`/I` = case-insensitive), `IF %N% EQU 0 …` / `IF %N% GTR 0 …` (numeric, using `EQU`/`NEQ`/`LSS`/`LEQ`/`GTR`/`GEQ`). Block form: `IF EXIST x.txt ( command1 & command2 )` — parentheses, not braces. `ELSE` must be on the same line as the closing `)`: `) ELSE (`. No `elif` — chain `IF` statements or use `GOTO`.

    The `IF "%X%"=="Y"` string-compare form **must quote both sides** — bare `IF %X%==Y …` fails outright when `%X%` is empty (parses as `IF ==Y` → syntax error). The double-quote-on-both-sides idiom is the safe form.

  • Loops over files / lists

    sh (POSIX)

    `for f in *.log; do echo "$f"; done` — the shell expands `*.log` into words before the loop body sees them. The variable is named with a regular identifier (`$f`).

    cmd.exe

    `FOR %%i IN (*.log) DO @ECHO %%i` *inside a `.cmd` script*. `FOR %i IN (*.log) DO @ECHO %i` *at the interactive cmd prompt*. The `%%` vs `%` difference is the most-tripped-over gotcha: cmd needs the doubled `%` in batch files to escape its own variable-expansion pass on the script.

    Other `FOR` forms have no sh equivalent: `FOR /L %%i IN (1,1,10) DO …` (numeric counter), `FOR /R %%i IN (*.txt) DO …` (recursive walk, replacing `find . -name "*.txt"`), `FOR /F "tokens=1,2 delims=," %%a IN (data.csv) DO …` (CSV / line tokeniser, replacing `awk` / `cut` for simple cases).

  • Functions / subroutines

    sh (POSIX)

    `my_fn() { echo "$1"; }` then call `my_fn arg1 arg2`. Positional args `$1`, `$2`, `$@`. Returns an integer via `return N` (0 = success).

    cmd.exe

    No functions. The closest equivalent is a labelled subroutine: define `:my_fn` lower in the script, then `CALL :my_fn arg1 arg2` to invoke it. Inside the subroutine, args are `%1`, `%2`, `%*`. Exit the subroutine with `EXIT /B 0` (the `/B` is critical — bare `EXIT` quits the entire cmd session). At end of main flow, jump past the subroutine block with `GOTO :EOF` (predefined label = end-of-file).

    Forgetting `EXIT /B` at the end of a `:label` subroutine causes execution to fall through into the *next* label's code — a silent corruption mode. The idiomatic pattern is `:my_fn` → … body … → `EXIT /B 0`, and the main script ends with `GOTO :EOF` before the first `:label`.

  • Command substitution (capture command output)

    sh (POSIX)

    `$(cmd)` or backticks `` `cmd` `` (legacy). `today=$(date +%Y-%m-%d)`. Captures stdout as a string with trailing newlines stripped. Nests freely: `echo "in $(dirname $(pwd))"`.

    cmd.exe

    No direct equivalent. Use `FOR /F`: `FOR /F "delims=" %%i IN ('date /T') DO SET today=%%i`. The `"delims="` clears the default whitespace tokeniser so the entire line is captured. Nesting is awkward — you typically write two FOR loops in sequence.

    The `FOR /F` capture pattern is the single most-needed cmd idiom for porting `.sh` installers. Memorise it: `FOR /F "delims=" %%V IN ('COMMAND') DO SET RESULT=%%V` — captures one line of output from COMMAND into the variable RESULT.

  • Arithmetic

    sh (POSIX)

    `x=$((3 + 4))`, `((x++))` (bash extension), or via `expr`: `x=$(expr 3 + 4)`. POSIX arithmetic expansion handles integers; floating-point requires `bc` or `awk`.

    cmd.exe

    `SET /A x=3+4`. The `/A` flag tells SET to evaluate the right side as an arithmetic expression — without it, x literally becomes the string `3+4`. Supports `+ - * / % & | ^ ~ << >> ( )` on 32-bit signed integers. No floating-point at all in cmd — shell out to PowerShell or a tool for that.

  • Arrays / lists

    sh (POSIX)

    No portable POSIX arrays. Bash / zsh / ksh add `arr=(a b c)` + `${arr[1]}` but pure sh has only positional parameters (`$1 $2 ...`) and the IFS-split single string trick.

    cmd.exe

    No arrays either. The cmd idiom is to encode an array as a delimited string and re-tokenise with `FOR /F "tokens=* delims=," %%i IN ("a,b,c") DO …`, or to use numbered variables: `SET items[0]=a` / `SET items[1]=b` / iterate with `FOR /L %%n IN (0,1,2) DO CALL ECHO %%items[%%n]%%`. The double-`%%` indirection is fragile.

    For anything beyond 3-4 fixed entries, ship a PowerShell script — cmd's array workarounds are the single most common reason a `.cmd` port gives up halfway and the team switches to `.ps1` mid-project.

  • Pipes, redirection, and the escape character

    sh (POSIX)

    `cmd1 | cmd2`, `cmd > out`, `cmd >> out` (append), `cmd 2>&1` (merge stderr into stdout), `cmd < in.txt`, `<< EOF … EOF` (heredoc). Escape character is backslash: `\$`, `\"`. Line continuation with trailing `\`.

    cmd.exe

    `cmd1 | cmd2`, `cmd > out`, `cmd >> out`, `cmd 2>&1` — these all work with the same syntax. **No heredoc** — feed multi-line input via a redirected file or pipe through `echo` lines. Escape character is the caret `^`: `^&`, `^|`, `^>`, `^"`. Line continuation with trailing `^` (the caret must be the last character on the line — trailing whitespace breaks it).

    A pipe character in a literal string is the most common collision: `echo cat | dog` runs `echo cat` and pipes to `dog`. To echo the literal text `cat | dog`, write `echo cat ^| dog`. The caret then collides with delayed expansion (`!`) — when both are active, you may need `^^!`.

  • PATH separator and case-sensitivity

    sh (POSIX)

    `:` separator. `export PATH="$HOME/bin:$PATH"`. POSIX file systems are case-sensitive — `./Script.sh` and `./script.sh` are different files.

    cmd.exe

    `;` separator. `SET PATH=%USERPROFILE%\bin;%PATH%`. Windows file system is case-insensitive — `script.cmd`, `Script.cmd`, `SCRIPT.CMD` all resolve to the same file. Path separator is `\` not `/` (though cmd tolerates `/` in many built-in commands and external tools).

    Hard-coding `:` as a PATH separator in a portable shell snippet breaks on Windows; hard-coding `;` breaks on Linux. If the script is run from one OS to touch the other (e.g. inside WSL writing to Windows-side files), pick a delimiter explicitly per-OS.

  • Script extension and how the OS finds the interpreter

    sh (POSIX)

    `.sh` is convention only — POSIX ignores file extensions. The shebang line `#!/bin/sh` (first line of the file) tells the kernel which interpreter to use. `chmod +x script.sh; ./script.sh` runs it.

    cmd.exe

    `.cmd` (preferred) or `.bat` (legacy MS-DOS, behavioural quirks like `ERRORLEVEL` being set differently after some commands). **No shebang** — the file extension IS the dispatch mechanism: `.cmd` and `.bat` are dispatched to `cmd.exe`. Just double-click or invoke by name — no `chmod`. Files must be CRLF-terminated; LF-only line endings cause cryptic syntax errors mid-script.

  • Exit codes

    sh (POSIX)

    `$?` holds the previous command's exit code (integer, 0 = success). Check with `cmd; if [ $? -ne 0 ]; then exit 1; fi`. Set the script's own exit code with `exit N`. POSIX `set -e` aborts on any non-zero exit.

    cmd.exe

    `%ERRORLEVEL%` holds the most recent command's exit code, **as a string** (so `IF %ERRORLEVEL% NEQ 0 …` works for comparison). The shortcut `IF ERRORLEVEL N …` is true if the exit code is N **or greater** — a subtle ordering trap. Set the script's own exit code with `EXIT /B N`. **No `set -e` equivalent** — every command's exit code must be checked manually, or the script keeps running through failures.

    The "ERRORLEVEL N matches N-or-greater" rule means a chain like `IF ERRORLEVEL 1 GOTO err1` / `IF ERRORLEVEL 2 GOTO err2` will route every code ≥ 1 to `:err1` — the order is wrong. Either descend (test 2 before 1) or use `IF %ERRORLEVEL% EQU N` for exact-match.

  • Delayed expansion (`SETLOCAL EnableDelayedExpansion`)

    sh (POSIX)

    Not a concept — variables always expand at evaluation time. `for i in 1 2 3; do x=$i; echo "$x"; done` prints 1, 2, 3 as expected.

    cmd.exe

    By default, `%VAR%` is expanded once when cmd parses the whole `IF` / `FOR` block — before any iteration runs. To get per-iteration expansion you must `SETLOCAL EnableDelayedExpansion` at the top of the script and use `!VAR!` (bangs, not percents) inside the loop. Example: `FOR %%i IN (a b c) DO ( SET X=%%i & ECHO !X! )` prints a / b / c; with `%X%` it prints whatever X was before the loop, three times.

    This is the single largest semantic gap between sh and cmd. Half of all cmd debugging time is spent realising "I needed delayed expansion here". Default-enable it: every non-trivial `.cmd` should start with `@ECHO OFF` + `SETLOCAL EnableDelayedExpansion`.

Gotchas when porting between them

  • **cmd has no shebang — script-portability is impossible without two-file shipping.** A `.sh` you `chmod +x`-ed runs on Linux, macOS, and inside Git Bash / WSL on Windows. A `.cmd` runs only on Windows. There is no single file that runs natively on both. The realistic patterns: (1) ship `install.sh` and `install.cmd` side-by-side, document both in README; (2) ship a `.ps1` (PowerShell Core / `pwsh` runs cross-platform); (3) ship a single-language alternative (Python / Node) and skip shells entirely. There is no "polyglot shebang" trick that survives cmd parsing.
  • **`IF NOT EXIST "dir\"` is sensitive to the trailing backslash on directories.** With trailing `\`, cmd checks for the directory itself; without, the same test may pass even on a missing path (cmd checks for a same-named file). The safe form for "does directory `foo` exist" is `IF NOT EXIST "foo\NUL" …` — the special `NUL` device exists in every directory by ancient DOS convention, so its presence proves the directory exists. Modern cmd accepts `IF NOT EXIST "foo\" …` but the `\NUL` trick is the most-portable historical idiom.
  • **`%~dp0` and the `%~` modifier cluster are the cmd equivalent of `$(dirname "$0")` — memorise them or be lost.** `%0` is the script's invocation; `%~0` strips surrounding quotes; `%~dp0` is the **d**rive + **p**ath of the script (always ends in `\`); `%~nx0` is the **n**ame + e**x**tension; `%~f0` is the **f**ull path. Combinable: `%~dpn1` is drive + path + name of arg 1. The full table is in `HELP CALL` — there are about 8 modifiers and you will need most of them once.
  • **Caret line-continuation (`^` at end of line) is whitespace-sensitive and collides with `!` delayed expansion.** A trailing `^` continues the line — but if there's any trailing space after the `^`, it silently does nothing and the next line is treated as a new command. Inside a block with `EnableDelayedExpansion`, the caret needs to be doubled (`^^`) when followed by `!` because the bang triggers another expansion pass. The combination of caret line-continuation + delayed expansion + nested `FOR` / `IF` is what makes long `.cmd` scripts notoriously unreadable.
  • **cmd's PATH lookup ordering is different from sh: current directory first, then `%PATH%` directories in listed order, then file extensions per `%PATHEXT%`.** `%PATHEXT%` defaults to `.COM;.EXE;.BAT;.CMD;.VBS;.JS;…` — so typing `git` may resolve to `git.com`, `git.exe`, `git.bat`, or `git.cmd` depending on what's on PATH first; typing `git.exe` is unambiguous. The current-directory-first rule is a longstanding security concern: an attacker who can drop a `git.exe` in your working directory wins. sh requires `./script` (explicit current-directory) and never auto-searches `.`. Translating a `.sh` that runs `./helper` to cmd is just `helper` — but be aware that bare `helper` in cmd may run a different program than expected.

Full references

Other comparisons