execa vs zx vs shelljs: Running Shell Commands from Node.js (2026)
TL;DR
execa is the best subprocess library for Node.js — it wraps child_process.spawn with promises, streaming, piping, and excellent error handling. zx is Google's scripting toolkit — template literal syntax ($\command`), automatic shell escaping, and batteries-included utilities (fetch, globbing, YAML parsing) for scripting workflows. **shelljs** is the synchronous, Unix-command-on-Windows option — shell.ls(), shell.cp(), shell.mv()`, making shell scripts portable without a real shell. For programmatic subprocess management: execa. For scripting and automation: zx. For cross-platform shell commands in Node.js code: shelljs.
Key Takeaways
- execa: ~70M weekly downloads — best subprocess API, streaming, pipes, TypeScript-native
- zx: ~6M weekly downloads — scripting toolkit, template literals, async-first
- shelljs: ~40M weekly downloads — synchronous, cross-platform Unix commands
- Node.js 22+ has
spawn()improvements andutil.parseArgs()— but execa is still superior - zx v8+ is ESM-native — use
#!/usr/bin/env zxshebang for script files - shelljs syncs the current directory —
shell.cd()affects subsequent calls
Download Trends
| Package | Weekly Downloads | API Style | Streaming | TypeScript | Cross-platform |
|---|---|---|---|---|---|
execa | ~70M | Promise/async | ✅ | ✅ Native | ✅ |
zx | ~6M | Template literal | ✅ | ✅ | ✅ |
shelljs | ~40M | Synchronous | ❌ | ✅ @types | ✅ Excellent |
execa
execa — the definitive Node.js subprocess library:
Basic usage
import { execa } from "execa"
// Run a command and get output:
const result = await execa("git", ["log", "--oneline", "-5"])
console.log(result.stdout)
// "abc1234 feat: add package comparison\n8def567 fix: health score..."
// With options:
const { stdout } = await execa("npm", ["list", "--json"], {
cwd: "/path/to/project",
env: { ...process.env, NODE_ENV: "production" },
})
// Short-form (no args):
const { stdout: files } = await execa("ls")
Error handling
import { execa, ExecaError } from "execa"
try {
await execa("git", ["push", "--force", "origin", "main"])
} catch (err) {
if (err instanceof ExecaError) {
console.error("Exit code:", err.exitCode)
console.error("Stderr:", err.stderr)
console.error("Command:", err.command)
// err.killed — was it killed by a signal?
// err.signal — signal name if killed
// err.timedOut — did it timeout?
}
}
// Or allow non-zero exit codes:
const result = await execa("npm", ["test"], { reject: false })
if (result.exitCode !== 0) {
console.log("Tests failed:", result.stderr)
}
Streaming output
import { execa } from "execa"
// Stream stdout as it comes (long-running process):
const subprocess = execa("npm", ["run", "build"])
subprocess.stdout!.on("data", (chunk) => {
process.stdout.write(chunk) // Real-time build output
})
subprocess.stderr!.on("data", (chunk) => {
process.stderr.write(chunk)
})
await subprocess // Wait for completion
// Or inherit — pipe directly to parent's stdio:
await execa("npm", ["run", "build"], { stdio: "inherit" })
// Now all output goes directly to terminal — same as running npm run build yourself
Piping commands
import { execa } from "execa"
// Pipe output of one command into another:
const gitLog = execa("git", ["log", "--format=%s"])
const grepFeat = execa("grep", ["feat:"])
gitLog.stdout!.pipe(grepFeat.stdin!)
const result = await grepFeat
console.log(result.stdout) // Only commits with "feat:" in the message
// Or use the pipe helper (execa v8+):
const { stdout } = await execa("git", ["log", "--format=%s"]).pipe(
execa("grep", ["feat:"])
)
Scripts with multiple commands
import { execa } from "execa"
async function publishPackage(version: string) {
console.log("Running tests...")
await execa("npm", ["test"], { stdio: "inherit" })
console.log("Building...")
await execa("npm", ["run", "build"], { stdio: "inherit" })
console.log("Bumping version...")
await execa("npm", ["version", version])
console.log("Publishing...")
await execa("npm", ["publish", "--access", "public"], { stdio: "inherit" })
console.log("Pushing tags...")
await execa("git", ["push", "--follow-tags"])
}
execaNode — run Node.js scripts
import { execaNode } from "execa"
// Run another Node.js script with proper inter-process communication:
const result = await execaNode("scripts/generate-sitemap.js", ["--all"])
console.log(result.stdout)
// With IPC for message passing:
const worker = execaNode("worker.js", { ipc: true })
worker.on("message", (msg) => console.log("Worker says:", msg))
worker.sendMessage({ command: "start", data: { packages: ["react", "vue"] } })
zx
zx — Google's JavaScript scripting toolkit:
Script files
#!/usr/bin/env zx
// scripts/deploy.mjs
// zx provides $ for shell commands, plus built-in fetch, globbing, YAML, etc.
// Run commands with template literals:
await $`git status`
await $`npm test`
await $`npm run build`
// Variables are automatically shell-escaped:
const branch = "feature/my-feature"
await $`git checkout -b ${branch}` // Safe — branch name is escaped
// → git checkout -b "feature/my-feature"
// Get output:
const version = await $`node --version`
console.log(`Node version: ${version.stdout.trim()}`) // "v22.13.0"
Built-in utilities
#!/usr/bin/env zx
// Built-in fetch:
const response = await fetch("https://registry.npmjs.org/react/latest")
const data = await response.json()
console.log(`Latest react: ${data.version}`)
// Built-in glob:
const tsFiles = await glob("src/**/*.ts")
console.log(`Found ${tsFiles.length} TypeScript files`)
// Built-in YAML:
const config = YAML.parse(await fs.readFile("config.yml", "utf8"))
// Built-in question (interactive prompt):
const answer = await question("Deploy to production? [y/n] ")
if (answer !== "y") process.exit(0)
// Environment variables:
const { HOME, PATH } = process.env
Error handling and quiet mode
#!/usr/bin/env zx
// Commands fail loudly by default:
try {
await $`git push origin main`
} catch (err) {
console.error(`Exit code: ${err.exitCode}`)
console.error(`Stderr: ${err.stderr}`)
}
// Or nothrow:
const result = await $`git status`.nothrow()
if (result.exitCode !== 0) {
console.log("Working directory not clean")
}
// Quiet mode — suppress output:
$.quiet = true
const version = await $`npm --version`
$.quiet = false
// Or inline:
const gitStatus = await $`git status`.quiet()
Use in Node.js scripts (not just shell scripts)
import { $ } from "zx"
// zx works fine in regular .mjs scripts:
async function buildAndDeploy() {
await $`npm run build`
const tag = (await $`git describe --tags --abbrev=0`).stdout.trim()
console.log(`Deploying version ${tag}...`)
await $`docker build -t myapp:${tag} .`
await $`docker push myapp:${tag}`
await $`kubectl set image deployment/myapp app=myapp:${tag}`
}
buildAndDeploy().catch(console.error)
shelljs
shelljs — Unix shell commands in Node.js (cross-platform):
Basic commands
import shell from "shelljs"
// Directory operations:
shell.mkdir("-p", "dist/assets")
shell.cd("dist")
shell.pwd() // "/path/to/project/dist"
// File operations:
shell.cp("-r", "src/", "backup/")
shell.mv("old-name.js", "new-name.js")
shell.rm("-rf", "temp/")
shell.ls("src/") // ShellArray of file names
// Find files:
const jsFiles = shell.find("src/").filter((f) => f.endsWith(".js"))
// Test conditions:
if (shell.test("-f", ".env")) {
console.log(".env file exists")
}
if (shell.test("-d", "dist")) {
console.log("dist directory exists")
}
Run commands
import shell from "shelljs"
// Run a command:
const result = shell.exec("npm list --json", { silent: true })
if (result.code !== 0) {
console.error("Failed:", result.stderr)
}
const deps = JSON.parse(result.stdout)
// Cross-platform compatibility — works on Windows without WSL:
shell.exec("npm install")
shell.exec("node scripts/build.js")
// Check if commands are available:
if (!shell.which("docker")) {
console.error("Docker is required but not installed")
process.exit(1)
}
Environment and config
import shell from "shelljs"
// Configuration:
shell.config.silent = true // Suppress all output
shell.config.fatal = true // Exit on first error (like set -e in bash)
shell.config.verbose = false // Don't echo commands
// Environment variables:
shell.env["NODE_ENV"] = "production"
// String operations:
const content = shell.cat("package.json")
shell.echo("Build complete").to("build.log")
// sed-like replacement:
shell.sed("-i", /VERSION/, "1.2.3", "config.js")
// grep:
const matches = shell.grep("react", "package.json")
Feature Comparison
| Feature | execa | zx | shelljs |
|---|---|---|---|
| API style | Promise/async | Template literal | Synchronous |
| Streaming | ✅ Excellent | ✅ | ❌ |
| Piping | ✅ | ✅ | ❌ |
| TypeScript | ✅ Native | ✅ | ✅ @types |
| Shell escaping | ✅ Automatic | ✅ Automatic | ⚠️ Manual |
| Cross-platform | ✅ | ✅ | ✅ Excellent |
| Built-in fetch | ❌ | ✅ | ❌ |
| Built-in globbing | ❌ | ✅ | ✅ find |
| File operations | ❌ | ❌ | ✅ cp/mv/rm/mkdir |
| Interactive prompts | ❌ | ✅ question() | ❌ |
| IPC (Node.js) | ✅ execaNode | ❌ | ❌ |
| Error details | ✅ Excellent | ✅ | ⚠️ Basic |
When to Use Each
Choose execa if:
- You need programmatic subprocess control (spawning, killing, streaming)
- Building tools, CLIs, or libraries that spawn child processes
- You need pipe chains or IPC communication with Node.js workers
- TypeScript-native types and excellent error messages matter
Choose zx if:
- Writing automation scripts (deployment, CI/CD, release scripts)
- You like the template literal syntax for shell commands
- You want built-in fetch, globbing, and YAML without extra imports
- Converting bash scripts to JavaScript
Choose shelljs if:
- You need cross-platform file operations (copy, move, delete, mkdir)
- Your team isn't comfortable with async/await in scripts
- Building scripts that need to run on Windows without WSL
- Sequential synchronous scripting where blocking is acceptable
Use Node.js built-ins directly if:
- Simple
execorspawn— no need for execa for single commands fs.copyFile,fs.mkdir,fs.renamefor file operations — no shelljs neededutil.parseArgs()for script argument parsing (Node.js 22+)
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on execa v9.x, zx v8.x, and shelljs v0.8.x.
Compare automation and developer tool packages on PkgPulse →