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
# npm / pnpm / yarn / bun
npm install qs
pnpm add qs
yarn add qs
bun add qs
Output: runtime dep. ~9 KB gzipped.
# TypeScript declarations are separate
npm install --save-dev @types/qs
Output: DefinitelyTyped declarations. Required for TS projects.
# 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
qstypically 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.
| Package | Purpose |
|---|---|
@types/qs | TS declarations. |
query-string | Smaller alt (~3 KB). Less feature-rich; no nested objects. |
qs-lite | Stripped-down fork (rare). |
urlcat | Higher-level URL builder; uses qs under the hood (older versions). |
Alternatives
| Library | Trade-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. |
| picoquery | New, very small (~700 bytes). Handles nesting via dot notation. Modern alt. |
builtin querystring | Deprecated in Node, do not use. URLSearchParams replaces it. |
Common gotchas
- Default parsing limit is 1000 keys. Beyond that,
qs.parsesilently truncates. For large query strings, passparameterLimit: Infinity(with security caveats — see Security below). qs.parse("?a=1")includes the?— produces{ '?a': '1' }. Strip the leading?(or use a URL-parsing step first).nullvs empty string.qs.stringify({ a: null })→"a="(empty), not omitted. UseskipNulls: trueto drop null values.- Default array format is
indices(a[0]=1&a[1]=2). Most APIs expectrepeat(a=1&a=2) orbrackets(a[]=1&a[]=2). SpecifyarrayFormatexplicitly. allowPrototypes: falseis 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.- 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
import qs from "qs";
const parsed = qs.parse("user[name]=Alice&user[age]=30&tags[]=admin&tags[]=user");
console.log(parsed);
Output:
{
"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
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:
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
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:
{ "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
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:
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
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:
{ 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
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:
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
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 style | arrayFormat |
|---|---|
| Modern REST (Stripe, GitHub) | repeat |
| Older Rails / PHP | brackets |
| OData / odd APIs | comma |
| Internal tools | indices |
Wrap calls in a helper:
// 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:
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:
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)
nulland empty string distinction added.strictNullHandlingopt-in.allowPrototypesdefaults tofalsesince 6.10 —__proto__,constructor,prototypekeys ignored. Critical security default.charsetandinterpretNumericEntitiesoptions added for HTML-form decoding.encoder/decodercallbacks 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.0 —
allowDotspolish. - 6.10+ —
allowPrototypes: falsedefault. - 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.
"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.
| CVE | Year | Notes |
|---|---|---|
| CVE-2014-7191 | 2014 | DoS via deeply nested input |
| CVE-2017-1000048 | 2017 | Prototype pollution via __proto__ |
| CVE-2022-24999 | 2022 | Prototype pollution in __proto__.constructor |
| Various | ongoing | Performance-DoS issues fixed in 6.10+ |
Rules:
- Pin
qsto^6.11.0or newer. Audit transitive pins withnpm ls qs. Older versions are exploitable. allowPrototypes: falseis the default since 6.10 — don't override unless you have a hard requirement, and you understand the risk.- Bound the parse for untrusted input:
qs.parse(input, { parameterLimit: 1000, arrayLimit: 100, depth: 5, allowPrototypes: false, parseArrays: false, }); - Don't feed
qs.parseresults into deep-set / deep-merge helpers. Even withallowPrototypes: false, attacker-controlled keys can confuse downstream code. Filter against an allow-list of expected keys. - Express's
req.queryusesqsby default. Express ≥4.18 ships a patchedqs. Older Express versions in production are still vulnerable — upgrade. - Bundle size: ensure no double-
qsfrom old transitive deps. Yarn resolutions / npmoverridesto pin the entire tree to one safe version.
Testing & CI integration
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:
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
| Tool | Integration |
|---|---|
express | req.query uses qs (configurable via app.set("query parser", ...)) |
axios (≤ 0.x, optional in 1+) | Configurable as the params serializer |
restify | Uses qs for query parsing |
koa | ctx.query uses URLSearchParams by default, but koa-qs swaps in qs |
fastify | URLSearchParams by default; qs available as opt-in |
next.js | useSearchParams() returns URLSearchParams — use qs client-side for nested |
react-router | URLSearchParams 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 elements —
arrayLimitdefaults to 20. Increase or userepeatformat. - Prototype pollution warning in audit — upgrade
qsto^6.11.0or newer. - Query string is way too long after stringify — using
indicesformat with large arrays. Switch torepeatorbrackets. - TS error
Cannot find module 'qs'— install@types/qs. - Encoded
[and]make URL ugly — setencode: falseif your destination doesn't require strict encoding, or useallowDots: truefor 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) orfast-querystring. - You don't trust the input source. Strict bounds (
depth,parameterLimit,arrayLimit) or switch toURLSearchParamswith manual nesting. - API spec uses JSON in the body. Don't shoehorn into query strings; use JSON POST bodies.
- Edge runtime, every byte counts.
URLSearchParamsis the only zero-cost option.
See also
- JavaScript: fetch — building requests with URLSearchParams
- Concept: HTTP — query string in URL structure
- Concept: JSON — nested structures over the wire