The path Module
The path module is one of Node.js’s original built-in modules, and it solves a deceptively tricky problem: turning directory and file name pieces into a correct, working filesystem path. Every operating system has its own rules for separators and how .. and . segments resolve — Windows uses backslash-separated paths with drive letters, POSIX systems use forward slashes and no drive letters. The path module hides those differences behind a small, consistent API so code that reads files, writes files, or serves static assets behaves the same way on Linux, macOS, and Windows.
Overview: How the path Module Works
Import it like any other core module: const path = require("node:path") in CommonJS, or import path from "node:path" in an ES module. Unlike fs, the path module never touches the disk — it is pure string manipulation. Every function takes one or more strings describing path segments and returns a new string. That matters: path.join("a", "b") does not check whether a folder named b actually exists inside a; it just produces the string a/b (or the Windows-separated equivalent).
Internally, path exposes two full sets of platform-specific implementations: path.posix (forward-slash, POSIX rules) and path.win32 (backslash, drive letters, UNC paths). The plain path.xxx functions you normally call are simply an alias for whichever of those two matches the operating system Node is currently running on. That is why the same script produces POSIX-style output on Linux and Windows-style output on Windows without any conditional logic in your code — and why you can force one platform’s behavior with path.posix.join(...) even while running on Windows, which is handy when building URL-style paths that must always use forward slashes.
Two constants describe the host platform’s conventions: path.sep (the separator character) and path.delimiter (the character used to join entries in process.env.PATH — a colon on POSIX, a semicolon on Windows). Use these instead of hardcoding a separator so path-splitting code stays portable.
The most important distinction to internalize is join versus resolve. path.join() concatenates segments and normalizes the result (collapsing something like a/b/../c down to a/c), but it does not care whether the final string is absolute or relative — it simply reflects what you gave it. path.resolve(), on the other hand, always returns an absolute path: it processes its arguments from right to left, prepending each one, until an absolute segment has been reached, and if it runs out of arguments before that happens it prepends process.cwd() (the current working directory). This mirrors exactly how a shell resolves a sequence of cd commands.
Syntax
There is no single “syntax” for the module — it is a collection of pure functions with the general shape:
path.methodName(pathSegment1, pathSegment2, ...)
The table below covers the methods you will use most often:
| Method | Purpose |
|---|---|
path.join(...segments) |
Joins segments with the platform separator and normalizes .. / . |
path.resolve(...segments) |
Builds an absolute path, resolving right-to-left, falling back to process.cwd() |
path.normalize(p) |
Cleans up a single path string: collapses .., ., and repeated separators |
path.basename(p, ext) |
Returns the last segment (file name), optionally stripping a given extension |
path.dirname(p) |
Returns everything except the last segment |
path.extname(p) |
Returns the extension, including the leading dot |
path.parse(p) |
Splits a path into root, dir, base, ext, name |
path.format(obj) |
The inverse of parse — builds a path string from an object |
path.isAbsolute(p) |
Returns a boolean |
path.relative(from, to) |
Returns the relative path needed to get from one path to another |
path.sep / path.delimiter |
Platform separator / PATH-list delimiter constants |
Examples
Example 1: Pulling a path apart
const path = require("node:path");
const filePath = "/home/user/projects/app/index.js";
console.log("dirname:", path.dirname(filePath));
console.log("basename:", path.basename(filePath));
console.log("basename no ext:", path.basename(filePath, ".js"));
console.log("extname:", path.extname(filePath));
console.log("parsed:", path.parse(filePath));
Output:
dirname: /home/user/projects/app
basename: index.js
basename no ext: index
extname: .js
parsed: { root: '/', dir: '/home/user/projects/app', base: 'index.js', ext: '.js', name: 'index' }
path.dirname and path.basename split a path at its last separator. path.extname looks only at the portion after the final dot in the base name. path.parse gives you all of those pieces at once as an object, which is convenient when you need several of them together — for example, to build a new file name that keeps the same directory but changes the extension.
Example 2: join vs. resolve vs. isAbsolute
const path = require("node:path");
console.log(path.join("/home/user", "projects", "../projects", "app.js"));
console.log(path.resolve("/home/user", "projects", "../projects", "app.js"));
console.log(path.join("a", "b", "..", "c"));
console.log(path.isAbsolute("/home/user"));
console.log(path.isAbsolute("projects/app.js"));
Output:
/home/user/projects/app.js
/home/user/projects/app.js
a/c
true
false
Both join and resolve collapse the ../projects back-reference the same way here, because the first argument to both calls is already absolute. The difference shows up when none of the segments are absolute: path.join("a", "b") stays relative (a/b), while path.resolve("a", "b") would return an absolute path built from process.cwd(), since resolve never returns anything but an absolute path.
Example 3: A safe static file server
This ties path together with node:http and streaming file reads — a realistic use case where getting path handling wrong is a security bug, not just a cosmetic one.
const http = require("node:http");
const path = require("node:path");
const fs = require("node:fs");
const { pipeline } = require("node:stream/promises");
const PUBLIC_DIR = path.join(__dirname, "public");
const MIME_TYPES = {
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".json": "application/json",
".png": "image/png",
};
async function sendFile(res, filePath) {
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
await pipeline(fs.createReadStream(filePath), res);
}
const server = http.createServer(async (req, res) => {
const requestedPath = decodeURIComponent(req.url === "/" ? "/index.html" : req.url);
const safePath = path.normalize(requestedPath).replace(/^(\\.\\.[/\\\\])+/, "");
const filePath = path.join(PUBLIC_DIR, safePath);
if (!filePath.startsWith(PUBLIC_DIR + path.sep)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
try {
await sendFile(res, filePath);
} catch (err) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
}
});
server.listen(process.env.PORT ?? 3000, () => {
console.log("Server running on port 3000");
});
Output:
Server running on port 3000
Three uses of path make this server both correct and safe. path.join(__dirname, "public") builds an absolute base directory regardless of where the process is started from. path.extname(filePath) picks the right Content-Type header. And path.normalize plus the filePath.startsWith(PUBLIC_DIR + path.sep) check stop a request like /../../etc/passwd from ever resolving to a file outside PUBLIC_DIR — without that check, a client could read arbitrary files on the host.
How It Works Step by Step
For path.resolve("/home/user", "projects", "../projects", "app.js"), Node processes the arguments right to left conceptually, but builds practically by walking left to right and normalizing at the end: it starts from the first (leftmost) argument that is itself absolute — here /home/user already is — appends each subsequent segment, and finally normalizes the whole string, collapsing projects/../projects down to projects. If none of the arguments were absolute, resolve would keep walking left until it ran out, then fall back to prepending process.cwd() so the result is always absolute.
For the static file server, the order of operations per request is: (1) req.url arrives as a raw, URL-encoded string straight from the socket; (2) decodeURIComponent turns escape sequences like %20 back into normal characters; (3) path.normalize collapses any .. or repeated separators the client sent; (4) the leading-.. strip removes any residual traversal attempt; (5) path.join anchors the cleaned path under PUBLIC_DIR; (6) the startsWith check is the actual security gate — only after it passes does the handler ever call fs.createReadStream. Skipping any one of these steps, especially the final containment check, reopens the traversal hole.
Common Mistakes
Mistake 1: Concatenating paths with a hardcoded separator
// Works by accident on POSIX, breaks on Windows, and breaks again
// if __dirname ever already ends with a separator
const configPath = __dirname + "/config/settings.json";
const settings = fs.readFileSync(configPath, "utf8");
String concatenation assumes a forward slash and assumes __dirname never ends in one. Neither assumption is safe across platforms. Use path.join instead, which normalizes separators and handles trailing slashes correctly:
const path = require("node:path");
const fs = require("node:fs");
const configPath = path.join(__dirname, "config", "settings.json");
const settings = fs.readFileSync(configPath, "utf8");
console.log(settings.length > 0 ? "Loaded config" : "Empty config");
Mistake 2: Using __dirname in an ES module
import path from "node:path";
// __dirname and __filename do not exist in ES module scope —
// this throws "ReferenceError: __dirname is not defined"
const configDir = path.join(__dirname, "config");
console.log(configDir);
__dirname and __filename are CommonJS-only globals injected by Node’s module wrapper; ES modules simply do not have them. In an ES module, derive the equivalent from import.meta.url, or use import.meta.dirname directly (stable since Node 20.11):
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Node 20.11+: import.meta.dirname is equivalent and simpler
console.log(import.meta.dirname === __dirname);
console.log(path.join(__dirname, "config", "settings.json"));
Other frequent mistakes: trusting path.join alone to make a request path safe (it does not remove .. segments the way you might expect once they are mixed with real segments — always normalize and verify containment as in Example 3); and calling path.extname on a path with no extension and forgetting it returns an empty string rather than undefined, which can silently break a switch or lookup table if not handled.
Best Practices
- Always build paths with
path.joinorpath.resolveinstead of string concatenation, so code works identically on POSIX and Windows. - Reach for
path.resolvewhen you specifically need an absolute path; usepath.joinwhen you just need to combine segments predictably. - Normalize and validate any path built from user input before touching the filesystem, to prevent path traversal attacks.
- In ES modules, replace
__dirname/__filenamewithimport.meta.dirname(Node 20.11+) orfileURLToPath(import.meta.url). - Use
path.extname()to branch on file type (for example, choosing aContent-Type) instead of parsing strings by hand. - Use
path.sepandpath.delimiterinstead of hardcoding/or:so code stays portable. - Reach for
path.posixorpath.win32only when you must force one platform’s rules regardless of the host OS, such as building URL-style paths.
Practice Exercises
- Write a function that takes an array of path segments and returns them joined into a single absolute path resolved relative to
__dirname. - Write a script that reads a file path from
process.argvand prints its directory, base name, extension, and name without extension usingpath.parse. - Extend the static file server from Example 3 by adding a manual test for a request like
/../../etc/passwdand confirm the server responds with 403 instead of leaking the file.
Summary
- The
pathmodule manipulates filesystem path strings without touching disk, and is platform-aware. path.joincombines segments and normalizes../., but does not guarantee an absolute result.path.resolvealways returns an absolute path, resolving right to left and falling back toprocess.cwd().path.parseandpath.formatconvert between full path strings and their component parts.- Always build paths with the
pathmodule, never string concatenation, for cross-platform correctness. - In ESM,
__dirname/__filenamedon’t exist — useimport.meta.dirnameorfileURLToPath. - Validate and normalize any path derived from user input before using it in
fsoperations, to avoid path traversal vulnerabilities.
