Your client sends ?ids=inv_1,inv_2 and the server answers with an empty list, because its
parser read one id called inv_1,inv_2. A second client sends ?filter[status]=open and the
server ignores the filter, because nothing split the brackets. The document gives both parameters
a type but no style or explode, so each side chose a wire format of its own.
What you get
You will end up with a document that states each array and object parameter’s wire format, a client serializer that follows it, and a Prism mock that rejects what does not. This is for you if clients are generated from your OpenAPI document.
Short answer
Set style and explode on every array and object query parameter in the OpenAPI document, so
clients and servers build and parse the same URL. Use style: form with explode: true for
arrays, which Express, FastAPI, and Spring read as repeated keys without configuration. Use
style: deepObject for filter objects only where the server’s parser splits brackets. Then make
the client follow the document rather than its own default, and validate requests with a Prism
mock.
You will need
Node 22 or later, an OpenAPI 3.1 document, and a server whose query parser you can name. Verified
2026-10-21 against Node 22.22.0, openapi-fetch 0.17.0, express 5.3.0, @stoplight/prism-cli
5.14.2, and @stoplight/spectral-cli 6.17.0. The
Parameter Object carries style
and explode, and the style examples
table shows what each combination looks like on the wire.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
deepObject | A filter object one level deep, read by a server whose parser builds objects from brackets | Express 5 and FastAPI do not split brackets without configuration, generator support varies, and a nested value has no defined form | The object nests, or the server’s parser is not yours to configure |
| form with explode false | A short list you want readable in the URL, such as tags=urgent,q3 | The server receives one string and splits it in handler code, and a value containing a comma has no escape | Values can contain commas, or the parser already turns repeated keys into arrays |
| form with explode true | Arrays of ids, and any list a server should receive as an array without configuration | A long URL for a long list, since every value repeats the key, and a one-element list that arrives as a plain string in most parsers | URL length is the constraint, or the list is really a set of named fields |
pipeDelimited or spaceDelimited | A server you also wrote, with a parser that splits on the delimiter | Framework parsers hand the handler one string, so the handler splits it, and the delimiter has to be percent-encoded to stay inside RFC 3986 | Any client or server you do not control |
Exploded arrays cost URL length, but they are the default and the one style all three
frameworks here read as an array without configuration. The comma form saves bytes and hands
the server a string to split. deepObject reads well in a filter and works only where the server’s
parser builds objects from brackets, which Express 5 stopped doing by default. The two delimited
styles exist to carry Swagger 2 documents forward.
Declare the wire format on each parameter
style says how a value is laid out, and explode says whether an array or object spreads into
separate key=value pairs. For a query parameter the default is form with explode: true, so
an undeclared array is already the repeated-key form. Writing the two keys out anyway, on every
array and object, turns a reader’s guess into a rule a validator can check.
{
"name": "ids",
"in": "query",
"description": "Invoice ids. Repeat the key: ids=inv_1&ids=inv_2",
"style": "form",
"explode": true,
"schema": {
"type": "array",
"items": { "type": "string", "pattern": "^inv_[0-9]+$" },
"maxItems": 50
}
},
{
"name": "tags",
"in": "query",
"description": "Tags, comma separated: tags=urgent,q3",
"style": "form",
"explode": false,
"schema": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z0-9-]+$" }
}
},
{
"name": "filter",
"in": "query",
"description": "Field filters, one bracketed key per field: filter[status]=open&filter[customer]=cus_9",
"style": "deepObject",
"explode": true,
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["open", "paid", "void"] },
"customer": { "type": "string", "pattern": "^cus_[0-9]+$" }
},
"additionalProperties": false
}
},
The three parameters use three combinations. ids repeats the key, tags joins its values with
a comma, and filter spreads its properties into bracketed keys.2 A fourth parameter, sort,
is a plain string, where style has nothing to do. pipeDelimited and spaceDelimited complete
the list of query styles, and the server step shows why this document leaves them out. The
pattern on each item is what lets a mock tell a comma-joined string from a list.
Lint the document:
npx spectral lint openapi.json
No results with a severity of 'error' found!
The oas3-schema rule in Spectral checks the document against the specification’s own schema,
which is what catches the content mistake at the end of this page.
Serialize from the document, not from the client’s default
openapi-fetch serializes arrays as
form with explode: true and objects as deepObject, and its querySerializer option changes
that per client or per request. The option takes one rule for every array and one for every
object. The generated types carry no style or explode, so nothing in the client reads what the
document says about tags. A document that mixes styles needs a serializer that reads the
document, and the option’s function form accepts one.
/** The style and explode a query parameter has once the specification's defaults are applied. */
export function wireFormat(parameter) {
const style = parameter.style ?? 'form'
// Object.hasOwn, because `in` would also accept constructor or __proto__ from the prototype.
if (!Object.hasOwn(DELIMITER, style) && style !== 'deepObject') {
throw new Error(`${parameter.name}: ${style} is not a query parameter style`)
}
// The default is true for form and false for every other style. deepObject has no defined
// serialization with explode false, and the tools on this page serialize it exploded anyway.
const explode = parameter.explode ?? style === 'form'
return { style, explode }
}
/** One parameter's key=value pairs, both halves already percent-encoded. */
export function pairs(parameter, value) {
const { style, explode } = wireFormat(parameter)
const name = enc(parameter.name)
// null, undefined, and an empty list or object send nothing, as under openapi-fetch's defaults.
if (value == null || (typeof value === 'object' && Object.keys(value).length === 0)) return []
const item = (v) => scalar(parameter, v)
if (Array.isArray(value)) {
if (style === 'deepObject') throw new Error(`${parameter.name}: deepObject takes an object, not an array`)
if (style === 'form' && explode) return value.map((v) => [name, item(v)])
return [[name, value.map(item).join(DELIMITER[style])]]
}
if (typeof value === 'object') {
const entries = Object.entries(value)
if (style === 'deepObject') return entries.map(([key, v]) => [`${name}[${enc(key)}]`, item(v)])
if (style === 'form' && explode) return entries.map(([key, v]) => [enc(key), item(v)])
return [[name, entries.flatMap(([key, v]) => [enc(key), item(v)]).join(DELIMITER[style])]]
}
return [[name, enc(value)]]
}
/** A value inside an array or object, encoded. Nothing nested deeper has a wire format. */
function scalar(parameter, value) {
if (value === null || value === undefined || typeof value === 'object') {
throw new Error(`${parameter.name}: a nested ${value === null ? 'null' : typeof value} has no wire format`)
}
return enc(value)
}
querySerializerFor in the same file wraps pairs for one operation, following each $ref,
letting an operation parameter replace a path-level one, and refusing an undeclared value.
client.mjs builds the request with the default serializer and with that one, and captures the
URL instead of sending it:
/** The query string openapi-fetch would send for GET /invoices, captured instead of sent. */
export async function queryStringFor(values, options = {}) {
let sent
const client = createClient({
baseUrl: doc.servers[0].url,
fetch: async (request) => {
sent = request.url
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })
},
...options,
})
await client.GET('/invoices', { params: { query: values } })
return new URL(sent).search.slice(1)
}
node client.mjs
parameter openapi-fetch default serializer read from openapi.json
ids ids=inv_1&ids=inv_2 same
tags tags=urgent&tags=q3 tags=urgent,q3
filter filter[status]=open&filter[customer]=cus_9 same
sort sort=-created_at same
Three of the four parameters agree, because the client’s defaults are the document’s choices for
them. tags disagrees: the document says comma-joined, and the default serializer repeats the key.
Switching the whole client to explode: false would fix tags and break ids, which is the cost
of mixing array styles in one operation.
Check what the server’s parser receives
A parameter’s style is a promise about the server as much as the client.
Express has two query parsers: simple, which is
Node’s querystring module, and extended, which is the qs package. Express 5 made simple
the default. server.mjs starts an app under each and echoes req.query.
/** An Express app that answers GET /invoices with the object its query parser produced. */
export function createApp(queryParser = 'simple') {
const app = express()
app.set('query parser', queryParser)
app.get('/invoices', (req, res) => res.json(req.query))
return app
}
/** req.query for one query string under one parser, from a server on a free port. */
export async function parsedBy(queryParser, search) {
const server = createApp(queryParser).listen(0, '127.0.0.1')
await new Promise((resolve) => server.once('listening', resolve))
try {
const res = await fetch(`http://127.0.0.1:${server.address().port}/invoices?${search}`)
return await res.json()
} finally {
server.close()
}
}
node server.mjs
express 5.3.0, query parser simple (the default) and extended
?ids=inv_1&ids=inv_2
simple {"ids":["inv_1","inv_2"]}
extended {"ids":["inv_1","inv_2"]}
?tags=urgent,q3
simple {"tags":"urgent,q3"}
extended {"tags":"urgent,q3"}
?filter[status]=open&filter[customer]=cus_9
simple {"filter[status]":"open","filter[customer]":"cus_9"}
extended {"filter":{"status":"open","customer":"cus_9"}}
?ids=inv_1|inv_2
simple {"ids":"inv_1|inv_2"}
extended {"ids":"inv_1|inv_2"}
?ids=inv_1%20inv_2
simple {"ids":"inv_1 inv_2"}
extended {"ids":"inv_1 inv_2"}
Repeated keys become an array under both parsers, which is why form with explode: true is the
safe default, until a list passes 20 values under extended. The comma form arrives as one string
either way, and the handler splits it. Brackets become a nested object only under extended; the
default parser hands the handler a key named filter[status].3 The pipe and the space forms
arrive as one string under both parsers, because neither parser treats those characters as a
delimiter.
The other two frameworks behave like the default parser.
FastAPI
documents the repeated-key form for a list parameter and no bracket form, and
Spring
resolves repeated keys into a List when the argument is declared as one. Both read exploded
arrays without configuration.
Validate requests against the document with Prism
Prism
mocks the document and validates every request against it, answering a request that does not fit
with a 422 and a list of violations. validate.mjs starts it on a free port and sends each wire
format.
/** The status Prism answers one query string with, and its validation messages on a 422. */
export async function check(port, search) {
const res = await fetch(`http://127.0.0.1:${port}/invoices?${search}`)
const body = await res.json()
const problems = Array.isArray(body.validation)
? body.validation.map((v) => `${v.location.join('.')}: ${v.message}`)
: []
return { status: res.status, problems }
}
node validate.mjs
200 ?ids=inv_1&ids=inv_2&tags=urgent,q3&filter[status]=open&filter[customer]=cus_9&sort=-created_at
422 ?ids=inv_1,inv_2
query.ids.0: Request query parameter ids.0 must match pattern "^inv_[0-9]+$"
422 ?ids=inv_1|inv_2
query.ids.0: Request query parameter ids.0 must match pattern "^inv_[0-9]+$"
422 ?tags=urgent,Q3
query.tags.1: Request query parameter tags.1 must match pattern "^[a-z0-9-]+$"
422 ?filter[status]=overdue
query.filter.status: Request query parameter filter.status must be equal to one of the allowed values: open, paid, void
422 ?sort=created_at,total
query.sort: Request query parameter sort must be equal to one of the allowed values: created_at, -created_at, total, -total
The documented form passes. ids=inv_1,inv_2 fails because the parameter is exploded, so the
whole string inv_1,inv_2 is one id, and it fails the pattern. The pipe form fails the same way.
The tags line shows the comma form at work: Prism split on the comma, and the second value
failed the lowercase pattern. The filter and sort lines are plain enum checks. With type: string alone on the items, the comma form would have passed as one odd id.
Check it worked
Nine tests run the serializer, openapi-fetch, both Express parsers, two Prism mocks, and Spectral.
node --test query.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7347.531072
The second test pins the disagreement: openapi-fetch’s default sends tags=urgent&tags=q3, and
the serializer built from the document sends tags=urgent,q3. The fourth asserts that Prism also
accepts tags=urgent&tags=q3, because both values pass the item pattern on their own, so the
client test is the one that catches a wrong shape.
When it goes wrong
A validator rejects the document after you add content to a parameter. The specification says a
parameter holds either schema or content and never both, and style and explode belong to
the schema form. pitfall-content.json gives tags both keys and gives filter a content
map beside its style.
npx spectral lint pitfall-content.json
48:11 error oas3-schema "1" property must match exactly one schema in oneOf. paths./invoices.get.parameters[1]
73:11 error oas3-schema "2" property must not have unevaluated properties. paths./invoices.get.parameters[2]
✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
Pick one form. A content parameter carries a media type, usually application/json, and the
tools that support it expect the whole value JSON-encoded under one key.
The brackets work with no explode key, which hides a gap in the document. The 3.1 default for
every style other than form is false, and the specification’s table has no row for
deepObject with explode: false. Prism reads the bracketed form regardless:
node pitfall.mjs
filter without an explode key: style deepObject, explode false by the 3.1 default
200 ?filter[status]=open
422 ?filter[status]=overdue
query.filter.status: Request query parameter filter.status must be equal to one of the allowed values: open, paid, void
openapi-fetch does the same and says so in its documentation, and
OpenAPI 3.2 made it
official: for deepObject, explode “has no effect” on the wire format. Write explode: true
anyway, so a reader of the 3.1 document and the tools agree.
req.query.filter is undefined after an upgrade to Express 5. The
migration guide lists the change: the default query
parser moved from extended to simple, so bracketed keys stopped nesting. Set
app.set('query parser', 'extended'), or stop describing the filter as deepObject.
extended turns more than 20 values into an object keyed by index, so ids needs
(query) => qs.parse(query, { arrayLimit: 50 }) as its parser.
When not to do this
Do not describe a nested filter with deepObject. The specification defines one level of
brackets, and OpenAPI 3.2
says that framework query syntax for indices and deeper nesting goes “well beyond the
capabilities” of deepObject. Put a nested filter in a JSON-encoded content parameter, or move
the search to a POST body.
Do not use pipeDelimited or spaceDelimited on an API other people call. They survive from
Swagger 2’s collectionFormat,4 and the two Express parsers, FastAPI, and Spring all hand the
handler one string. Every client of yours then needs a serializer that knows the delimiter.
Do not mix array styles inside one operation when clients are generated. openapi-fetch takes one
array rule per client, and OpenAPI Generator’s typescript-fetch feature table lists collection
formats as Swagger 2 features and says nothing about deepObject. One style for every array in
the API is the cheapest rule to keep.
Do not take a passing mock for proof of the shape. Prism accepted tags=urgent&tags=q3 for a
parameter declared explode: false, because each value passed the pattern on its own. The mock
checks values against the schema, and only a client test that compares URLs checks the
serialization.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, openapi-fetch 0.17.0, express 5.3.0,
@stoplight/prism-cli 5.14.2, and @stoplight/spectral-cli 6.17.0. Every output block is what
the command preceding it printed, run in the page’s code directory after npm ci. FastAPI,
Spring, and OpenAPI Generator were not run; what the page says about them is read from the
documentation it links.
Footnotes
-
Swagger 2.0 described the same choice with one keyword,
collectionFormat, whose values werecsv,ssv,tsv,pipes, andmulti. OpenAPI 3.0 replaced it withstyleandexplode, and the 3.1 style values table still introduces four of its styles as the replacement for one of those values.tsvhas no successor, so a tab-separated array is the one Swagger 2 shape that no 3.x document can describe. ↩︎ Back to text -
RFC 3986 lists
[and]among the reserved gen-delims, kept for IPv6 literals in the host, and OpenAPI 3.2 says thedeepObjectdelimiters MUST be percent-encoded to comply with it. openapi-fetch writes them bare, as the 3.1 examples table does, and both Express parsers decode%5Band[to the same key, so the rule is observed mostly in the breach. ↩︎ Back to text -
The
extendedparser isqs. By default it parses brackets five levels deep and keeps whatever sits past the fifth as a literal key. The specification knows one level ofdeepObject, so the two agree onfilter[status]and part company on anything deeper. ↩︎ Back to text -
The
formstyle is RFC 6570’s form-style query expansion, the?operator, andexplodeis the*modifier from the same RFC. Appendix C of OpenAPI 3.2 works through the equivalences and then listspipeDelimited,spaceDelimited, anddeepObjectas the styles that “have no equivalents at all” in the template language. Of the four query styles, one is a URI Template expansion and three are habits the template language never had. ↩︎ Back to text