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

  1. The shell (bash, zsh, PowerShell, etc.) splits the line you typed into tokens, respecting quotes.
  2. The operating system starts the node process and hands it that full token list.
  3. Before your module's code runs, Node populates the global process.argv array 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.
  4. Your code runs synchronously from the top. If you call process.argv.slice(2) or util.parseArgs(...), that happens immediately and synchronously — there is no event loop, no I/O, and no async step involved in reading arguments themselves.
  5. 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 raw process.argv indices past index 1.
  • Prefer node:util's parseArgs for 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 commander or yargs rather 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 on stderr (via console.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 in process.argv; use process.execArgv if you need to inspect those.
  • When forwarding arguments through an npm script, use the -- separator, e.g. npm run build -- --watch, so --watch reaches your script instead of being interpreted by npm.
  • 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.js that reads a single positional argument (a name) from process.argv and prints Hello, <name>!. If no name is given, print a usage message to stderr and exit with status 1.
  • Using node:util's parseArgs, build a script that accepts --width and --height as string options (with defaults of "10" and "5"), converts them to numbers, and prints their product. Add a --help/-h boolean flag that prints usage instructions and exits before doing any calculation.
  • Extend the cli-copy.js example from this lesson so that it also accepts a --verbose/-v flag; when set, it should log the resolved source and destination paths before copying, using node:path's resolve().

Summary

  • process.argv is 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=value arrives as one unsplit string; you must parse it yourself or with a library.
  • node:util's parseArgs is the built-in, dependency-free way to turn raw arguments into typed values and positionals, 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 commander or yargs are the practical next step beyond the built-ins.