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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
2 Stable |
|
|
2 Stable |
Added in v22.5.0 and v20.17.0; marked stable in v24.8.0 and v22.20.0 (experimental before). |
|
2 Stable ( |
Classes global since v10.0.0; |
|
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: |
|
2 Stable |
The |
|
3 Legacy module section; the three functions are 0 Deprecated |
|
|
2 Stable |
Not deprecated; the documentation recommends |
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.
| API | What it returns | Use it for |
|---|---|---|
|
An estimate of the default amount of parallelism a program should use; always greater than zero. A wrapper around libuv’s |
Sizing worker pools and cluster forks. |
|
One object per logical CPU with |
Reporting hardware and computing CPU load from two samples. Not for sizing pools. |
|
The home directory. On POSIX it uses |
Per-user config and caches. Not a trust boundary: the environment variable wins. |
|
|
When you need the account’s real home regardless of the environment. |
|
The default directory for temporary files, with no trailing slash. On POSIX it checks |
Scratch files; combine with |
|
|
Writing text files meant for people on the current platform. |
|
|
Discarding output of a child process portably. |
|
Objects |
Mapping between numbers and names ( |
|
Strings identifying the OS and CPU architecture the binary was built for. |
Choosing a native binary or reporting diagnostics. |
|
Host-level numbers; |
Health endpoints and diagnostics. |
|
Scheduling priority of a process, expressed with the |
Lowering the priority of a background batch job. |
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);
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):
docker run --rm --cpus=2 node:26 node -e \
"const os=require('node:os');console.log(os.availableParallelism(), os.cpus().length)"
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.
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`);
}
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'.
import os from 'node:os';
console.log(os.tmpdir());
console.log(os.userInfo().homedir === os.homedir());
/var/tmp/bookshelf
true
Source: Node.js API: os.tmpdir()
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.
| Function | Behaviour | Watch out for |
|---|---|---|
|
Joins the segments with the platform separator and normalizes the result. Zero-length segments are ignored; an empty result is |
|
|
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 |
|
The relative path that leads from |
On Windows, two different drives have no relative path, so |
|
Split a path into |
|
|
The last segment (optionally without a suffix), the parent directory, the extension of the last segment. |
|
|
Collapses |
It does not check the result stays inside any directory. |
|
Whether a path is absolute; the segment separator ( |
|
|
On Windows returns the |
Rarely needed by application code. |
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);
/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.
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);
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.
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); }
true
false
true
false
true
false
true
ERR_INVALID_ARG_TYPE
Source: Node.js API: path.matchesGlob()
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.
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'));
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.
| Property | Example value for https://ana:example-only@api.bookshelf.test:8443/v1/books/42?tag=node#reviews |
Notes |
|---|---|---|
|
|
Includes the colon. Invalid values assigned to it are ignored. |
|
|
They are part of |
|
|
|
|
Read-only. It is the string |
|
|
|
Setting it percent-encodes what needs encoding. |
|
|
Changes to |
|
|
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.
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);
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
Source: Node.js API: The WHATWG URL API
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().
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);
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
Source: Node.js API: URLSearchParams
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.
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' }));
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
Source: Node.js API: URLPattern
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.
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();
});
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
Source: Node.js API: URLPattern, Node.js API: HTTP
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.
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));
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.
| API | Status | Why and what to use instead |
|---|---|---|
Legacy URL API section ( |
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. |
|
0 Deprecated; DEP0169 is an application deprecation since v24.0.0 |
|
|
2 Stable (not deprecated) |
It is not standardized and has behaviour of its own (see the example). The documentation says it is faster than |
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 }));
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.
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=é'));
[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:
npx codemod@latest @nodejs/node-url-to-whatwg-url
Source: Node.js API: url.parse()
| Legacy | Replacement |
|---|---|
|
|
|
|
|
|
|
Set |
|
|
Pitfalls
| Mistake | What to do instead |
|---|---|
Using |
Use |
Assuming |
They follow environment variables. Create private subdirectories with |
Joining untrusted names onto a base with |
Resolve, compute the relative path and reject |
Hardcoding |
Build paths with |
Parsing a Windows path on Linux (or the reverse) with the default |
Use |
Reading |
Use |
Trusting |
Check the converted path with the same traversal test you use for any path. |
Concatenating strings into URLs and query strings |
Use |
Logging |
Log |
Building the base URL of a request from the |
Use a configured constant, or a trusted proxy’s header only when you control the proxy. |
Using |
Test |
Using |
Use |
What changed recently
| Version | Change | Effect on you |
|---|---|---|
Node.js 19.4.0 and 18.14.0 |
|
Replace |
Node.js 19.9.0 and 18.17.0 |
|
Validate input without |
Node.js 22.1.0 |
|
Parse without throwing; test Windows behaviour on any OS. |
Node.js 22.5.0 and 20.17.0 |
|
Glob matching without a dependency. |
Node.js 23.8.0 and 24.0.0 |
|
Built-in route matching on 24 and later; not available on 22. |
Node.js 24.0.0 |
|
Expect warnings on 24 and 26; migrate to |
Node.js 24.3.0 and 22.18.0 |
|
Handle file names that are not valid UTF-8. |
Node.js 24.8.0 and 22.20.0 |
|
No experimental caveat remains. |
Node.js 25.0.0 |
DEP0170 reaches end of life: |
One more reason inputs parsed by |
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.
| Statement you may meet | Current situation |
|---|---|
"Use |
Use |
"`path.join()` makes a path safe" |
It only normalizes. Untrusted names need a resolve, relative and prefix check, and symbolic links need |
“__dirname` does not exist in ESM, so use `path.dirname(new URL(import.meta.url).pathname)” |
Use |
"`url.parse()` is the standard way to read a query string" |
It is deprecated; |
"`querystring` is deprecated" |
It is not; it is Stable but non-standard. Prefer |
"You need a router package to match URL patterns" |
|
"`fs.glob` is the only built-in glob" |
|
References
-
Node.js Learn: Node.js File Paths, https://nodejs.org/learn/manipulating-files/nodejs-file-paths
-
Node.js API: OS, https://nodejs.org/docs/latest/api/os.html
-
Node.js API: Path, https://nodejs.org/docs/latest/api/path.html
-
Node.js API: URL, https://nodejs.org/docs/latest/api/url.html
-
Node.js API: Query string, https://nodejs.org/docs/latest/api/querystring.html
-
Node.js API: Global objects, https://nodejs.org/docs/latest/api/globals.html
-
Node.js API: Deprecations (DEP0169, DEP0170), https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: Worker threads, https://nodejs.org/docs/latest/api/worker_threads.html
-
WHATWG: URL Standard, https://url.spec.whatwg.org/
-
WHATWG: URL Pattern Standard, https://urlpattern.spec.whatwg.org/
-
libuv: Miscellaneous utilities (uv_available_parallelism), https://docs.libuv.org/en/v1.x/misc.html
-
Node.js userland migrations: node-url-to-whatwg-url, https://github.com/nodejs/userland-migrations/tree/main/recipes/node-url-to-whatwg-url
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/