Why is an unbounded list field on a GraphQL object type hard to fix later?
answer
- The declaration promises all of them
- Arguments are additive, return types are not
- Bounded by the domain or by luck?
- Truncation under a non-null list lies
- New field beside the old, then deprecate
basics
~20 sThe field's return type is part of the contract. Adding arguments to it is safe, but replacing a plain list with a paged wrapper type changes what every existing document receives, so the fix is a breaking change to every caller.
solid answer
~50 sA field declared `trips: [Trip!]!` promises the whole collection, with no place to put a limit and no way for a caller to ask for less. Once the collection grows without a bound, the server has three bad options: return everything and hope, silently truncate — which lies about a non-null list — or change the field. Adding optional arguments such as `limit` is additive and safe; changing the **return type** from `[Trip!]!` to a wrapper object is breaking, because every existing document selects trip fields directly under `trips` and those selections stop validating. The decision therefore belongs at design time: ask whether the relationship is bounded *by construction* — a vehicle's axles, a trip's two endpoints — or bounded only by luck. Bounded-by-construction collections are fine as plain lists. Anything a business process appends to should be paged from its first release, using whatever pagination convention the schema has adopted.
code
graphql · 9 linestype Vehicle {
id: ID!
# bounded by construction - a plain list is correct forever
axleWeightsKg: [Float!]!
# bounded only by how young the fleet is - one unit reached 41,900
trips: [Trip!]!
}go deeper
Recall that a list field with no arguments promises the entire collection, and that an empty list, not null, is how it says there are none. Know that huge collections need paging.
Explain why the fix is expensive: optional arguments can be added safely, but changing the field's return type invalidates every document selecting through it. Distinguish collections bounded by the domain from ones bounded by the launch date.
Demonstrate the migration on a live schema — parallel paged field, deprecation, usage evidence, removal — and the interim server-side guard that errors instead of truncating a field that claims completeness.
Own it as a review rule: no new unbounded list field ships without a stated bound, because the cost of the mistake is paid later by every team with a client, not by the team that wrote the field.
## The shape and its promise ```graphql type Vehicle { id: ID! plate: String! trips: [Trip!]! } ``` Read the declaration literally. `trips` returns a non-null list of non-null trips, takes no arguments, and says nothing about size. The only honest reading is *all of them*. There is no argument a caller could pass to ask for fewer, and no field in the result where the server could report that it held something back. That is comfortable in a seed database. In a fleet telematics graph it stops being comfortable the first time a long-haul tractor unit reaches its third year: one vehicle's `trips` came back with 41,900 rows, a 96 MB response body, and a resolver that had already materialised every row before serialization started. Nothing was misconfigured. The schema asked for all of them. ## Why the obvious repairs are all bad **Truncate server-side.** Return the newest 100 and move on. The field still claims to be the whole list, so every caller that counts, sums or paginates client-side is now quietly wrong, and no error says so. Silent truncation behind a non-null list is the worst of the three because it produces plausible numbers. **Add a limit argument.** `trips(limit: Int = 100): [Trip!]!` is genuinely additive — adding an optional argument to an existing field does not break existing documents — and it is a legitimate stopgap. But it only bounds one request; it gives the caller no way to reach page two, because a plain list has nowhere to carry a position. You end up bolting `offset` on, and now the schema has an ad-hoc paging scheme that behaves badly under concurrent writes. **Change the return type.** The real fix replaces `[Trip!]!` with an object type that carries a page of items plus paging state. This is a breaking change of the plainest kind: every existing document selects `trips { startedAt }`, and after the change those fields no longer exist directly under `trips`. Every caller must be rewritten in step. In a schema with published clients you cannot do that on a Tuesday, and the standard fallback is to add a **new** paged field beside the old one, deprecate the original, wait for its usage to fall to zero, and only then remove it — which is months of two fields meaning almost the same thing. ## The decision you should make at design time Ask one question of every list field before it ships: **is this collection bounded by construction, or bounded only by how young the system is?** Bounded by construction means something in the domain caps it and always will: a vehicle's axle weights, a trip's origin and destination, a driver's licence categories, the enum-like set of fault codes a device can raise. These are legitimately plain lists. Paging them adds ceremony to a field that will never have a second page. Bounded by luck means some process appends to it: trips, telemetry samples, maintenance jobs, dispatch messages, audit entries. Every one of these is unbounded in principle, and the fact that today's largest row has 12 entries is not a property of the domain — it is a property of the launch date. These get a paged field from day one, even while the data is small. The grey middle is the collection with a soft cap — a depot's vehicles, a driver's certifications — where the number is small *because someone would have to do a lot of work to make it large*. Here it is reasonable to ship a plain list and write the cap down: if the model genuinely forbids more than a few dozen, say so in the field description and add a server-side guard that errors rather than truncates when the assumption is violated. An error is a bug report; a truncation is a silent wrong answer. ## Related sharp edges **The non-null modifiers matter.** `[Trip!]!` means the list itself is always present and no element is null. That is usually right for a collection — an empty list is the natural "none" — and it is exactly what makes truncation dishonest. **Depth is a separate problem.** Nested plain lists multiply: `depot { vehicles { trips { events } } }` is a product of four unbounded collections, and the operational caps that bound such a document are a security concern, not a modelling one. But paging each level is the modelling half of that defence. **Filtering has the same trap.** A list field with no arguments can never be narrowed by the caller; the arguments a caller uses to select within the list are their own design problem, and, unlike the return type, they can be added later without breaking anyone. ## What an interviewer is listening for The weak answer is "always paginate everything". The strong answer distinguishes the two kinds of collection, knows that arguments are additive while the return type is not, and can say what it would do to an already-published unbounded field — new field beside the old, deprecate, watch usage, remove — rather than pretending the change is free.
- The unbounded field is already published and in use. What is your migration?Add a new paged field beside it rather than changing the existing one, since the return-type change would break every document at once. Deprecate the original with a reason pointing at the replacement, watch field usage until it reaches zero, then remove it. In the meantime bound the old field server-side with an error, not a silent truncation.
- Is adding a `limit` argument to an existing list field safe for current callers?Yes, provided it is optional or has a default — adding an optional argument is additive and existing documents keep validating. It is only a partial fix: it bounds a single response but gives the caller no way to reach anything beyond the first slice, so treat it as a stopgap rather than pagination.
- How do you justify paging a collection that only ever has eight rows today?By asking what caps it. If nothing in the domain does, eight is a fact about the launch date, and the cheapest moment to page it is before any caller depends on the plain-list shape. If the domain genuinely caps it — axles, endpoints, licence categories — a plain list is right and paging it is ceremony.
saying these in an interview costs you the question
- Says every list field must be paged, with no distinction
- Silently truncates a list that claims to be complete
- Thinks changing a field's return type is additive
- Believes adding an optional argument breaks callers
- Treats today's row count as the domain's bound
- Bolts offset onto a plain list and calls it pagination