How-to › Describe your API

How to add field selection to resource responses#

Add a fields parameter that returns a subset on request and the full object otherwise, refuse unknown names with an RFC 9457 problem, and check the mock agrees.

Audience
API producer
Level
intermediate
Topic
Model resources, relations and actions
Verified

The mobile app lists two hundred orders and reads three fields of each, so every screen pulls forty kilobytes of line items it throws away. The team asks for a way to request less. The first proposal returns id, status, and total unless a client asks for more, which breaks every client that never asked for anything. The second is a GraphQL layer in front of the REST API.

What you get

You will end up with an endpoint that honors fields, refuses unknown names with a problem document, and returns the full object when nothing is asked. This is for you if you add partial responses to an API and test clients against a Prism mock.

Short answer

Declare one fields query parameter, a comma-separated array whose items are an enum of the resource’s field names, with no default. A request without it gets the full object; a request with it gets exactly the named fields, in schema order. An unknown name is a 400 with an application/problem+json body that lists the real fields. Document that 400 in the OpenAPI description, because a Prism mock answers 422 with its own problem type when you do not.

You will need

Node 22 or later, curl, and an OpenAPI 3.1 description of the resource. Verified 2026-10-21 against Node 22.22.0, curl 8.5.0, and @stoplight/prism-cli 5.14.2, the last Prism release whose engines field admits Node 22. The sample runs the real handler on node:http and the mock on Prism, each on a free local port, and drives both with curl, so the transcripts are the bytes on the wire. The problem document follows RFC 9457.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Google AIP-157 read masksA platform where a view enum covers most cases and a mask is the escape hatchA FieldMask grammar to parse, a mask carried outside the request as a system parameter, and a BASIC view that is itself a default subsetA REST API with no proto tooling, where a comma-separated list is the whole need
GraphQL selection setsSelection is the whole model and every client already writes a queryA second protocol with a schema, a caching story, and a server of its own, bought to trim fieldsYou have a REST API and intend to keep it
JSON:API sparse fieldsetsA JSON:API document, where fields[orders]=id,status fits beside includeBrackets to percent-encode, per-type keys to validate, and a specification that lets a server send a subset when nothing was askedYour resources are not JSON:API documents
OData $selectA data service where $select rides with $expand and $filter and the clients already speak ODataThe rest of the OData query language, and expanded navigation properties that come back whether selected or notA product API with a handful of known views
Stripe expand with no field selectionFull objects by default and expand[] to pull related objects in, up to four levels deepEvery response carries every field, so a list of two hundred orders carries two hundred line-item arraysPayload size on a list is the problem you were asked to solve
Zalando fields parameterOne parameter with a nested grammar, fields=(name,friends(name)), across every resourceA grammar with parentheses to parse and document, and a schema in which every property turns optionalThe resources are flat, where a plain comma-separated list does the same job

Stripe’s position is the one to argue with first, because it is the cheapest: no selection, and expand[] when a caller wants more rather than less.1 JSON:API and Zalando both hand the caller a list of names, and they differ on nesting and on whether a server may trim a response nobody asked to trim.2 AIP-157 steers you to a view enum first, two or three documented shapes, and reaches for a mask only after that.3 GraphQL makes selection mandatory, which is a different protocol rather than a parameter.

Declare the parameter and the 400 in the document

The parameter is an array with style: form and explode: false, so it arrives as fields=id,status. Its items are an enum of the field names, which gives a validator and a generator the vocabulary without a sentence of prose. minItems refuses an empty list, uniqueItems refuses a repeat, and there is no default, because the absence of the parameter is the only default the resource has.

    "parameters": {
      "fields": {
        "name": "fields",
        "in": "query",
        "description": "Comma-separated names of the fields to return, in any order. Omit the parameter to receive every field; there is no default subset. A name that is not a field of the resource is a 400.",
        "style": "form",
        "explode": false,
        "schema": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "items": { "type": "string", "enum": ["id", "status", "customer", "total", "currency", "created", "items"] }
        }
      }
    },

The 400 is declared with the problem media type and a worked example. The example is what a mock serves when a request fails validation, so it is written as the real handler would write it.

    "responses": {
      "UnknownField": {
        "description": "A name in fields that is not a field of the resource, an empty fields parameter, or fields given more than once",
        "content": {
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/Problem" },
            "example": {
              "type": "https://api.example.com/problems/unknown-field",
              "title": "A requested field does not exist",
              "status": 400,
              "detail": "fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items",
              "instance": "/orders/ord_7001?fields=id,nope"
            }
          }
        }
      }
    }

openapi-no-400.json is the same document without that response. Prism still rejects the unknown name, with a status and a problem type of its own.

node mock.mjs openapi-no-400.json "?fields=id,nope"
prism 5.14.2 mocking openapi-no-400.json

curl -s "http://127.0.0.1:45055/orders/ord_7001?fields=id,nope"
{"type":"https://stoplight.io/prism/errors#UNPROCESSABLE_ENTITY","title":"Invalid request","status":422,"detail":"Your request is not valid and no HTTP validation response was found in the spec, so Prism is generating this error for you.","validation":[{"location":["query","fields","1"],"severity":"Error","code":"enum","message":"Request query parameter fields.1 must be equal to one of the allowed values: id, status, customer, total, currency, created, items"}]}
422 application/problem+json

A client tested against that mock learns to expect 422 and a Stoplight problem type, and the real API sends 400 with yours.4 The declared response is what makes the mock and the server agree.

Return exactly what was asked, and everything when nothing was

The selector takes the full object and the raw parameter value: null when the request carried none, an array when it carried several. It returns either the body to send or the problem to send as a 400.

export function selectFields(object, fields, allowed = ORDER_FIELDS) {
  // No parameter: the full object. The absence of fields is the only default there is.
  if (fields === null || fields === undefined) return { ok: true, body: object }
  // fields=id&fields=status: frameworks keep the first value, the last, or all of them.
  if (Array.isArray(fields)) return { ok: false, problem: problem(`fields is given ${fields.length} times; send one comma-separated list`) }
  if (fields === '') return { ok: false, problem: problem('fields is empty; omit the parameter to receive every field') }

  const names = fields.split(',')
  const unknown = names.filter((n) => !allowed.includes(n))
  if (unknown.length) {
    return { ok: false, problem: problem(`fields names ${unknown.map((n) => `"${n}"`).join(', ')}, which is not a field of an order; the fields are ${allowed.join(', ')}`) }
  }
  const twice = names.find((n, i) => names.indexOf(n) !== i)
  if (twice) return { ok: false, problem: problem(`fields names "${twice}" twice`) }

  // Walk the schema's order, not the request's, so two clients asking for the same fields in a
  // different order get byte-identical bodies.
  const body = {}
  for (const name of allowed) if (names.includes(name)) body[name] = object[name]
  return { ok: true, body }
}

The body is built in the schema’s order rather than the request’s, so fields=total,id and fields=id,total produce the same bytes. A cache still stores them under two keys, because the query strings differ. An empty value is refused rather than read as no fields or as all fields, because both readings have been chosen by some API and a caller cannot tell which. A second fields is refused for the same reason. The handler adds the request as the problem’s instance and sets the media type.

const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1')
  const match = /^\/orders\/([^/]+)$/.exec(url.pathname)
  if (req.method !== 'GET' || !match || match[1] !== ORDER.id) {
    return send(res, 404, 'application/problem+json', { type: 'about:blank', title: 'Not Found', status: 404 })
  }
  const given = url.searchParams.getAll('fields')
  const result = select(ORDER, given.length > 1 ? given : given[0] ?? null)
  if (!result.ok) {
    return send(res, 400, 'application/problem+json', { ...result.problem, instance: url.pathname + url.search })
  }
  send(res, 200, 'application/json', result.body)
})

Three requests through curl: no parameter, two fields, and a field that does not exist.

node demo.mjs
curl -s -i "http://127.0.0.1:37349/orders/ord_7001"
HTTP/1.1 200 OK
content-type: application/json
content-length: 244
connection: close

{
  "id": "ord_7001",
  "status": "paid",
  "customer": "cus_9",
  "total": 12050,
  "currency": "EUR",
  "created": "2026-09-14T09:30:00Z",
  "items": [
    {
      "sku": "TEE-M-BLK",
      "quantity": 2,
      "unit_price": 6025
    }
  ]
}

curl -s -i "http://127.0.0.1:37349/orders/ord_7001?fields=id,status"
HTTP/1.1 200 OK
content-type: application/json
content-length: 43
connection: close

{
  "id": "ord_7001",
  "status": "paid"
}

curl -s -i "http://127.0.0.1:37349/orders/ord_7001?fields=id,nope"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
content-length: 317
connection: close

{
  "type": "https://api.example.com/problems/unknown-field",
  "title": "A requested field does not exist",
  "status": 400,
  "detail": "fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items",
  "instance": "/orders/ord_7001?fields=id,nope"
}

The full object is 244 bytes and the two-field selection is 43, which is the saving the mobile team asked for, on one order. The 400 carries the misspelled name, the list of real names, and the request that caused it, so the developer who typed nope can fix it without opening the documentation.

Hold the mock to the same contract

Run the same requests, plus an empty fields, against Prism mocking the document with the 400.

node mock.mjs
prism 5.14.2 mocking openapi.json

curl -s "http://127.0.0.1:41611/orders/ord_7001"
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json

curl -s "http://127.0.0.1:41611/orders/ord_7001?fields=id,status"
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json

curl -s "http://127.0.0.1:41611/orders/ord_7001?fields=id,nope"
{"type":"https://api.example.com/problems/unknown-field","title":"A requested field does not exist","status":400,"detail":"fields names \"nope\", which is not a field of an order; the fields are id, status, customer, total, currency, created, items","instance":"/orders/ord_7001?fields=id,nope"}
400 application/problem+json

curl -s "http://127.0.0.1:41611/orders/ord_7001?fields="
{"id":"ord_7001","status":"paid","customer":"cus_9","total":12050,"currency":"EUR","created":"2026-09-14T09:30:00Z","items":[{"sku":"TEE-M-BLK","quantity":2,"unit_price":6025}]}
200 application/json

The unknown field gets the documented 400 and the documented body, so a client built against the mock meets the same error in production. The valid selection gets the full example, because a mock answers from the document and the document has one example. The mock proves only that the parameter is well formed; selection is a behavior the example cannot show. The empty fields passes the mock and fails the handler, which is the request to test against the real thing.

Check it worked

node --test select.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1609.283677

The line to read is # pass 8. One test reads openapi.json and asserts that the fields enum and the Order properties both equal ORDER_FIELDS in the handler, and that Order lists no required properties. Another starts Prism and asserts those three behaviors, so a Prism release that changes any of them fails here first.

When it goes wrong

Every client that never sent fields loses four properties overnight. Someone gave the parameter a default subset, on the grounds that most callers only want the summary, while the document went on promising the full object. pitfall.mjs runs a client built from the document against that server and against the handler as written.

node pitfall.mjs
server with a default subset
  GET /orders/ord_7001 with no fields -> 200, keys: id, status, total
  documented properties: id, status, customer, total, currency, created, items
  client validation fails: missing customer, currency, created, items; order.customer reads as undefined
server with no default
  GET /orders/ord_7001 with no fields -> 200, keys: id, status, customer, total, currency, created, items
  documented properties: id, status, customer, total, currency, created, items
  client validation passes: every documented property is present

The client did nothing wrong. It read the schema, asked for nothing, and expected everything, and order.customer came back undefined in code that had worked for a year. If a summary shape is wanted, give it a name as a second schema or a view, as AIP-157 does, and leave the bare request alone.

A cache hands one client another client’s subset. The handler keys nothing itself, but a CDN configured to drop the query string from its key stores ?fields=id,status under the same key as the full object. RFC 9111 lets a cache reuse a stored response only when the target URI matches, and the query string is part of that URI. The fix is to stop the CDN rewriting the key, or to mark selected responses Cache-Control: private.

The generated client turns every field optional. With fields in play, Order can list no required properties, so the SDK’s type for a full fetch reads customer?: string and every call site grows a guard. Where the typed client matters more than the flexibility, publish two schemas, a summary and the full object, and let each endpoint return exactly one of them.

When not to do this

Do not give fields a default subset. AIP-157 says a partial response as the default of a standard method can degrade the effectiveness of declarative clients, and the pitfall on this page is the imperative version of that sentence. The absence of the parameter means the whole resource, or the parameter is not field selection.

Do not add selection to a resource with six scalar properties and no nested arrays. The saving is a few bytes per object, and the price is a schema with no required properties and a parameter every client has to learn.

A mock cannot prove selection. Prism validates the parameter against the enum and answers from the example. A test that reads the trimmed body back has to run against the handler, or against a proxy in front of it.

Do not adopt GraphQL to trim fields from a REST API. Selection is one thing GraphQL does, and it arrives with a schema language, a resolver layer, and a caching model that the REST API did not have. Stripe’s answer, every field on every object and expand[] for more, is the right one when payload size was never the complaint.

Last verified

Verified 2026-10-21 against Node 22.22.0, curl 8.5.0, and @stoplight/prism-cli 5.14.2. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci. The handler and the mock ran on local ports without any call leaving the machine. The JSON:API, Zalando, AIP-157, OData, GraphQL, and Stripe conventions were read from the documentation this page links and were not exercised against a running service.

Footnotes

  1. Stripe’s expand reaches related objects through dotted paths, to a maximum depth of four levels. The API publishes a depth limit on the mechanism it offers, which asks for more. It offers no mechanism for asking for less, so the trimming is left to the client’s JSON parser. ↩︎ Back to text

  2. The JSON:API specification permits the pitfall on this page. When a client does not specify the set of fields for a type, the server MAY send all fields, a subset of fields, or no fields. The MUST NOT runs the other way: once a client restricts the fields, the server may not add any. ↩︎ Back to text

  3. AIP-157 moved the mask out of the request. Field masks should not be specified in the request, it says, and Google Cloud APIs specify them as a system parameter, in a query parameter, a header, or gRPC metadata. The section that records the move is titled as a deprecation of read_mask in request messages, which is where the field had lived. ↩︎ Back to text

  4. Prism’s generated error explains its own existence. The detail reads that the request is not valid and no HTTP validation response was found in the spec, so Prism is generating this error for you, and the type points at stoplight.io/prism/errors#UNPROCESSABLE_ENTITY. The request validation page documents the checks; the status code is Prism’s choice, not RFC 9457’s, which assigns none. ↩︎ 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.