mrkeyoor.com_
Thu 06 Aug 07:43 UTC
npmUtilsupdated 06 Aug 2026

unzipper

unzipper reads ZIP archives in Node without loading them into memory. It comes in two halves that people constantly mix up. The Open API reads the archive's central directory first, which is the index at the end of the file, then gives you a list of entries you can stream or buffer individually, in any order. That is the half you want, and it works over a file handle, an S3 object, an HTTP range request, or any source you describe yourself. The older Parse API pipes the whole archive through a transform stream and emits entries as they go past, which is what you use when the bytes arrive as a stream and you never get to seek. Decompression is handled by Node's built-in zlib, so there is nothing to compile.

Verdict

For pulling selected files out of big archives without buffering them, the Open API is still the most practical thing on npm, and the streaming design earns its keep. The dependency list, missing types, and slow release cadence are the price, so if all you do is read a small local ZIP, yauzl or node-stream-zip is a lighter answer.

API stability4/5Parse, ParseOne, Extract, and Open have kept the same signatures for years, and 0.12 only swapped internal dependencies. The pending ESM change on master is the first thing in a while that could break require-based code.
Docs3/5The README is a decent tour with working examples for each Open method, but it documents no s3_v3 helper, still shows the deprecated request library for Open.url, and there is no API reference beyond it.
Maintenance2/5Commits landed in July 2026 and 0.12.5 went to npm in June 2026, but 73 issues are open, GitHub releases stopped at 0.12.3 in 2024, and work is essentially one person's occasional attention.
Ecosystem4/5About 18.5M weekly downloads, mostly as a transitive dependency of build and deployment tooling, so it is battle-tested against odd real-world archives. Types live outside the package in @types/unzipper.

Use it if

  • You want one or two files out of a large archive and cannot afford to download or decompress the rest; Open.file and Open.s3_v3 use range reads and touch only the bytes those entries occupy
  • The archive arrives as a stream you cannot rewind, such as an HTTP upload or a pipe, and you need to handle entries as they pass through with Parse
  • You are extracting user-supplied ZIPs on a server where memory is the binding constraint, since nothing here buffers the whole archive the way adm-zip does
  • You need to read a ZIP that lives in object storage and want the central directory fetched with range requests instead of pulling the entire object down first
Skip it if

Setup reality

npm install unzipper works with no native build step, and if you use TypeScript you also want npm install --save-dev @types/unzipper. The friction is in the shape of the API rather than the install. Open.url does not accept fetch or axios; it wants a callable with the old request library's interface, one that returns a stream, emits a response event, and has an abort method, so in practice you either keep a legacy client around or write an Open.custom source instead. Open.s3 targets aws-sdk v2 while Open.s3_v3 targets @aws-sdk/client-s3, and the v3 helper is not mentioned in the README at all. In the Parse API, every entry you do not consume must have autodrain() called on it or the stream stalls forever with no error, which is the single most common way people get stuck.

Patterns

Read the archive index without extractinglist-entries

const unzipper = require("unzipper");

const directory = await unzipper.Open.file("archive.zip");
for (const file of directory.files) {
  console.log(file.path, file.type, file.uncompressedSize);
}

This reads only the central directory at the tail of the file, so listing a multi-gigabyte archive costs a couple of small reads. file.type is "File" or "Directory".

Pull a single file out by nameextract-one-entry

const directory = await unzipper.Open.file("archive.zip");
const entry = directory.files.find((f) => f.path === "data/report.csv");
if (!entry) throw new Error("not in archive");

const content = await entry.buffer();
console.log(content.toString("utf8"));

Only that entry's compressed bytes are read and inflated. Paths inside a ZIP always use forward slashes regardless of the platform that created the archive.

Stream one entry straight to a filestream-entry-to-disk

const { pipeline } = require("node:stream/promises");
const fs = require("node:fs");

const directory = await unzipper.Open.file("archive.zip");
await pipeline(
  directory.files[0].stream(),
  fs.createWriteStream("out.bin"),
);

Use stream() rather than buffer() for anything large. Wrapping it in pipeline gives you error propagation, which piping by hand does not.

Extract the whole archive to a directoryextract-all

const directory = await unzipper.Open.file("archive.zip");
await directory.extract({ path: "/tmp/out", concurrency: 5 });

concurrency defaults to 1, which is slow for archives with many small files. This is the Open-based extract; the older unzipper.Extract({ path }) stream form needs an absolute path.

Handle entries from a stream you cannot seekstream-parse-entries

fs.createReadStream("archive.zip")
  .pipe(unzipper.Parse())
  .on("entry", (entry) => {
    if (entry.path === "wanted.txt") {
      entry.pipe(fs.createWriteStream("wanted.txt"));
    } else {
      entry.autodrain();
    }
  });

Every entry you skip must get autodrain(), or the stream halts with no error and your process just hangs. Parse also relies on local file headers, which some writers fill in wrongly, so prefer Open when you can seek.

Iterate entries with for awaitasync-iterate-entries

const zip = fs.createReadStream("archive.zip")
  .pipe(unzipper.Parse({ forceStream: true }));

for await (const entry of zip) {
  if (entry.path.endsWith(".json")) {
    const body = await entry.buffer();
    handle(JSON.parse(body.toString()));
  } else {
    entry.autodrain();
  }
}

forceStream: true is required, otherwise Parse behaves as an event emitter and the loop never receives anything. The autodrain rule still applies inside the loop.

Read a ZIP sitting in S3open-s3-object

const { S3Client } = require("@aws-sdk/client-s3");
const client = new S3Client({ region: "eu-west-1" });

const directory = await unzipper.Open.s3_v3(client, {
  Bucket: "my-bucket",
  Key: "archive.zip",
});
const csv = await directory.files.find((f) => f.path.endsWith(".csv")).buffer();

s3_v3 is for @aws-sdk/client-s3 and is not documented in the README; the older Open.s3 expects an aws-sdk v2 client. Both issue ranged GetObject calls, so you pay for a few kilobytes rather than the whole object.

Read from any storage with a custom sourcecustom-source

const source = {
  size: async () => (await head(key)).contentLength,
  stream: (offset, length) =>
    createRangeStream(key, offset, length && offset + length),
};

const directory = await unzipper.Open.custom(source);

This is the escape hatch when Open.url will not take your HTTP client. You supply size() returning a promise and stream(offset, length) returning a Readable; unzipper does the rest.

Open an archive already in memoryopen-buffer

const directory = await unzipper.Open.buffer(zipBuffer);
const names = directory.files.map((f) => f.path);

Handy for archives arriving in a request body. Remember the whole archive is already resident, so the streaming advantage is gone and the memory ceiling is yours to enforce.

Read an encrypted entrypassword-protected

const directory = await unzipper.Open.file("secret.zip");
const entry = directory.files.find((f) => f.path === "payload.txt");
const content = await entry.buffer("hunter2");

The password goes to stream() or buffer(), not to Open. A wrong password does not always fail cleanly; you can get garbage bytes or a zlib error rather than a clear message.

Sanitize entry paths before writing themprevent-zip-slip

const path = require("node:path");

const root = path.resolve("/tmp/out");
const target = path.resolve(root, entry.path);

if (target !== root && !target.startsWith(root + path.sep)) {
  entry.autodrain();
  throw new Error(`refusing path traversal: ${entry.path}`);
}

unzipper had an arbitrary file write advisory, GHSA-884w-698f-927f, fixed back in 0.8.13, and the built-in extract guards itself. If you write entry.path yourself from the Parse API, that check is your job.

Decode filenames written by old Windows toolslegacy-filename-encoding

const il = require("iconv-lite");

.on("entry", (entry) => {
  const name = entry.props.flags.isUnicode
    ? entry.path
    : il.decode(entry.props.pathBuffer, "cp866");
  // ...
});

Archives from DOS-era and some Windows tools store names in an OEM code page. entry.props.pathBuffer keeps the raw bytes so you can decode with the right charset instead of getting mojibake.

Alternatives

PackageRegistryPick it when
yauzlnpmYou want a small, carefully specified ZIP reader with explicit control over every entry and no promise-library dependencies.
adm-zipnpmSmall archives where a synchronous, buffer-everything API is simpler than streams and memory is not a concern.
node-stream-zipnpmRandom access to entries with a dependency-free implementation and both callback and promise APIs.
archivernpmYou need to create ZIP or TAR archives rather than read them.