cheat sheet

qs

Package-level reference for qs on npm — nested objects, arrays, allowDots, prototype-pollution history, and URLSearchParams comparison.

qs

What it is

qs is the long-running query-string parser/stringifier for Node.js and the browser. It's the canonical choice when query strings need to encode nested objects or arrays — something the platform's URLSearchParams doesn't do.

Express, Axios (until v1), Restify, and most older REST API clients use qs to parse req.query and build outgoing query strings. The package handles bracket notation (a[b]=1&a[c]=2{ a: { b: "1", c: "2" } }), dot notation (a.b=1 with allowDots: true), and multiple array formats (a[0]=1&a[1]=2, a[]=1&a[]=2, a=1&a=2).

It's been the source of multiple prototype-pollution CVEs — modern versions (^6.10) are patched, but version pinning matters here more than for most packages.

Install

bash
# npm / pnpm / yarn / bun
npm install qs
pnpm add qs
yarn add qs
bun add qs

Output: runtime dep. ~9 KB gzipped.

bash
# TypeScript declarations are separate
npm install --save-dev @types/qs

Output: DefinitelyTyped declarations. Required for TS projects.

bash
# CLI (uncommon)
npx qs '?a[b]=1&a[c]=2'

Output: qs doesn't ship a binary; use Node repl or node -e for one-off parses.

Versioning & Node support

  • Current major line is 6.x (stable since 2014, ongoing security patches). Internal API has evolved but the public surface — qs.parse, qs.stringify — has been compatible for over a decade.
  • Pure JS; runs on Node 0.12+ (yes, really — kept extremely backward-compatible), Bun, Deno, Cloudflare Workers, browsers (ES5 baseline).
  • CJS-first. ESM importable via the dual-export shim: import qs from "qs" works in both.
  • Always a runtime dependency — your code calls qs.parse/qs.stringify.
  • Strict semver — major bumps in qs typically clamp permissive parsing to safer defaults.

Package metadata

  • Maintainer: Jordan Harband (@ljharb) + the ljharb org
  • Project home: github.com/ljharb/qs
  • Docs: github.com/ljharb/qs#readme
  • npm: npmjs.com/package/qs
  • License: BSD-3-Clause
  • First released: 2011
  • Downloads: ~100 million per week — top-15 package on npm

Peer dependencies & extras

Zero runtime dependencies.

PackagePurpose
@types/qsTS declarations.
query-stringSmaller alt (~3 KB). Less feature-rich; no nested objects.
qs-liteStripped-down fork (rare).
urlcatHigher-level URL builder; uses qs under the hood (older versions).

Alternatives

LibraryTrade-off
URLSearchParams (built-in)Zero-dep. Doesn't handle nested objects or arrays — flat keys only. Pick when your query strings are flat.
query-string~3 KB; flat keys + simple array formats. Doesn't do nested objects. Pick when bundle size matters and nesting isn't needed.
fast-querystring~2 KB, faster than querystring. Flat keys. Pick for performance-critical Node paths.
picoqueryNew, very small (~700 bytes). Handles nesting via dot notation. Modern alt.
builtin querystringDeprecated in Node, do not use. URLSearchParams replaces it.

Common gotchas

  1. Default parsing limit is 1000 keys. Beyond that, qs.parse silently truncates. For large query strings, pass parameterLimit: Infinity (with security caveats — see Security below).
  2. qs.parse("?a=1") includes the ? — produces { '?a': '1' }. Strip the leading ? (or use a URL-parsing step first).
  3. null vs empty string. qs.stringify({ a: null })"a=" (empty), not omitted. Use skipNulls: true to drop null values.
  4. Default array format is indices (a[0]=1&a[1]=2). Most APIs expect repeat (a=1&a=2) or brackets (a[]=1&a[]=2). Specify arrayFormat explicitly.
  5. allowPrototypes: false is the default since 6.10. Older versions allowed __proto__ keys — the prototype-pollution attack surface. Don't override this default unless you understand the risk.
  6. Nested arrays/objects encode as bracket-paths. { a: [{ b: 1 }] }a[0][b]=1. Many APIs reject the brackets — match the API's expected format.

Real-world recipes

Parse a query string

typescript
import qs from "qs";

const parsed = qs.parse("user[name]=Alice&user[age]=30&tags[]=admin&tags[]=user");
console.log(parsed);

Output:

json
{
  "user":  { "name": "Alice", "age": "30" },
  "tags":  ["admin", "user"]
}

Default behaviour: bracket notation for nested objects; [] for repeated arrays. Values are always strings — coerce types separately.

Stringify a nested object

typescript
const obj = {
  filter: { status: "active", role: "admin" },
  page: 1,
  tags: ["urgent", "billing"],
};

console.log(qs.stringify(obj));
console.log(qs.stringify(obj, { arrayFormat: "repeat" }));
console.log(qs.stringify(obj, { arrayFormat: "brackets" }));
console.log(qs.stringify(obj, { arrayFormat: "comma" }));

Output:

text
filter%5Bstatus%5D=active&filter%5Brole%5D=admin&page=1&tags%5B0%5D=urgent&tags%5B1%5D=billing
filter%5Bstatus%5D=active&filter%5Brole%5D=admin&page=1&tags=urgent&tags=billing
filter%5Bstatus%5D=active&filter%5Brole%5D=admin&page=1&tags%5B%5D=urgent&tags%5B%5D=billing
filter%5Bstatus%5D=active&filter%5Brole%5D=admin&page=1&tags=urgent%2Cbilling

Each arrayFormat matches a different API style. Most modern REST APIs use repeat; older Rails/PHP servers use brackets; some custom APIs use comma.

Parse with allowDots — dot notation for nested keys

typescript
const parsed = qs.parse("user.name=Alice&user.age=30", { allowDots: true });
console.log(parsed);

const cyclic = qs.parse("a.b.c=deep", { allowDots: true });
console.log(cyclic);

Output:

json
{ "user": { "name": "Alice", "age": "30" } }
{ "a": { "b": { "c": "deep" } } }

allowDots: true makes dotted keys equivalent to bracket-keys. Useful for hand-crafted URLs (less ugly than %5B...%5D).

Array format options reference

typescript
const data = { tags: ["a", "b", "c"] };

const formats = ["indices", "brackets", "repeat", "comma"] as const;
for (const fmt of formats) {
  console.log(`${fmt}: ${qs.stringify(data, { arrayFormat: fmt, encode: false })}`);
}

Output:

text
indices:  tags[0]=a&tags[1]=b&tags[2]=c
brackets: tags[]=a&tags[]=b&tags[]=c
repeat:   tags=a&tags=b&tags=c
comma:    tags=a,b,c

encode: false here shows the raw bracket characters; in production keep encoding on.

Round-trip parse + stringify

typescript
const orig = "filter[status]=active&tags=a&tags=b&page=2";

const parsed = qs.parse(orig);
const restored = qs.stringify(parsed, { arrayFormat: "repeat", encode: false });

console.log(parsed);
console.log(restored);

Output:

text
{ filter: { status: "active" }, tags: ["a", "b"], page: "2" }
filter[status]=active&tags=a&tags=b&page=2

Round-trip works perfectly when arrayFormat is consistent and types are strings.

Strict null vs empty handling

typescript
const data = { name: "Alice", age: null, email: "", tags: [] };

console.log(qs.stringify(data));                       // includes null=""
console.log(qs.stringify(data, { skipNulls: true }));  // drops null
console.log(qs.stringify(data, { strictNullHandling: true })); // distinguish null

Output:

text
name=Alice&age=&email=&tags=
name=Alice&email=&tags=
name=Alice&age&email=&tags=

strictNullHandling lets you round-trip null distinctly from empty string — useful when the consumer cares about the difference.

Limit-bounded parse for untrusted input

typescript
const input = "...maliciously-long-querystring...";

const safe = qs.parse(input, {
  parameterLimit: 1000,
  arrayLimit: 100,
  depth: 5,
  allowPrototypes: false,
  parseArrays: false,
});

Output: explicit bounds prevent DoS via deeply-nested or huge query strings. Always set these for untrusted input.

Production deployment

Use the right arrayFormat for your API

API consistency matters more than which format you pick. Common choices:

API stylearrayFormat
Modern REST (Stripe, GitHub)repeat
Older Rails / PHPbrackets
OData / odd APIscomma
Internal toolsindices

Wrap calls in a helper:

typescript
// src/lib/qs.ts
import qs from "qs";

export const stringify = (obj: any) => qs.stringify(obj, { arrayFormat: "repeat" });
export const parse = (s: string) => qs.parse(s.replace(/^\?/, ""), { allowPrototypes: false });

Edge runtime compatibility

qs is pure JS — works on Cloudflare Workers, Vercel Edge, Bun, Deno. No platform-specific code.

Bundle size

~9 KB gzipped. For lighter use, query-string (~3 KB) or native URLSearchParams (0 KB) suffice when nesting isn't needed.

Performance tuning

URLSearchParams for flat keys

Flat key parsing with URLSearchParams is 5-10× faster than qs. Use it when your query strings are flat:

typescript
const params = new URLSearchParams("a=1&b=2&c=3");
const obj = Object.fromEntries(params);

Limits prevent quadratic blowups

qs.parse(deeplyNested) is O(depth × keys). Without depth and parameterLimit, a malicious input can hang the event loop. Always set bounds for untrusted input.

Pre-encode keys

qs.stringify calls encodeURIComponent per key+value. For frequently-stringified shapes, cache the result:

typescript
import qs from "qs";
import { LRUCache } from "lru-cache";

const cache = new LRUCache<string, string>({ max: 1000 });

function fastStringify(obj: object) {
  const key = JSON.stringify(obj);
  let s = cache.get(key);
  if (!s) {
    s = qs.stringify(obj, { arrayFormat: "repeat" });
    cache.set(key, s);
  }
  return s;
}

For hot paths this can be 10× faster than re-stringifying.

Version migration guide

v5 → v6 (the big break)

  • null and empty string distinction added. strictNullHandling opt-in.
  • allowPrototypes defaults to false since 6.10 — __proto__, constructor, prototype keys ignored. Critical security default.
  • charset and interpretNumericEntities options added for HTML-form decoding.
  • encoder/decoder callbacks can be custom per-call.

v6.x internal milestones

  • 6.5.3 (2018) — first major prototype-pollution patch (CVE-2017-1000048).
  • 6.6.0 — added arrayFormat: "comma".
  • 6.9.0allowDots polish.
  • 6.10+allowPrototypes: false default.
  • 6.11+ — performance improvements to parse loop.

Stay on ^6.11.0 or newer

Pin qs to a version that includes the prototype-pollution patches. Older versions are exploitable when parsing untrusted query strings.

json
"dependencies": { "qs": "^6.11.0" }

Many transitive dependencies still pull old qs. Audit with npm ls qs.

No v7 announced

qs is in maintenance mode — bug fixes and CVE patches only. There's no v7 roadmap. For new code on modern Node, consider URLSearchParams first.

Security considerations

qs's CVE history is the most concerning of any major npm package — multiple prototype-pollution and DoS issues.

CVEYearNotes
CVE-2014-71912014DoS via deeply nested input
CVE-2017-10000482017Prototype pollution via __proto__
CVE-2022-249992022Prototype pollution in __proto__.constructor
VariousongoingPerformance-DoS issues fixed in 6.10+

Rules:

  1. Pin qs to ^6.11.0 or newer. Audit transitive pins with npm ls qs. Older versions are exploitable.
  2. allowPrototypes: false is the default since 6.10 — don't override unless you have a hard requirement, and you understand the risk.
  3. Bound the parse for untrusted input:
    typescript
    qs.parse(input, {
      parameterLimit: 1000,
      arrayLimit: 100,
      depth: 5,
      allowPrototypes: false,
      parseArrays: false,
    });
    
  4. Don't feed qs.parse results into deep-set / deep-merge helpers. Even with allowPrototypes: false, attacker-controlled keys can confuse downstream code. Filter against an allow-list of expected keys.
  5. Express's req.query uses qs by default. Express ≥4.18 ships a patched qs. Older Express versions in production are still vulnerable — upgrade.
  6. Bundle size: ensure no double-qs from old transitive deps. Yarn resolutions / npm overrides to pin the entire tree to one safe version.

Testing & CI integration

typescript
import { describe, it, expect } from "vitest";
import qs from "qs";

describe("qs", () => {
  it("parses bracket-nested objects", () => {
    expect(qs.parse("a[b]=1&a[c]=2")).toEqual({ a: { b: "1", c: "2" } });
  });

  it("stringifies with repeat arrayFormat", () => {
    expect(qs.stringify({ tags: ["a", "b"] }, { arrayFormat: "repeat", encode: false }))
      .toBe("tags=a&tags=b");
  });

  it("rejects __proto__ keys by default", () => {
    const parsed = qs.parse("__proto__[polluted]=true");
    expect(({} as any).polluted).toBeUndefined();   // prototype not polluted
  });
});

Output:

text
 PASS  qs > parses bracket-nested objects
 PASS  qs > stringifies with repeat arrayFormat
 PASS  qs > rejects __proto__ keys by default

For CI, run npm audit --production to catch new CVEs.

Ecosystem integrations

ToolIntegration
expressreq.query uses qs (configurable via app.set("query parser", ...))
axios (≤ 0.x, optional in 1+)Configurable as the params serializer
restifyUses qs for query parsing
koactx.query uses URLSearchParams by default, but koa-qs swaps in qs
fastifyURLSearchParams by default; qs available as opt-in
next.jsuseSearchParams() returns URLSearchParams — use qs client-side for nested
react-routerURLSearchParams under the hood; pair with qs for nested filters

Troubleshooting common errors

  • qs.parse("?a=1") produces { '?a': '1' } — strip the leading ? (s.replace(/^\?/, "")).
  • Nested array drops elementsarrayLimit defaults to 20. Increase or use repeat format.
  • Prototype pollution warning in audit — upgrade qs to ^6.11.0 or newer.
  • Query string is way too long after stringify — using indices format with large arrays. Switch to repeat or brackets.
  • TS error Cannot find module 'qs' — install @types/qs.
  • Encoded [ and ] make URL ugly — set encode: false if your destination doesn't require strict encoding, or use allowDots: true for cleaner output.

When NOT to use this

  • Your query strings are flat. Use URLSearchParams — built in, zero-dep, faster.
  • Bundle size critical, simple arrays only. Use query-string (~3 KB) or fast-querystring.
  • You don't trust the input source. Strict bounds (depth, parameterLimit, arrayLimit) or switch to URLSearchParams with manual nesting.
  • API spec uses JSON in the body. Don't shoehorn into query strings; use JSON POST bodies.
  • Edge runtime, every byte counts. URLSearchParams is the only zero-cost option.

See also