Zsh vs cmd.exe
Default macOS shell vs default Windows batch shell — the porting context when distributing Mac-authored scripts to Windows users.
Summary
Zsh has been the default macOS shell since Catalina (2019) — POSIX-extended, runs most bash scripts unchanged, with smarter completion, glob qualifiers, and 1-indexed arrays. Word-splitting on `$var` is off by default, which quietly fixes one of bash's most common bugs.
cmd.exe is Windows' legacy text shell — a direct descendant of MS-DOS COMMAND.COM, present on every Windows install since NT 3.1 (1993). Pipes carry bytes in the **OEM codepage**, variables expand at **parse time** unless you opt into delayed expansion, and the language has barely changed in 25 years.
Practical scenario: a macOS developer who writes zsh interactively but must ship a `.bat` file alongside their tool for Windows users who don't have WSL. Almost nothing from zsh ports unchanged — the operators, the parse-time expansion, the encoding, and the absence of native arrays all need rethinking from scratch.
Syntax & semantic differences
Pipe contents
ZshBytes of text, UTF-8 by default on macOS / Linux. Next command parses lines itself with `grep` / `awk` / `sed`.
cmd.exeBytes of text, but in the **OEM codepage** (cp437 / cp850 on US Windows) unless you `chcp 65001`. Legacy console tools may still misbehave even after the switch.
Piping zsh's UTF-8 output into a `.bat` file via a shared file (e.g. a generated `.csv`) is the #1 source of mojibake bugs when delivering Mac-authored data to Windows users.
POSIX compatibility
ZshPOSIX-extended. Most bash scripts run unchanged. `emulate sh` or `emulate bash` at the top of a script for the closest thing to a clean port.
cmd.exeNo POSIX compatibility. Bash / zsh scripts must be rewritten from scratch — different operators, different control flow, different built-ins.
Variable assignment
Zsh`name=value` (no spaces around `=`). `export name=value` to put it in the child-process environment.
cmd.exe`set NAME=value` (no spaces around `=` — trailing spaces silently become part of the value). `set "NAME=value"` is the safe quoted form. Always inherited by children.
Reading variables
Zsh`$name` or `${name}`, with full POSIX parameter expansion (`${name:-default}`, `${name#prefix}`, `${name%suffix}`).
cmd.exe`%NAME%` for normal expansion. Delayed expansion `!NAME!` only after `setlocal enabledelayedexpansion`. No defaults, no prefix/suffix stripping.
Variable scope in loops
ZshNormal lexical scope. `for x in *.log; do count=$((count+1)); done` accumulates as written.
cmd.exeVariables expand at **parse time**. `for %%f in (*.log) do set /a count=%count%+1` does not accumulate unless you enable delayed expansion (`setlocal enabledelayedexpansion` + `!count!`).
Arrays
ZshNative arrays, **1-indexed**. `arr=(a b c); echo $arr[1]` prints `a`. `${#arr[@]}` for length. Associative arrays with `typeset -A`.
cmd.exeNo native arrays. Parse text with `for /f` or use space-separated strings and hope filenames have no spaces.
Conditional
Zsh`if [[ -f file ]]; then ...; fi`. `[[ ]]` is the zsh test built-in; supports `=~` for regex, `<` / `>` for string compare.
cmd.exe`if exist file (...) else (...)`. Operators are keywords: `exist`, `defined`, `equ`, `lss`, `gtr`. Whitespace and parenthesis placement is strict — `if exist file(...)` (no space) is a parse error.
Loops
Zsh`for f in *.log; do echo $f; done` — terminator is `done`. Glob expansion happens shell-side before the loop runs.
cmd.exe`for %f in (*.log) do @echo %f` interactively, **but** `for %%f in (*.log) do @echo %%f` inside a `.bat` file. Doubled-`%%` is the #1 batch gotcha.
Glob qualifiers
ZshRich qualifiers: `*(.)` (regular files), `*(/)` (directories), `*(.m-1)` (modified < 1 day), `*(.L+1M)` (> 1 MiB), `*(om[1,5])` (5 newest). Zsh's headline feature.
cmd.exeNo qualifiers. Use `dir /A:-D *.log` for "files only", `forfiles /M *.log /D -1` for "modified before yesterday", and parse the text output yourself.
Command substitution
Zsh`$(cmd)` (preferred) or backticks (legacy). Output captured cleanly: `today=$(date +%F)`.
cmd.exeAwkward: `for /f "delims=" %i in ('cmd') do set RESULT=%i` interactively, or `%%i` in a `.bat` file. No clean inline form.
Functions / subroutines
Zsh`my_fn() { echo $1 }`. Args are `$1`, `$2`, `$@`. `local var` for function-scoped vars.
cmd.exe`:label` blocks called with `call :label arg1 arg2`. Args are `%1`, `%2`. `%~1` strips quotes. `goto :eof` returns. No local variables — `setlocal` / `endlocal` is the workaround.
Arithmetic
Zsh`$((1 + 2))` is built-in. Integer-only by default; floating-point via `bc` or `python -c`.
cmd.exe`set /a x=1+2` — integer-only. Hex with `0x`, octal with a leading `0`. No floating-point at all.
Logical operators
Zsh`cmd1 && cmd2`, `cmd1 || cmd2`, `cmd1 ; cmd2` (unconditional). Standard.
cmd.exe`cmd1 && cmd2`, `cmd1 || cmd2` on every modern Windows. `&` is the unconditional separator. Escape with `^&`, `^|` outside quotes.
Cross-platform reach
ZshmacOS / Linux / BSD / WSL. The de facto interactive default on macOS; opt-in elsewhere.
cmd.exeWindows-only. Will never run anywhere else.
Side-by-side commands
The 32 most-compared commands in Zsh and cmd.exe. See all Zsh commands · See all cmd.exe commands.
- aliasCreate a shortcut name for a longer command line.Zsh
alias ll='ls -la'cmd.exedoskey ll=dir /a $* - awkPattern scanning and processing language for structured text.Zsh
awk '{print $1}' filecmd.exefor /f "tokens=1" %a in (file) do @echo %a - catPrint file contents to standard output.Zsh
cat file.txtcmd.exetype file.txt - chmodChange file mode bits (read / write / execute permissions) on Unix files.Zsh
chmod 755 filecmd.exeicacls file /grant Users:(RX) - cpCopy files and directories.Zsh
cp source destcmd.execopy source dest - curlTransfer data from or to a server over HTTP, HTTPS, FTP, and many other protocols.Zsh
curl https://example.comcmd.execurl https://example.com - cutExtract sections (fields or characters) from each line.Zsh
cut -d',' -f1 file.csvcmd.exefor /f "tokens=1 delims=," %a in (file.csv) do @echo %a - echoPrint arguments to standard output, separated by spaces, followed by a newline.Zsh
echo "hello world"cmd.exeecho hello world - exportSet an environment variable and mark it for export to child processes.Zsh
export NAME=valuecmd.exeset NAME=value - findLocate files by name, size, time, or other attributes.Zsh
find . -name "*.log"cmd.exedir /s /b *.log - grepSearch file contents for a pattern.Zsh
grep -r "pattern" .cmd.exefindstr /S /I "pattern" * - headOutput the first lines of a file.Zsh
head -n 10 filecmd.exepowershell -Command "Get-Content file -TotalCount 10" - historyShow previously run commands from the shell history.Zsh
historycmd.exedoskey /history - killSend a signal to a process (typically to terminate it).Zsh
kill <pid>cmd.exetaskkill /pid <pid> - lsList directory contents.Zsh
ls -lacmd.exedir /a - mkdirCreate a new directory.Zsh
mkdir dircmd.exemkdir dir - mvMove or rename files and directories.Zsh
mv source destcmd.exemove source dest - pingSend ICMP echo requests to test reachability and round-trip latency.Zsh
ping example.comcmd.exeping example.com - psList a snapshot of currently running processes.Zsh
ps auxcmd.exetasklist - rmDelete files and directories.Zsh
rm filecmd.exedel file - sedStream editor for filtering and transforming text.Zsh
sed 's/old/new/g' filecmd.exepowershell -Command "(Get-Content file) -replace 'old','new'" - sortSort lines of text.Zsh
sort filecmd.exesort file - sshOpen a secure shell on a remote host or run a remote command.Zsh
ssh user@hostcmd.exessh user@host - tailOutput the last lines of a file.Zsh
tail -n 10 filecmd.exepowershell -Command "Get-Content file -Tail 10" - tarBundle and unbundle files into a single archive (often combined with gzip / bzip2 / xz).Zsh
tar -czf archive.tar.gz dir/cmd.exetar -czf archive.tar.gz dir - topInteractive process viewer — show running processes sorted by CPU or memory, refreshed in place.Zsh
topcmd.exetasklist - touchCreate an empty file or update its modification time if it exists.Zsh
touch file.txtcmd.exetype nul > file.txt - wcCount lines, words, or bytes.Zsh
wc -l filecmd.exefind /c /v "" file - wgetNon-interactive network downloader for HTTP, HTTPS, and FTP.Zsh
wget https://example.com/file.zipcmd.execurl -O https://example.com/file.zip - whichLocate an executable in PATH and print its full path.Zsh
which python3cmd.exewhere python3 - xargsBuild and execute command lines from standard input.Zsh
find . -name '*.tmp' | xargs rmcmd.exefor /f "delims=" %f in ('dir /s /b *.tmp') do del "%f" - zipPackage and compress files into a ZIP archive.Zsh
zip -r archive.zip dir/cmd.exetar -a -cf archive.zip dir
Gotchas when porting between them
- Distributing a `.bat` file to Windows users from a macOS dev box: Git's `core.autocrlf=input` (the macOS default) leaves the `.bat` file with LF endings, which **older Windows** parses incorrectly. Set `core.autocrlf=true` for repos that ship batch files, or commit them with `* text eol=crlf` in `.gitattributes`.
- Encoding mismatch: zsh on macOS writes UTF-8; cmd writes cp437 / cp850 by default. A `.csv` generated on macOS and opened with `type file.csv` on Windows shows mojibake for any non-ASCII content. Either add a UTF-8 BOM, ship `chcp 65001 &` at the top of the batch wrapper, or instruct users to open the file in a UTF-8-aware editor.
- Path separators in literal strings: zsh uses `/`, cmd uses `\`. Hardcoded `/usr/local/bin/...` paths in a zsh script have no Windows analog. On the Windows side, use `%~dp0` (the directory the `.bat` lives in) rather than absolute paths.
- Glob no-match behavior is opposite: zsh **errors out** on `ls *.nope` ("no matches found") by default unless `setopt NULL_GLOB`; cmd just passes the literal `*.nope` to the command, which then handles it however it likes (`dir *.nope` says "File Not Found"; `echo *.nope` prints `*.nope`).
- zsh aliases support inline arguments (`alias ll='ls -la'`); cmd has `doskey` macros which can too (`doskey ll=dir /a $*`), **but** doskey only works in interactive sessions — it is not read by `.bat` files. Aliases written in `~/.zshrc` simply do not exist on the Windows side.