# 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.

Source: https://voxgig.com/howto/array-and-object-query-parameters

- Audience: api-producer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-17

## 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](https://spec.openapis.org/oas/v3.1.0.html#parameter-object) carries `style`
and `explode`, and the [style examples](https://spec.openapis.org/oas/v3.1.0.html#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`](https://spec.openapis.org/oas/v3.1.0.html#style-values) | 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](https://spec.openapis.org/oas/v3.1.0.html#style-examples) | 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](https://www.rfc-editor.org/rfc/rfc6570.html#section-3.2.8) | 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`](https://spec.openapis.org/oas/v3.1.0.html#style-values) | 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.

```json title="openapi.json"
{
  "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:

```bash
npx spectral lint openapi.json
```

```text output
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](https://openapi-ts.dev/openapi-fetch/api#queryserializer) 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.

```js title="serialize.mjs"
/** 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:

```js title="client.mjs"
/** 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)
}
```

```bash
node client.mjs
```

```text output
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](https://expressjs.com/en/5x/api/application/) 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`.

```js title="server.mjs"
/** 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()
  }
}
```

```bash
node server.mjs
```

```text output
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](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#query-parameter-list-multiple-values)
documents the repeated-key form for a `list` parameter and no bracket form, and
[Spring](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/requestparam.html)
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](https://github.com/stoplightio/prism/blob/master/docs/guides/02-request-validation.md)
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.

```js title="validate.mjs"
/** 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 }
}
```

```bash
node validate.mjs
```

```text output
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.

```bash
node --test query.test.mjs
```

```text output
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`.

```bash
npx spectral lint pitfall-content.json
```

```text output
 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:

```bash
node pitfall.mjs
```

```text output
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](https://spec.openapis.org/oas/v3.2.0.html#fixed-fields-for-use-with-schema) 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](https://expressjs.com/en/guide/migrating-5/) 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](https://spec.openapis.org/oas/v3.2.0.html#extending-support-for-querystring-formats)
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

- [Describe pagination so generators can follow it](/howto/describe-pagination-in-openapi-for-generators)

- [Map API parameters to CLI arguments and flags](/howto/map-api-parameters-to-cli-flags-and-arguments)

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

## 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.

[^1]: Swagger 2.0 described the same choice with one keyword,
[`collectionFormat`](https://swagger.io/specification/v2/), whose values were `csv`, `ssv`, `tsv`,
`pipes`, and `multi`. OpenAPI 3.0 replaced it with `style` and `explode`, and the 3.1
[style values](https://spec.openapis.org/oas/v3.1.0.html#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.

[^2]: RFC 3986 lists `[` and `]` among the
[reserved gen-delims](https://www.rfc-editor.org/rfc/rfc3986.html#section-2.2), kept for IPv6
literals in the host, and OpenAPI 3.2 says the `deepObject` delimiters
[MUST be percent-encoded](https://spec.openapis.org/oas/v3.2.0.html#percent-encoding-and-illegal-or-reserved-delimiters)
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.

[^3]: The `extended` parser is [`qs`](https://github.com/ljharb/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.

[^4]: The `form` style is RFC 6570's
[form-style query expansion](https://www.rfc-editor.org/rfc/rfc6570.html#section-3.2.8), the `?`
operator, and `explode` is the `*` modifier from the same RFC. Appendix C of
[OpenAPI 3.2](https://spec.openapis.org/oas/v3.2.0.html#appendix-c-using-rfc6570-based-serialization)
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.