How-to › Describe your API

How to set style and explode on array and object query parameters#

Set style and explode on each array and object query parameter, serialize from the document in the client, and validate requests with a Prism mock.

Audience
API producer
Level
advanced
Topic
Write an OpenAPI description
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
deepObjectA filter object one level deep, read by a server whose parser builds objects from bracketsExpress 5 and FastAPI do not split brackets without configuration, generator support varies, and a nested value has no defined formThe object nests, or the server’s parser is not yours to configure
form with explode falseA short list you want readable in the URL, such as tags=urgent,q3The server receives one string and splits it in handler code, and a value containing a comma has no escapeValues can contain commas, or the parser already turns repeated keys into arrays
form with explode trueArrays of ids, and any list a server should receive as an array without configurationA 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 parsersURL length is the constraint, or the list is really a set of named fields
pipeDelimited or spaceDelimitedA server you also wrote, with a parser that splits on the delimiterFramework parsers hand the handler one string, so the handler splits it, and the delimiter has to be percent-encoded to stay inside RFC 3986Any 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.

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

  1. Swagger 2.0 described the same choice with one keyword, collectionFormat, whose values were csv, ssv, tsv, pipes, and multi. OpenAPI 3.0 replaced it with style and explode, and the 3.1 style values table still introduces four of its styles as the replacement for one of those values. tsv has no successor, so a tab-separated array is the one Swagger 2 shape that no 3.x document can describe. ↩︎ Back to text

  2. RFC 3986 lists [ and ] among the reserved gen-delims, kept for IPv6 literals in the host, and OpenAPI 3.2 says the deepObject delimiters 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 %5B and [ to the same key, so the rule is observed mostly in the breach. ↩︎ Back to text

  3. The extended parser is qs. By default it parses brackets five levels deep and keeps whatever sits past the fifth as a literal key. The specification knows one level of deepObject, so the two agree on filter[status] and part company on anything deeper. ↩︎ Back to text

  4. The form style is RFC 6570’s form-style query expansion, the ? operator, and explode is the * modifier from the same RFC. Appendix C of OpenAPI 3.2 works through the equivalences and then lists pipeDelimited, spaceDelimited, and deepObject as 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.