Command Line Arguments
Every Node.js program can read the words typed after node on the command line — the script name, flags like --verbose, and positional values like a filename. Node stores these in a global array called process.argv, and gives you a built-in parser, util.parseArgs, for turning them into a structured options object. Command line arguments are how you make a script configurable without editing its source every time, and they are the foundation of every CLI tool you will ever build in Node — from a one-off migration script to a full-blown command like npm itself.
Overview / How it works
process is a global object — you never require it — and one of its most-used properties is argv, short for “argument vector”. Before your script’s first line even runs, the underlying operating system passes the full list of words from the command line to the Node executable, and Node (via its embedded V8 and libuv layer) populates process.argv as a plain array of strings, in the exact order they were typed. Nothing is parsed or interpreted for you — no flags are recognized, no types are inferred, no = is split. It is raw text, exactly as the shell delivered it.
There are two entries you always get for free, followed by whatever the user typed:
process.argv[0]— the absolute path to the Node.js executable that is running your script (e.g./usr/local/bin/node).process.argv[1]— the absolute path to the JavaScript file being executed (e.g./home/user/app.js).process.argv[2]and beyond — every additional word the user typed, one array element per space-separated token.
This is different from process.env, which holds environment variables set outside the invocation (like PORT=3000 node app.js). Arguments are explicit, positional, and typed by the user at the moment the program is launched; environment variables are ambient configuration. Node also exposes process.argv0, which is the argument the operating system was told to use as argument zero — this is almost always identical to process.argv[0] and rarely differs in practice, and process.execPath, which reliably gives you the Node binary’s real path if you need it.
It matters how the shell — bash, zsh, PowerShell, whatever launched the process — splits your typed line into tokens before Node ever sees it. Unquoted spaces separate arguments; quoting groups them. Typing node app.js --name John Doe produces three separate array entries (--name, John, Doe), while node app.js --name "John Doe" produces one (John Doe) attached as a single token after --name. This is a shell-level behavior, not a Node one, but it’s the source of many confusing bugs when a value with a space in it silently splits into two arguments.
Node itself can also take its own flags before the script name — things like node --inspect app.js or node --env-file=.env app.js. Those are consumed by Node itself and do not appear in process.argv; if you need to inspect the flags passed to the Node binary specifically, use process.execArgv instead.
Syntax
The general shape of process.argv looks like this:
process.argv[0] // path to the node executable
process.argv[1] // path to the script being run
process.argv[2] // first user-supplied argument
process.argv[3] // second user-supplied argument
// ...and so on, one entry per token
Since the first two entries are almost never useful to you, the standard pattern is to work with process.argv.slice(2), which gives you just the user-supplied arguments as a plain array. For real parsing (flags with values, short aliases, boolean switches), Node’s built-in node:util module ships parseArgs (stable since Node 20), which takes an options schema and returns structured values:
const { values, positionals } = require("node:util").parseArgs({
args: process.argv.slice(2),
options: {
exampleOption: { type: "string", short: "e", default: "value" },
},
allowPositionals: true,
strict: true,
});
| parseArgs option | Purpose |
|---|---|
args |
The array of raw arguments to parse — normally process.argv.slice(2). |
options.<name>.type |
"string" or "boolean" — the value type for that flag. |
options.<name>.short |
A single-character alias, e.g. -p for --port. |
options.<name>.default |
Value used when the flag is not passed. |
options.<name>.multiple |
If true, repeated flags collect into an array. |
allowPositionals |
If true, non-flag arguments are collected into positionals instead of throwing. |
strict |
If true (the default), an unknown flag throws instead of being silently ignored. |
Examples
Example 1: Inspecting the raw argv array
console.log("node binary path (argv0):", process.argv0);
console.log("full argv array:", process.argv);
Run with:
node argv-basic.js deploy --env=production --force
Output:
node binary path (argv0): node
full argv array: [
'/usr/local/bin/node',
'/home/user/argv-basic.js',
'deploy',
'--env=production',
'--force'
]
Notice that --env=production comes through as a single unparsed string — Node did not split it into a key and value for you. That is the whole reason a parsing step exists.
Example 2: Writing your own minimal parser
For simple scripts, a hand-rolled parser is often all you need. This one turns --key=value pairs into object properties, treats a bare --flag as boolean true, and collects everything else as a positional argument:
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith("--")) {
const [key, value] = arg.slice(2).split("=");
args[key] = value === undefined ? true : value;
} else {
args._.push(arg);
}
}
return args;
}
const options = parseArgs(process.argv.slice(2));
console.log(options);
Run with:
node parse-args.js build --env=production --verbose
Output:
{ _: [ 'build' ], env: 'production', verbose: true }
This works, but it has no idea what a valid flag is, does no type coercion beyond strings and true, and silently accepts typos like --vebose. That is exactly the gap util.parseArgs closes.
Example 3: A realistic CLI tool with util.parseArgs
Here is a small, complete command-line file-copy tool that validates its inputs, supports a --force flag, and exits with a non-zero status on failure — the shape of a real CLI utility:
const fs = require("node:fs/promises");
const { existsSync } = require("node:fs");
const { parseArgs } = require("node:util");
async function main() {
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
force: { type: "boolean", short: "f", default: false },
},
allowPositionals: true,
});
const [source, destination] = positionals;
if (!source || !destination) {
console.error("Usage: node cli-copy.js <source> <destination> [--force]");
process.exit(1);
}
if (existsSync(destination) && !values.force) {
console.error(`${destination} already exists. Use --force to overwrite.`);
process.exit(1);
}
try {
await fs.copyFile(source, destination);
console.log(`Copied ${source} -> ${destination}`);
} catch (err) {
console.error("Copy failed:", err.message);
process.exit(1);
}
}
main();
Run with:
node cli-copy.js notes.txt backup/notes.txt --force
Output:
Copied notes.txt -> backup/notes.txt
parseArgs splits the input into values (the declared --force/-f flag) and positionals (the source and destination paths), so the rest of the function reads like normal, typed application code instead of manual string-splitting. Because fs.copyFile is from the promise-based node:fs/promises API, the whole flow stays inside a single async function with ordinary try/catch error handling.
How it works step by step
- The shell (bash, zsh, PowerShell, etc.) splits the line you typed into tokens, respecting quotes.
- The operating system starts the
nodeprocess and hands it that full token list. - Before your module's code runs, Node populates the global
process.argvarray from that list — index 0 is the Node executable, index 1 is your script's resolved path, and everything after is what the user typed. - Your code runs synchronously from the top. If you call
process.argv.slice(2)orutil.parseArgs(...), that happens immediately and synchronously — there is no event loop, no I/O, and no async step involved in reading arguments themselves. - If you exit early with
process.exit(1)(for example, on missing required arguments), Node terminates the process right there, skipping any code — and any pending asynchronous work, like unflushed writes — that comes after it.
Common Mistakes
Mistake 1: Forgetting that index 2, not index 0 or 1, is where user arguments start
It's easy to assume the first array index holds the first real argument. It doesn't — indices 0 and 1 are always the Node path and script path.
Wrong:
const target = process.argv[1];
console.log(`Copying to ${target}`);
This logs the path to your own script file, not the argument the user typed. Corrected:
const target = process.argv[2];
console.log(`Copying to ${target}`);
Mistake 2: Not validating required arguments before using them
If an argument is missing, destructuring it out of process.argv just gives you undefined — Node won't stop you from passing that straight into a function that expects a real path, and you'll get a confusing crash several lines away from the actual problem.
Wrong:
const [source, destination] = process.argv.slice(2);
fs.copyFileSync(source, destination);
console.log(`Copied ${source} to ${destination}`);
If the user runs the script with zero or one argument, this throws a low-level TypeError from deep inside fs.copyFileSync instead of a clear usage message. Corrected:
const [source, destination] = process.argv.slice(2);
if (!source || !destination) {
console.error("Usage: node copy.js <source> <destination>");
process.exit(1);
}
fs.copyFileSync(source, destination);
console.log(`Copied ${source} to ${destination}`);
Two more pitfalls worth knowing: assuming Node parses --flag=value into a key on process.argv itself (it never does — process.argv is always an array of raw strings, never an object), and forgetting that unquoted values containing spaces get split into multiple array entries by the shell before your script ever runs.
Best Practices
- Always slice off the first two entries — work with
process.argv.slice(2)or a parser built on top of it, never rawprocess.argvindices past index 1. - Prefer
node:util'sparseArgsfor anything beyond a couple of ad-hoc flags — it gives you typed values, short aliases, defaults, and validation without a dependency. - For complex CLIs with subcommands, help text generation, and rich validation, reach for a well-known package like
commanderoryargsrather than growing your own parser indefinitely. - Validate required arguments up front and exit with a non-zero
process.exit(1)and a clear usage message onstderr(viaconsole.error) — never let a missing argument surface as a cryptic crash later. - Remember that flags before your script name (like
node --env-file=.env app.js) are consumed by Node itself and never appear inprocess.argv; useprocess.execArgvif you need to inspect those. - When forwarding arguments through an
npmscript, use the--separator, e.g.npm run build -- --watch, so--watchreaches your script instead of being interpreted bynpm. - Quote any argument value that might contain spaces (
--name="John Doe") — the shell splits on unquoted spaces before Node ever sees the input.
Practice Exercises
- Write a script
greet.jsthat reads a single positional argument (a name) fromprocess.argvand printsHello, <name>!. If no name is given, print a usage message tostderrand exit with status1. - Using
node:util'sparseArgs, build a script that accepts--widthand--heightas string options (with defaults of"10"and"5"), converts them to numbers, and prints their product. Add a--help/-hboolean flag that prints usage instructions and exits before doing any calculation. - Extend the
cli-copy.jsexample from this lesson so that it also accepts a--verbose/-vflag; when set, it should log the resolved source and destination paths before copying, usingnode:path'sresolve().
Summary
process.argvis a global array: index 0 is the Node executable, index 1 is the script path, and index 2 onward are the user's raw arguments as strings.- Node does no flag parsing on its own —
--key=valuearrives as one unsplit string; you must parse it yourself or with a library. node:util'sparseArgsis the built-in, dependency-free way to turn raw arguments into typedvaluesandpositionals, with support for short aliases and defaults.- Always validate required arguments and exit with a clear error message and non-zero status rather than letting missing input crash deeper in your program.
- Node-level flags (before the script name) and script arguments (after it) are different things — the former never appear in
process.argv. - For real CLI tools with subcommands and generated help text, packages like
commanderoryargsare the practical next step beyond the built-ins.
