os, path & URL

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. Three small built-in modules answer the questions every application asks about its surroundings: how many CPUs and what kind of machine am I on (node:os), where is this file and how do I build a path to another one (node:path), and what does this address mean (the WHATWG URL, URLSearchParams and URLPattern classes, plus node:url helpers such as fileURLToPath()). None of them touches the network or the disk: they compute on strings and read a few environment variables, so they are cheap, synchronous and safe to call anywhere. Their mistakes are not cheap, though. Path handling is where directory traversal bugs are born, and URL handling is where host-spoofing and open-redirect bugs are born, so this page spends as much time on the safe patterns as on the API tour. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture. Reading and writing the files that these paths point to is the subject of File System, and the HTTP server that consumes the URLs is built in HTTP Server Fundamentals.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

node:os (availableParallelism(), cpus(), homedir(), tmpdir(), EOL, constants, devNull)

2 Stable

availableParallelism() added in v19.4.0 and v18.14.0; devNull in v16.3.0 and v14.18.0; tmpdir() stopped returning a trailing slash in v2.0.0.

node:path (join(), resolve(), relative(), parse(), format(), posix, win32)

2 Stable

format() adds the missing dot to ext since v19.0.0.

path.matchesGlob()

2 Stable

Added in v22.5.0 and v20.17.0; marked stable in v24.8.0 and v22.20.0 (experimental before).

URL, URLSearchParams (global classes), URL.canParse(), URL.parse()

2 Stable (node:url)

Classes global since v10.0.0; URL.canParse() since v19.9.0 and v18.17.0; URL.parse() since v22.1.0; URLSearchParams.size since v19.8.0; the optional value argument of has() and delete() since v20.2.0.

URLPattern

1 Experimental

Class added in v23.8.0, global since v24.0.0. It is not defined on Node.js 22 without a flag (measured: typeof URLPattern is 'function' on 26 and 24, 'undefined' on 22).

url.fileURLToPath(), url.pathToFileURL()

2 Stable

The windows option since v22.1.0 and v20.13.0; url.fileURLToPathBuffer() since v24.3.0 and v22.18.0.

url.parse(), url.format(urlObject), url.resolve(), legacy urlObject

3 Legacy module section; the three functions are 0 Deprecated

url.parse() is an application deprecation (DEP0169, non-node_modules code only) since v24.0.0.

node:querystring

2 Stable

Not deprecated; the documentation recommends URLSearchParams unless you need its speed.

node:os: the machine you run on

node:os exposes facts about the operating system and the hardware. Most functions are thin wrappers around libuv or around the C library, so the values are the ones the kernel reports, not something Node.js computes. Two of them deserve a decision rule; the rest are lookups.

Table 2. The os functions and properties used most often
API What it returns Use it for

os.availableParallelism()

An estimate of the default amount of parallelism a program should use; always greater than zero. A wrapper around libuv’s uv_available_parallelism().

Sizing worker pools and cluster forks.

os.cpus()

One object per logical CPU with model, speed (MHz) and cumulative times in milliseconds (user, nice, sys, idle, irq). The array is empty when the information is unavailable, for example when /proc is missing.

Reporting hardware and computing CPU load from two samples. Not for sizing pools.

os.homedir()

The home directory. On POSIX it uses $HOME if defined, otherwise it looks up the effective user; on Windows it uses USERPROFILE if defined.

Per-user config and caches. Not a trust boundary: the environment variable wins.

os.userInfo()

username, uid, gid, shell, homedir from the operating system itself (uid and gid are -1 on Windows). Its homedir ignores $HOME.

When you need the account’s real home regardless of the environment.

os.tmpdir()

The default directory for temporary files, with no trailing slash. On POSIX it checks TMPDIR, TMP and TEMP in that order and falls back to /tmp; on Windows TEMP wins over TMP.

Scratch files; combine with fs.mkdtemp() for a private directory.

os.EOL

"\n" on POSIX, "\r\n" on Windows.

Writing text files meant for people on the current platform.

os.devNull

/dev/null on POSIX, \\.\nul on Windows.

Discarding output of a child process portably.

os.constants

Objects signals, errno, priority and dlopen, plus the libuv flag UV_UDP_REUSEADDR. Not every constant exists on every platform.

Mapping between numbers and names (SIGTERM is 15, ENOENT is 2 on Linux and macOS).

os.platform(), os.arch(), os.type(), os.release(), os.version(), os.machine()

Strings identifying the OS and CPU architecture the binary was built for.

Choosing a native binary or reporting diagnostics.

os.totalmem(), os.freemem(), os.uptime(), os.loadavg(), os.hostname(), os.networkInterfaces()

Host-level numbers; loadavg() is always [0, 0, 0] on Windows.

Health endpoints and diagnostics.

os.getPriority(), os.setPriority()

Scheduling priority of a process, expressed with the os.constants.priority values (a nice value on POSIX, a priority class on Windows).

Lowering the priority of a background batch job.

A tour of node:os
import os from 'node:os';

console.log('platform        :', os.platform(), os.arch());
console.log('parallelism     :', os.availableParallelism());
console.log('cpus().length   :', os.cpus().length);
console.log('cpu model       :', typeof os.cpus()[0]?.model, Object.keys(os.cpus()[0]?.times ?? {}).join(','));
console.log('homedir is str  :', typeof os.homedir() === 'string');
console.log('tmpdir          :', os.tmpdir());
console.log('EOL             :', JSON.stringify(os.EOL));
console.log('devNull         :', os.devNull);
console.log('totalmem > 0    :', os.totalmem() > 0, ' freemem <= total:', os.freemem() <= os.totalmem());
console.log('constants keys  :', Object.keys(os.constants).join(', '));
console.log('SIGTERM / SIGINT:', os.constants.signals.SIGTERM, os.constants.signals.SIGINT);
console.log('ENOENT / EACCES :', os.constants.errno.ENOENT, os.constants.errno.EACCES);
console.log('priority        :', os.constants.priority.PRIORITY_LOW, os.constants.priority.PRIORITY_NORMAL, os.constants.priority.PRIORITY_HIGH);
Output on Node.js 26 (identical on 24 and 22; the temporary directory is the macOS per-user one, Linux prints /tmp)
platform        : darwin arm64
parallelism     : 10
cpus().length   : 10
cpu model       : string user,nice,sys,idle,irq
homedir is str  : true
tmpdir          : /var/folders/ab/cdefgh/T
EOL             : "\n"
devNull         : /dev/null
totalmem > 0    : true  freemem <= total: true
constants keys  : UV_UDP_REUSEADDR, dlopen, errno, signals, priority
SIGTERM / SIGINT: 15 2
ENOENT / EACCES : 2 13
priority        : 19 0 -14

Source: Node.js API: OS

availableParallelism() versus cpus().length

The Node.js documentation says plainly that os.cpus().length should not be used to calculate the amount of parallelism available to an application, and gives os.availableParallelism() as the replacement. The difference shows up as soon as the process does not own the whole machine. A container limited with --cpus=2 on a ten-CPU host still lists ten processors, because cpus() describes the host as the kernel reports it, while availableParallelism() asks libuv for what this process may use. Observed on Node.js 26 in the official node:26 image under Docker Desktop (ten CPUs in the VM):

Observed in a container limited to two CPUs (Node.js 26, Docker Desktop)
docker run --rm --cpus=2 node:26 node -e \
  "const os=require('node:os');console.log(os.availableParallelism(), os.cpus().length)"
Output (observed; the second number is the host, the first is what the process may use)
2 10

The decision rule for the Bookshelf worker pool is therefore short: take availableParallelism(), cap it at what the work justifies, never go below one, and let an environment variable override it when an operator knows better. The example starts that many worker threads and joins their output with os.EOL; the worker pool itself is developed in Worker Threads, and the cluster module’s use of the same number in Cluster & Scaling.

Sizing a pool of workers from availableParallelism()
import os from 'node:os';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

if (isMainThread) {
  const size = Math.max(1, Math.min(os.availableParallelism(), 4));
  const lines = os.EOL;
  const results = await Promise.all(
    Array.from({ length: size }, (_, i) => new Promise((resolve, reject) => {
      const w = new Worker(new URL(import.meta.url), { workerData: { id: i } });
      w.once('message', resolve).once('error', reject);
    })),
  );
  process.stdout.write(`pool of ${size} workers${lines}${results.join(lines)}${lines}`);
} else {
  parentPort.postMessage(`worker ${workerData.id} ready`);
}
Output on Node.js 26, 24 and 22
pool of 4 workers
worker 0 ready
worker 1 ready
worker 2 ready
worker 3 ready

Where temporary files go

os.tmpdir() is not a constant, it is a function of the environment, and that is a feature: an operator can redirect all temporary files of a process by setting TMPDIR (POSIX) or TEMP (Windows) without touching the code. The same applies to os.homedir(), which is why neither is a security boundary. A predictable file name directly inside a shared temporary directory is also a classic attack surface, so create a fresh private directory with fs.mkdtemp(path.join(os.tmpdir(), 'bookshelf-')) (see Directories, Glob & Watch) instead of writing os.tmpdir() + '/cache.json'.

Overriding the temporary directory from the environment
import os from 'node:os';
console.log(os.tmpdir());
console.log(os.userInfo().homedir === os.homedir());
Run with TMPDIR=/var/tmp/bookshelf (identical on Node.js 26, 24 and 22)
/var/tmp/bookshelf
true

node:path: building and taking apart file paths

A path is just a string until the file system gets it, and node:path is the module that manipulates those strings the way the current platform expects. It never checks that a file exists, never follows symbolic links, and never touches the disk; that is why its functions are synchronous and why the Learn article on file paths points out that neither resolve() nor normalize() verify that the result exists. The default flavour follows the operating system the code runs on, so the same call can give different answers on Linux and Windows. Two properties expose both flavours at once: path.posix and path.win32 always behave like the platform they are named after, and path.posix.posix and path.win32.win32 point back to themselves.

Table 3. The path functions and what they decide
Function Behaviour Watch out for

path.join(…​segments)

Joins the segments with the platform separator and normalizes the result. Zero-length segments are ignored; an empty result is '.'.

join() keeps a leading .. and never anchors to the working directory, so it does not make a path absolute.

path.resolve(…​segments)

Processes the segments from right to left until an absolute path is built, then falls back to the current working directory. The result is normalized and has no trailing slash (except the root).

A later absolute segment discards everything before it. The result depends on process.cwd().

path.relative(from, to)

The relative path that leads from from to to, both resolved first. Returns an empty string for equal paths.

On Windows, two different drives have no relative path, so to is returned absolute.

path.parse() and path.format()

Split a path into root, dir, base, name and ext, and build it back. In format(), dir wins over root, and base wins over name and ext.

ext includes the dot, and only the last one: cover.final.png has name cover.final.

path.basename(), path.dirname(), path.extname()

The last segment (optionally without a suffix), the parent directory, the extension of the last segment.

extname('c.tar.gz') is '.gz', and extname('.bashrc') is the empty string.

path.normalize()

Collapses ., .. and repeated separators.

It does not check the result stays inside any directory.

path.isAbsolute(), path.sep, path.delimiter

Whether a path is absolute; the segment separator (/ or \); the PATH-style list delimiter (: or ;).

delimiter is what splits environment variables such as PATH.

path.toNamespacedPath()

On Windows returns the \\?\ form for long paths; a no-op on POSIX.

Rarely needed by application code.

The core path functions on Bookshelf paths
import path from 'node:path';

console.log(path.join('/srv', 'bookshelf', 'data/../uploads', 'cover.png'));
console.log(path.join('a', '', 'b', '.'));
console.log(path.join(''));
console.log(path.resolve('/srv/bookshelf', './uploads'));
console.log(path.resolve('/srv/bookshelf', '/tmp/file/'));
console.log(path.resolve('a', 'b') === path.join(process.cwd(), 'a', 'b'));
console.log(path.relative('/srv/bookshelf/data', '/srv/bookshelf/uploads/a.png'));
console.log(path.normalize('/users/joe/..//test.txt'));
console.log(path.parse('/srv/bookshelf/uploads/cover.final.png'));
console.log(path.format({ dir: '/srv/bookshelf', name: 'catalog', ext: 'json' }));
console.log(path.basename('/a/b/c.txt'), path.basename('/a/b/c.txt', '.txt'), path.dirname('/a/b/c.txt'), path.extname('c.tar.gz'), path.extname('.bashrc'));
console.log(path.isAbsolute('/a'), path.isAbsolute('a'), path.sep, path.delimiter);
Output on Node.js 26, 24 and 22 (POSIX)
/srv/bookshelf/uploads/cover.png
a/b
.
/srv/bookshelf/uploads
/tmp/file
true
../uploads/a.png
/users/test.txt
{
  root: '/',
  dir: '/srv/bookshelf/uploads',
  base: 'cover.final.png',
  ext: '.png',
  name: 'cover.final'
}
/srv/bookshelf/catalog.json
c.txt c /a/b .gz
true false / :

The difference between the first two functions is the one people trip over. join() is concatenation plus cleanup and works on any strings, even if the first one is relative; resolve() is "what absolute path would the shell reach by cd-ing through these arguments", so a segment that starts with / restarts the walk, and a relative result is anchored to the working directory. Use join() to assemble a path below a known base, and resolve() when you need an absolute path or want to compare two paths.

path.posix and path.win32

Because the default flavour depends on the host, a program that parses Windows paths on Linux (a build tool reading a manifest, a log analyser) or builds POSIX paths on Windows (the keys of an object store, the entries of a ZIP archive, the paths inside a URL) must say which rules it wants. The documentation’s own example is basename('C:\\temp\\myfile.html'), which returns the whole string on POSIX because a backslash is an ordinary character there. On Windows Node.js also follows the per-drive working directory concept: path.resolve('C:\\') and path.resolve('C:') can differ.

Forcing a path flavour regardless of the host
import path from 'node:path';

console.log(path.win32.basename('C:\\temp\\myfile.html'));
console.log(path.posix.basename('C:\\temp\\myfile.html'));
console.log(path.win32.join('C:\\', 'Users', 'ana', '..', 'bob'));
console.log(path.win32.resolve('C:\\data', '..\\logs', 'app.log'));
console.log(path.win32.parse('C:\\Users\\ana\\notes.txt'));
console.log(path.win32.relative('C:\\data\\a', 'C:\\data\\b\\c'));
console.log(path.win32.relative('C:\\data', 'D:\\data'));
console.log(path.win32.sep, path.posix.sep, path.win32.delimiter, path.posix.delimiter);
console.log(path.win32.toNamespacedPath('C:\\temp\\file.txt'));
console.log(path.posix.toNamespacedPath('/tmp/file.txt'));
console.log(path.posix === path.posix.posix, path.win32.win32 === path.win32);
Output on Node.js 26, 24 and 22 (every platform)
myfile.html
C:\temp\myfile.html
C:\Users\bob
C:\logs\app.log
{
  root: 'C:\\',
  dir: 'C:\\Users\\ana',
  base: 'notes.txt',
  ext: '.txt',
  name: 'notes'
}
..\b\c
D:\data
\ / ; :
\\?\C:\temp\file.txt
/tmp/file.txt
true true

A practical rule: paths that name something on the local disk use the default path; paths that name something elsewhere (an object key, a path inside a URL, a path inside an archive) use path.posix, so they look the same on every developer machine.

path.matchesGlob()

path.matchesGlob(path, pattern) answers "does this path match this glob?" without touching the disk, which is the half of globbing that fs.glob() does not need a file system for. It is useful for ignore lists, routing of file-change events and test-file selection. Both arguments must be strings, otherwise a TypeError (code ERR_INVALID_ARG_TYPE) is thrown. The function is Stability 2 (Stable) from v24.8.0 and v22.20.0; on earlier releases of the 22 and 20 lines it was experimental. The matching directory-walking counterpart, fs.glob(), is covered in Directories, Glob & Watch.

Matching paths against globs without reading the disk
import path from 'node:path';

console.log(path.matchesGlob('/foo/bar', '/foo/*'));
console.log(path.matchesGlob('/foo/bar*', 'foo/bird'));
console.log(path.matchesGlob('src/routes/books.test.js', 'src/**/*.test.js'));
console.log(path.matchesGlob('src/a/b/c.js', 'src/*/c.js'));
console.log(path.matchesGlob('uploads/cover.png', 'uploads/*.{png,jpg}'));
console.log(path.matchesGlob('uploads/cover.gif', 'uploads/*.{png,jpg}'));
console.log(path.win32.matchesGlob('C:\\data\\a.txt', 'C:\\data\\*.txt'));
try { path.matchesGlob(42, '*'); } catch (e) { console.log(e.code); }
Output on Node.js 26, 24 and 22
true
false
true
false
true
false
true
ERR_INVALID_ARG_TYPE

Joining untrusted input: the traversal check

path.join(root, name) does exactly what its name says, so when name comes from a request, '../secret.txt' or '/etc/passwd' walks right out of root. The last line of the example below shows the escape. The safe pattern has three steps: resolve the candidate against the base, compute the relative path from the base back to the candidate, and reject anything that starts by climbing out (.. as the whole result or followed by a separator) or that is absolute. Testing rel.startsWith('..') alone also rejects a legitimate file named ..notes, which is why the example checks for .. followed by the separator.

A traversal-safe join for the Bookshelf uploads directory
import path from 'node:path';

const root = path.resolve('/srv/bookshelf/uploads');

function safeJoin(base, untrusted) {
  const target = path.resolve(base, untrusted);
  const rel = path.relative(base, target);
  if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
    throw new RangeError(`path escapes ${base}: ${untrusted}`);
  }
  return target;
}

for (const name of ['cover.png', 'a/b.txt', '../secret.txt', '/etc/passwd', 'x/../../uploads-evil/a']) {
  try { console.log('ok  ', safeJoin(root, name)); }
  catch (e) { console.log('deny', e.message); }
}
console.log(path.join(root, '../secret.txt'));
Output on Node.js 26, 24 and 22 (POSIX)
ok   /srv/bookshelf/uploads/cover.png
ok   /srv/bookshelf/uploads/a/b.txt
deny path escapes /srv/bookshelf/uploads: ../secret.txt
deny path escapes /srv/bookshelf/uploads: /etc/passwd
deny path escapes /srv/bookshelf/uploads: x/../../uploads-evil/a
/srv/bookshelf/secret.txt

This check is purely lexical. It does not see symbolic links, so a symlink inside uploads/ that points elsewhere still passes; if that matters, resolve the final path with fs.realpath() and run the same comparison on it. Generated names (a random identifier plus a fixed extension, never the client’s file name) remove the problem at the source, and the opt-in permission model, which confines file system access for the whole process, is the second layer (see Permission Model).

URLs: the WHATWG URL API

Node.js implements the WHATWG URL Standard, the same one browsers implement, and exposes it as the global classes URL and URLSearchParams (and, experimentally, URLPattern). The classes are also importable from node:url, where URL === globalThis.URL is true. Code written for Node.js therefore runs unchanged in a browser, a service worker or another runtime. Every property of a URL is a getter and setter on the prototype rather than a data property, so Object.keys(url) is empty and delete url.protocol has no effect; use url.href, url.toString() or url.toJSON() (all identical) to get the whole string, and JSON.stringify() calls toJSON() for you.

Table 4. The parts of a URL and the property that reads or writes each
Property Example value for https://ana:example-only@api.bookshelf.test:8443/v1/books/42?tag=node#reviews Notes

protocol

https:

Includes the colon. Invalid values assigned to it are ignored.

username, password

ana, example-only

They are part of href, so a URL with credentials in it must never be logged or put in an error message.

host, hostname, port

api.bookshelf.test:8443, api.bookshelf.test, 8443

port is the empty string when it equals the scheme default, so a port equal to the default of the current scheme is dropped (the example shows it).

origin

https://api.bookshelf.test:8443

Read-only. It is the string 'null' for schemes without an origin such as data: and file:.

pathname

/v1/books/42

Setting it percent-encodes what needs encoding.

search, searchParams

?tag=node, a live URLSearchParams

Changes to searchParams update search and href, and the other way round.

hash

#reviews

Never sent to a server; the browser keeps it.

Parsing is strict. new URL(input, base) throws a TypeError with code ERR_INVALID_URL when input cannot be parsed, and input is the offending string. Relative references need a base. Two helpers avoid the try/catch when you only want to know: URL.canParse(input, base) returns a boolean, and URL.parse(input, base) (added in v22.1.0) returns a URL or null.

Parsing, resolving, validating and modifying a URL
const u = new URL('https://ana:example-only@api.bookshelf.test:8443/v1/books/42?tag=node&tag=esm&q=caf%C3%A9#reviews');

console.log(u.href);
console.log(u.protocol, u.username, u.password);
console.log(u.host, u.hostname, u.port);
console.log(u.origin, u.pathname);
console.log(u.search, u.hash);
console.log(u.searchParams.getAll('tag'), u.searchParams.get('q'));

console.log(new URL('../authors?id=7', 'https://api.bookshelf.test/v1/books/42').href);
console.log(new URL('//cdn.bookshelf.test/a.png', 'https://api.bookshelf.test/').href);
console.log(new URL('HTTPS://Example.COM:443/a/./b/../c d').href);

console.log(URL.canParse('/books', 'https://api.bookshelf.test'), URL.canParse('/books'));
console.log(URL.parse('not a url'), URL.parse('/x', 'http://h.test/')?.href);
try { new URL('/books'); } catch (e) { console.log(e.name, e.code, e.input); }

u.port = '80'; u.protocol = 'http'; u.hash = ''; u.pathname = '/v2/shelf items';
console.log(u.href);
console.log(JSON.stringify({ u: new URL('https://a.test/x?y=1') }));
console.log(Object.keys(u), delete u.protocol, u.protocol);
console.log(new URL('data:text/plain,hi').origin, new URL('file:///tmp/a').origin);
Output on Node.js 26, 24 and 22 (the credentials in the first lines are example values)
https://ana:example-only@api.bookshelf.test:8443/v1/books/42?tag=node&tag=esm&q=caf%C3%A9#reviews
https: ana example-only
api.bookshelf.test:8443 api.bookshelf.test 8443
https://api.bookshelf.test:8443 /v1/books/42
?tag=node&tag=esm&q=caf%C3%A9 #reviews
[ 'node', 'esm' ] café
https://api.bookshelf.test/v1/authors?id=7
https://cdn.bookshelf.test/a.png
https://example.com/a/c%20d
true false
null http://h.test/x
TypeError ERR_INVALID_URL /books
http://ana:example-only@api.bookshelf.test/v2/shelf%20items?tag=node&tag=esm&q=caf%C3%A9
{"u":"https://a.test/x?y=1"}
[] true http:
null null

Notice what the parser did without being asked: it lower-cased the host, dropped the default port, removed . and .. segments and percent-encoded the space in the path. Those are the normalizations that make URL comparison meaningful, and the reason to compare new URL(a).href with new URL(b).href instead of comparing raw strings.

URLSearchParams

URLSearchParams is the query string as a list of name and value pairs, in order, with duplicates allowed. It is reachable as url.searchParams (live) or constructible from a string, a plain object or an array of pairs. It percent-encodes on output (with + for spaces, the application/x-www-form-urlencoded convention), decodes on input, is iterable, and has size, has(name, value), delete(name, value) and sort(). Values are always strings: an array value in an object is joined with commas and undefined becomes the text undefined, so build repeated keys with append().

Building, reading and encoding query strings
const params = new URLSearchParams({ q: 'node & npm', page: '2' });
params.append('tag', 'esm');
params.append('tag', 'cjs');
params.set('page', '3');

console.log(params.toString());
console.log(params.size, params.has('tag'), params.has('tag', 'esm'), params.has('tag', 'ts'));
console.log(params.getAll('tag'), params.get('missing'));
params.delete('tag', 'cjs');
params.sort();
console.log([...params], Object.fromEntries(params));
console.log(new URLSearchParams('a=1&a=2&b=%20x+y&c').toString());
console.log(new URLSearchParams([['x', '1'], ['y', 'ü']]).toString());

const url = new URL('https://api.bookshelf.test/books');
url.searchParams.set('author', 'Cortázar');
url.searchParams.append('sort', '-year');
console.log(url.href);
Output on Node.js 26, 24 and 22
q=node+%26+npm&page=3&tag=esm&tag=cjs
4 true true false
[ 'esm', 'cjs' ] null
[ [ 'page', '3' ], [ 'q', 'node & npm' ], [ 'tag', 'esm' ] ] { page: '3', q: 'node & npm', tag: 'esm' }
a=1&a=2&b=+x+y&c=
x=1&y=%C3%BC
https://api.bookshelf.test/books?author=Cort%C3%A1zar&sort=-year

Building a URL by pasting strings together (a template literal such as `${base}/books?author=${name}`) is the usual way to get encoding and injection bugs: a name containing & or # changes the meaning of the URL. Set parameters through searchParams and let the class encode them.

URLPattern: routing without a framework

URLPattern matches URLs, or parts of URLs, against a pattern, using the syntax of the URL Pattern Standard (named groups such as :id, an optional regular expression in parentheses, wildcards, optional groups in braces). It was added as a class in v23.8.0 and became a global in v24.0.0. The documentation marks it Stability 1 (Experimental), but on the lines tested here it printed no experimental warning: it is 'function' on Node.js 26 and 24 and not defined on Node.js 22, so code that must run on 22 needs a fallback (a small hand-written matcher, or a router library). new URLPattern(init, options) accepts a URL string or an object with protocol, username, password, hostname, port, pathname, search, hash and baseURL; the only option listed in the Node.js documentation is ignoreCase. pattern.test(input) returns a boolean and pattern.exec(input) returns, for every component, an object with the matched input and the named groups (unnamed wildcards get numeric group names). A pattern that omits a component matches anything in it, so a pattern with only pathname matches any host.

Matching and extracting groups with URLPattern
console.log(typeof URLPattern);
const route = new URLPattern({ pathname: '/books/:id(\\d+)' });
console.log(route.test('https://api.bookshelf.test/books/42'));
console.log(route.test('https://api.bookshelf.test/books/abc'));
console.log(route.exec('https://api.bookshelf.test/books/42').pathname.groups);

const files = new URLPattern('https://nodejs.org/docs/latest/api/*.html');
console.log(files.test('https://nodejs.org/docs/latest/api/dns.html'));
console.log(files.exec('https://nodejs.org/docs/latest/api/dns.html').pathname.groups);

const api = new URLPattern({ pathname: '/api/:version/books{/:id}?' });
for (const p of ['/api/v1/books', '/api/v1/books/7', '/api/v1']) {
  console.log(p, api.exec({ pathname: p })?.pathname.groups ?? null);
}
console.log(new URLPattern({ pathname: '/BOOKS' }, { ignoreCase: true }).test({ pathname: '/books' }));
console.log(new URLPattern({ pathname: '/BOOKS' }).test({ pathname: '/books' }));
Output on Node.js 26 and 24 (on Node.js 22 the first line prints undefined, and the next line throws ReferenceError: URLPattern is not defined)
function
true
false
{ id: '42' }
true
{ '0': 'dns' }
/api/v1/books { id: undefined, version: 'v1' }
/api/v1/books/7 { id: '7', version: 'v1' }
/api/v1 null
true
false

The same class makes the Bookshelf API’s router small: a table of patterns, each tried against new URL(req.url, base).pathname. Two details matter. The base is a constant, not the Host header, because the header is client-controlled input; and req.url is only the path and query of the request, so it must be resolved against a base before it can be parsed. The full REST API is built in Building a REST API.

A tiny router for node:http built on URLPattern (Node.js 26 and 24)
import http from 'node:http';

const BASE = 'http://bookshelf.invalid';          // never trust the Host header for this
const routes = [
  ['GET', new URLPattern({ pathname: '/books/:id(\\d+)' }), (m, url) => ({ id: Number(m.pathname.groups.id), fields: url.searchParams.get('fields') })],
  ['GET', new URLPattern({ pathname: '/books' }), (m, url) => ({ page: Number(url.searchParams.get('page') ?? 1) })],
];

const server = http.createServer((req, res) => {
  const url = new URL(req.url, BASE);
  for (const [method, pattern, handler] of routes) {
    const match = req.method === method && pattern.exec({ pathname: url.pathname });
    if (match) {
      res.setHeader('content-type', 'application/json');
      return res.end(JSON.stringify(handler(match, url)));
    }
  }
  res.statusCode = 404;
  res.end('not found');
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  for (const p of ['/books/42?fields=title', '/books?page=3', '/books/abc', '/books/..%2f..%2fetc']) {
    const r = await fetch(`http://127.0.0.1:${port}${p}`);
    console.log(r.status, p, await r.text());
  }
  server.close();
});
Output on Node.js 26 and 24 (the router is not run on 22)
200 /books/42?fields=title {"id":42,"fields":"title"}
200 /books?page=3 {"page":3}
404 /books/abc not found
404 /books/..%2f..%2fetc not found

File URLs and file paths

A module’s identity in ESM is a URL, not a path: import.meta.url is a file: URL such as file:///srv/bookshelf/src/server.js. Converting between the two worlds is the job of two functions in node:url, and doing it by hand (new URL(…​).pathname, string slicing) is wrong in ways that only show up on some platforms: the pathname of a file URL is still percent-encoded (/hello%20world), and on Windows it carries a leading slash before the drive letter (/C:/path/). fileURLToPath(url) decodes it and returns a valid absolute path for the platform, pathToFileURL(path) resolves the path to absolute and encodes characters that are special in URLs (#, %, ?, spaces). Both accept a windows option (v22.1.0 and v20.13.0) to force the Windows or POSIX interpretation, which is how the example below can show Windows results on any platform. fileURLToPath() throws for non-file: URLs (ERR_INVALID_URL_SCHEME), for URLs with a host on POSIX (ERR_INVALID_FILE_URL_HOST) and for encoded slashes (ERR_INVALID_FILE_URL_PATH). url.fileURLToPathBuffer() (v24.3.0 and v22.18.0) returns a Buffer for names that are not valid UTF-8.

In ESM you rarely need the conversion for the current module: import.meta.filename and import.meta.dirname already give the path of the current file and its directory (see ECMAScript Modules); CommonJS has filename and dirname (see CommonJS Modules). Use new URL('./data/catalog.json', import.meta.url) to build a URL relative to the module, which fs functions also accept.

Converting between file paths and file URLs
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';

console.log(import.meta.url.startsWith('file:///'));
console.log(fileURLToPath(import.meta.url) === import.meta.filename);
console.log(path.dirname(fileURLToPath(import.meta.url)) === import.meta.dirname);

console.log(pathToFileURL('/srv/bookshelf/my books/#1.txt').href);
console.log(pathToFileURL('/some/path%.c').href);
console.log(fileURLToPath('file:///srv/bookshelf/my%20books/%231.txt'));
console.log(new URL('file:///hello world').pathname, fileURLToPath('file:///hello world'));
console.log(new URL('./data/catalog.json', import.meta.url).pathname.endsWith('/data/catalog.json'));

console.log(fileURLToPath('file:///C:/data/a.txt', { windows: true }));
console.log(fileURLToPath('file://nas/share/a.txt', { windows: true }));
console.log(pathToFileURL('C:\\data\\a b.txt', { windows: true }).href);

for (const bad of ['http://example.test/a', 'file://host/a/b', 'file:///a%2Fb']) {
  try { console.log(fileURLToPath(bad)); } catch (e) { console.log(e.code); }
}
const root = '/srv/bookshelf/public';
const evil = new URL('file:///srv/bookshelf/public/%2e%2e/secret.txt');
console.log(fileURLToPath(evil), fileURLToPath(evil).startsWith(root + path.sep));
Output on Node.js 26, 24 and 22 (POSIX; the Windows lines come from the windows option)
true
true
true
file:///srv/bookshelf/my%20books/%231.txt
file:///some/path%25.c
/srv/bookshelf/my books/#1.txt
/hello%20world /hello world
true
C:\data\a.txt
\\nas\share\a.txt
file:///C:/data/a%20b.txt
ERR_INVALID_URL_SCHEME
ERR_INVALID_FILE_URL_HOST
ERR_INVALID_FILE_URL_PATH
/srv/bookshelf/secret.txt false

The last two lines are a security lesson the official documentation spells out. fileURLToPath() decodes encoded dot segments (%2e%2e) and then normalizes, so a URL that looks as if it stays inside /srv/bookshelf/public converts to a path outside it, and the check printed false. The documentation states that applications must not rely on fileURLToPath() alone to prevent directory traversal: run the traversal check from above on the converted path.

Why url.parse() and querystring are legacy

Before the WHATWG classes existed, Node.js had its own URL API, now called the legacy API: url.parse(), url.format(urlObject), url.resolve() and the plain urlObject they use, together with the node:querystring module. The two have different statuses, and the difference is worth knowing.

Table 5. Status of the old APIs (Node.js 26 documentation)
API Status Why and what to use instead

Legacy URL API section (urlObject, url.format(urlObject))

3 Legacy

The deprecation announced in v11.0.0 was revoked in v15.13.0 and v14.17.0 and the API was labelled Legacy: it is kept for compatibility. Use the WHATWG classes in new code.

url.parse(), url.format(urlString), url.resolve()

0 Deprecated; DEP0169 is an application deprecation since v24.0.0

url.parse() uses a lenient, non-standard algorithm that is prone to host-name spoofing and to wrong handling of user names and passwords, and CVEs are not issued for its vulnerabilities. format(urlString) and resolve() call it internally. Use new URL() and URL.parse().

node:querystring

2 Stable (not deprecated)

It is not standardized and has behaviour of its own (see the example). The documentation says it is faster than URLSearchParams, which should be preferred unless performance is critical or browser compatibility matters.

What the two modules do differently from the WHATWG classes
import url from 'node:url';

// 1. The legacy result shape
const input = 'https://user:pw@example.com:8080/p/a/t/h?query=string#hash';
const legacy = url.parse(input, true);
console.log(legacy.host, legacy.pathname, legacy.query, legacy.auth);

// 2. Leniency: inputs the WHATWG parser rejects, url.parse() accepts
for (const s of ['https://exa mple.test/x', 'https://shelf.test:99999/x', 'https://shelf.test%2f.evil.test/x']) {
  const l = url.parse(s);
  let w;
  try { w = new URL(s).href; } catch (e) { w = e.code; }
  console.log(JSON.stringify(s), '\n  legacy  :', l.hostname, l.port, l.pathname, '\n  WHATWG  :', w);
}

// 3. Relative and format helpers
console.log(url.resolve('https://a.test/one/two', '../three'), new URL('../three', 'https://a.test/one/two').href);
console.log(url.format({ protocol: 'https', hostname: 'a.test', pathname: '/x', query: { a: 1 } }));
console.log(url.format(new URL('https://u:p@a.test/x?y=1#z'), { auth: false, fragment: false }));
Output on Node.js 26 and 24 (on 22 the same lines print; only the warning below is absent)
example.com:8080 /p/a/t/h [Object: null prototype] { query: 'string' } user:pw
"https://exa mple.test/x"
  legacy  : exa null %20mple.test/x
  WHATWG  : ERR_INVALID_URL
"https://shelf.test:99999/x"
  legacy  : shelf.test 99999 /x
  WHATWG  : ERR_INVALID_URL
"https://shelf.test%2f.evil.test/x"
  legacy  : shelf.test null %2f.evil.test/x
  WHATWG  : ERR_INVALID_URL
https://a.test/three https://a.test/three
https://a.test/x?a=1
https://a.test/x?y=1

On Node.js 26 and 24 the first call also wrote this to standard error (observed; Node.js 22 stays silent unless started with --pending-deprecation, which DEP0169 supports):

(node:PID) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.

The three leniency cases are the heart of the security argument. A validator built on new URL() and a downstream consumer built on url.parse() can disagree about what an input means, and an attacker lives in that disagreement (an allow-list of hosts that checks one parser and fetches through the other). Parsing every URL once, with one parser, and passing the resulting URL object around removes that class of bug. DEP0170 shows how far this went: url.parse() used to accept ports that are not numbers, and since v25.0.0 it throws ERR_INVALID_ARG_VALUE for them (observed on Node.js 26; Node.js 24 and 22 still return the host with a null port). Note that the application deprecation only warns for code outside node_modules, so a dependency of yours may keep using it silently.

querystring and URLSearchParams side by side
import querystring from 'node:querystring';

const raw = 'a=1&a=2&b=x+y&d';
console.log(querystring.parse(raw));
console.log(Object.fromEntries(new URLSearchParams(raw)));
console.log(new URLSearchParams(raw).getAll('a'));

const value = { a: [1, 2], b: 'x y', c: { nested: true }, d: undefined, e: null, f: true };
console.log(querystring.stringify(value));
console.log(new URLSearchParams(value).toString());

console.log(querystring.parse('c=%E4'), new URLSearchParams('c=%E4').get('c'));
console.log(querystring.escape('a b&c=é'), encodeURIComponent('a b&c=é'));
Output on Node.js 26, 24 and 22
[Object: null prototype] { a: [ '1', '2' ], b: 'x y', d: '' }
{ a: '2', b: 'x y', d: '' }
[ '1', '2' ]
a=1&a=2&b=x%20y&c=&d=&e=&f=true
a=1%2C2&b=x+y&c=%5Bobject+Object%5D&d=undefined&e=null&f=true
[Object: null prototype] { c: '�' } �
a%20b%26c%3D%C3%A9 a%20b%26c%3D%C3%A9

The querystring module is a different story: nothing is wrong with it, it is simply a second, older way to do what URLSearchParams does. The examples show where they differ. querystring.parse() returns an object with a null prototype and collects repeated keys into an array, while Object.fromEntries(new URLSearchParams(…​)) keeps only the last value (use getAll() for the others). querystring.stringify() expands arrays into repeated keys and turns nested objects, undefined and null into empty values, while URLSearchParams joins arrays with commas and writes the text [object Object], undefined and null. querystring.parse() replaces invalid UTF-8 with the replacement character, as URLSearchParams does, and encodes a space as %20 where URLSearchParams writes +. If you keep using querystring, keep it for the server-side cases it was written for and put an allow-list of known keys on its output; for everything that shares code with a browser, use URLSearchParams.

Migration is mostly mechanical, and the official Node.js userland migrations project ships a codemod for it, which the url.parse() documentation points to:

Migrating url.parse() calls automatically
npx codemod@latest @nodejs/node-url-to-whatwg-url
Table 6. Common legacy idioms and their replacements
Legacy Replacement

url.parse(req.url, true) in a server

new URL(req.url, 'http://bookshelf.invalid') with a constant base, then url.searchParams; never build the base from the Host header or from x-forwarded-* headers unless a trusted proxy sets them.

url.parse(s).query as an object

Object.fromEntries(url.searchParams) (last value wins) or url.searchParams.getAll(name).

url.resolve(from, to)

new URL(to, from).href.

url.format({ protocol, hostname, pathname, query })

Set protocol, hostname, pathname and searchParams on a URL, or use url.format(urlInstance, options) (still current) to leave out parts such as credentials or the fragment.

url.parse(s).hostname to validate a host

URL.parse(s)?.hostname (returns null on bad input instead of guessing), compared against an allow-list.

Pitfalls

Table 7. Mistakes that recur with these modules
Mistake What to do instead

Using os.cpus().length to size a pool

Use os.availableParallelism(); it respects the CPUs the process may use, which matters in containers.

Assuming os.tmpdir() or os.homedir() are fixed

They follow environment variables. Create private subdirectories with fs.mkdtemp() and do not trust them as a security boundary.

Joining untrusted names onto a base with path.join()

Resolve, compute the relative path and reject .. and absolute results; use generated names where you can; add realpath() if symbolic links are possible.

Hardcoding / or \ in a path

Build paths with path.join() or path.resolve(); use path.posix for keys and URL paths.

Parsing a Windows path on Linux (or the reverse) with the default path

Use path.win32 or path.posix explicitly.

Reading new URL('file:///…​').pathname as a path

Use fileURLToPath(). The pathname is still percent-encoded and, on Windows, begins with a slash before the drive letter.

Trusting fileURLToPath() to stay inside a directory

Check the converted path with the same traversal test you use for any path.

Concatenating strings into URLs and query strings

Use new URL(relative, base) and searchParams.set() or append().

Logging url.href of a URL that may contain credentials

Log url.origin + url.pathname, or clear username and password first.

Building the base URL of a request from the Host header

Use a configured constant, or a trusted proxy’s header only when you control the proxy.

Using URLPattern on Node.js 22 without a check

Test typeof URLPattern first, or require Node.js 24 or later. Remember it is still Experimental.

Using url.parse() or url.resolve() in new code

Use URL and URL.parse(). DEP0169 warns on 24 and later.

What changed recently

Table 8. Changes in os, path and URL, and when they landed
Version Change Effect on you

Node.js 19.4.0 and 18.14.0

os.availableParallelism() added.

Replace os.cpus().length in pool sizing.

Node.js 19.9.0 and 18.17.0

URL.canParse() added; url.parse() gains support for --pending-deprecation.

Validate input without try/catch.

Node.js 22.1.0

URL.parse() added; fileURLToPath() and pathToFileURL() get the windows option (also in v20.13.0).

Parse without throwing; test Windows behaviour on any OS.

Node.js 22.5.0 and 20.17.0

path.matchesGlob() added (experimental).

Glob matching without a dependency.

Node.js 23.8.0 and 24.0.0

URLPattern added as a class and then as a global (Experimental).

Built-in route matching on 24 and later; not available on 22.

Node.js 24.0.0

url.parse(), url.format(urlString) and url.resolve() become an application deprecation (DEP0169); the call prints a warning outside node_modules.

Expect warnings on 24 and 26; migrate to URL.

Node.js 24.3.0 and 22.18.0

url.fileURLToPathBuffer() added.

Handle file names that are not valid UTF-8.

Node.js 24.8.0 and 22.20.0

path.matchesGlob() is marked stable.

No experimental caveat remains.

Node.js 25.0.0

DEP0170 reaches end of life: url.parse() throws for ports that are not numbers.

One more reason inputs parsed by url.parse() and new URL() no longer disagree about ports.

Where the books are outdated or incomplete

Efficient Node.js (Samer Buna, O’Reilly 2025) lists node:os, node:path and node:url in a one-line-each table of built-in modules and then uses them only in passing. Its cluster example sizes the number of workers with os.availableParallelism(), which is the current API, but it describes the result as a number up to what os.cpus().length reports; as the container example above shows, the two can differ, and the point of the newer function is exactly that it does not report the host. Several of its HTTP examples route by comparing req.url with literal strings in a switch; that value includes the query string, so /compute?n=5 would not match /compute, and the page-level fix is to parse req.url with new URL() against a constant base and compare pathname, or to use URLPattern on Node.js 24 and later. The book treats __dirname and import.meta.dirname as alternatives, which is right, but it does not cover fileURLToPath() and pathToFileURL(), which are what ESM needs for everything that is not the current file, and it says nothing about path.matchesGlob(), URLPattern or the legacy status of url.parse() (an application deprecation since Node.js 24.0.0).

Node.js Projects (Jonathan Wexler, O’Reilly 2025) builds its projects on frameworks and does not teach these modules. Where it needs a path it resolves a relative file name with path.resolve(), which is correct, and where it composes HTTP addresses for the peers of its blockchain project and for API calls it concatenates strings with template literals. That works while every piece is trusted; once a piece comes from a user, use new URL() and searchParams so that the encoding is done for you.

Table 9. Quick corrections for older tutorials and books
Statement you may meet Current situation

"Use os.cpus().length to decide how many workers to start"

Use os.availableParallelism(); the documentation warns against cpus().length for this purpose.

"`path.join()` makes a path safe"

It only normalizes. Untrusted names need a resolve, relative and prefix check, and symbolic links need realpath().

“__dirname` does not exist in ESM, so use `path.dirname(new URL(import.meta.url).pathname)”

Use import.meta.dirname and import.meta.filename, or fileURLToPath(import.meta.url). The .pathname shortcut breaks on spaces and on Windows.

"`url.parse()` is the standard way to read a query string"

It is deprecated; new URL() and URLSearchParams are the standard replacement.

"`querystring` is deprecated"

It is not; it is Stable but non-standard. Prefer URLSearchParams for new code.

"You need a router package to match URL patterns"

URLPattern is built in from Node.js 24 (Experimental); on 22 use a package or your own matcher.

"`fs.glob` is the only built-in glob"

path.matchesGlob() tests a path against a glob without reading the disk and is stable from v24.8.0 and v22.20.0.

References