Skip to main content

execa vs zx vs shelljs: Running Shell Commands from Node.js (2026)

·PkgPulse Team

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 and util.parseArgs() — but execa is still superior
  • zx v8+ is ESM-native — use #!/usr/bin/env zx shebang for script files
  • shelljs syncs the current directory — shell.cd() affects subsequent calls

PackageWeekly DownloadsAPI StyleStreamingTypeScriptCross-platform
execa~70MPromise/async✅ Native
zx~6MTemplate literal
shelljs~40MSynchronous✅ @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

Featureexecazxshelljs
API stylePromise/asyncTemplate literalSynchronous
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 exec or spawn — no need for execa for single commands
  • fs.copyFile, fs.mkdir, fs.rename for file operations — no shelljs needed
  • util.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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.