Why did a GraphQL page-size variable sent as null return every row when omitting it returned 25?
answer
- Three states, not two
- Absent and null are different requests
- A default fires on absence only
- An explicit null overrides a schema default
- Non-null with a default rejects null
basics
~20 sOmitting a variable leaves it not provided, so the argument falls back to its schema default. Sending null provides a value, which overrides that default and reached a resolver that read null as no limit.
solid answer
~50 sA supplied variable has three states, not two. **Omitted** means not provided: if the variable definition has a default it is used, and if it does not, the argument that references it is treated as though it were never written, so the *argument's* schema default applies - here, 25. **Explicitly null** means provided, with the value null: for a nullable variable that null flows into the argument and overrides the schema default, and a resolver that reads null as "unlimited" then returns everything. **A concrete value** is simply used. The fix is to make null unrepresentable rather than to defend against it downstream: declare the variable `Int! = 25`, which lets a caller keep omitting it while turning an explicit null into a request error at coercion time, before any field runs. A server-side maximum page size is the belt to that pair of braces.
code
json · 3 lines{ "id": "pl_9f31" }
{ "id": "pl_9f31", "first": null }go deeper
Remember the one-line rule: a default applies when a value is absent, never when null was actually sent. Being able to say that omitting a key and sending null are two different requests is most of the answer at this level.
Explain all three states and trace where the value comes from in each: the variable's default, the argument's schema default, or the supplied value. Be able to say that a non-null variable may legally carry a default, and what each half of that buys.
Diagnose from the symptom and then design the case out of existence. Reach for a non-null type with a default so the bad request fails at coercion rather than being served, and be ready to explain why a resolver-side guard alone leaves the contract ambiguous for the next client.
Own the policy: which input positions in a shared graph are allowed to be nullable at all, how page-size and cost ceilings are enforced independently of client behaviour, and where entitlement filtering must run so a bad request fails cheaply instead of paying for work it discards.
## The incident A music catalogue graph exposes a playlist's tracks with a page size that has a schema default: ```graphql type Playlist { tracks(first: Int = 25, after: String): [Track!]! } ``` The client sends one document for the playlist screen: ```graphql query PlaylistTracks($id: ID!, $first: Int) { playlist(id: $id) { tracks(first: $first) { id title } } } ``` For months the values map carried `"first": 50` or omitted the key, and pages came back at 50 or 25 rows. Then a client refactor started sending `"first": null` whenever the user had not chosen a page size. The response for one editorial playlist came back with all 8,400 rows. Worse, the market-licensing check that trims tracks to what the caller is entitled to hear ran per track *after* the page was fetched, so the database read 8,400 rows and the server ran 8,400 permission checks to return the few hundred the caller was allowed to see. The cost was paid in full for a result nobody asked for. ## Three states, not two Every interview answer here turns on the fact that a variable is in one of **three** states, and the middle one is the one people forget. **Provided with a value.** The value is coerced against the declared type and used. **Not provided** - the name is absent from the values map. Then: if the variable definition has a default, that default becomes the value; if it has no default and the declared type is non-null, the request fails; and if it has no default and the type is nullable, the variable is left out of the coerced set entirely. That last case is the important one. An argument written as `first: $first` where `$first` was not provided is treated as if the argument had **not been written at all** - so the *schema's* default for that argument applies. Hence 25. **Provided as null.** The key is present with the value null. For a nullable variable this coerces to null, and null is a real value: `first: $first` becomes `first: null`. An explicitly supplied null is *not* absence, so the schema default never comes into play. The resolver receives null and does whatever its code does with null - in this incident, treat it as "no limit". ## The rule in one line A default fires on **absence**, never on **null**. That single sentence covers variables, arguments and input object fields alike, and it is the thing to say out loud in an interview. ## Why the schema default did not save anyone The schema author's intent was "25 unless told otherwise", and the schema expresses that correctly. What the schema did *not* say is that null is meaningless for this argument. Declaring `first: Int = 25` makes the argument nullable, so `null` is a legal value the server has to accept, and only the resolver's code decides what it means. Two clients can then disagree about that meaning while both being within the contract. ## The fixes, in order of strength **Make null unrepresentable at the variable.** Change the declaration to `$first: Int! = 25`. A non-null variable may carry a default - that combination is legal and is exactly right here. Callers that omit the key still get 25, and a caller that sends `"first": null` now gets a request error during coercion, before a single field executes and before a single row is read. The bad request is rejected at the edge instead of being served expensively. **Make null unrepresentable at the argument.** `tracks(first: Int! = 25)` pushes the same guarantee into the schema so every document benefits, not just the one that was fixed. Note the knock-on effect: a nullable variable can no longer be passed there unless it has a non-null default of its own, which is the validation rule doing exactly the work you want. **Cap it server-side anyway.** Clamp the effective page size to a maximum regardless of what arrives. Type-level guarantees stop null; they do not stop `"first": 8400`. **Move the entitlement filter into the fetch.** The permission check running per row after the page was materialised is what turned a wrong page size into an expensive incident. A check that participates in the query that selects rows fails cheap; a check that runs afterwards pays for everything it then discards. ## The same trap elsewhere Input object fields behave identically: leaving a field out of a filter input lets that field's default apply, while sending it as null supplies null. A client that serialises its whole form state, nulls included, therefore sends a materially different request from one that omits the empty fields - the same document, the same field names, different semantics. Clients that build their values map by dumping an object with null-valued keys hit this constantly, and it is why "strip nulls before sending" is such a common client-side rule. One caveat worth stating precisely: null-versus-absent is only *observable* for a nullable input position that also has a default or a distinct meaning for null. Where the type is non-null, coercion rejects the null and the ambiguity never reaches your code - which is the whole argument for reaching for a non-null type early.
- Is it legal to declare a variable non-null and give it a default value?Yes, and it is the precise tool for this problem. `$first: Int! = 25` says the operation always ends up with an integer: omit the key and you get 25, send a number and you get that number, send null and the request fails during coercion before anything executes. The default handles absence and the non-null handles null, which are two different failure modes that people often try to solve with one of them.
- Where does the value come from when a variable is omitted and it has no default of its own?From the argument. An argument whose value is a variable that was not provided is treated as though the argument had not been written at all, so the schema's default for that argument applies. If the argument has no default either, it is genuinely absent - which is a request error only when the argument's type is non-null. This is why the same document can behave correctly with a missing key and badly with a null one.
- Does the same absent-versus-null distinction apply inside an input object?Yes, field by field. Leaving a field out of a supplied input object lets that field's default apply; supplying it as null sets it to null, if the field's type is nullable. Clients that build a filter input by serialising form state with its empty values included therefore send something semantically different from clients that strip them, which is why stripping nulls before sending is such a widespread client-side habit.
- How would you catch this class of defect before it reaches production?Test the values map, not just the document. A test suite that only ever sends fully populated variables never exercises the absent or null paths, so add cases for each state of every nullable input that has a default. Beyond that, prefer making the state unrepresentable: a non-null argument with a default removes the case from the schema, and a server-side maximum bounds whatever still gets through.
Leaving a form field blank lets the shop use its house default; writing the word "none" in it is an instruction, and the shop obeys it.
saying these in an interview costs you the question
- Says an omitted variable and a null one are equivalent
- Thinks a schema default replaces an explicitly supplied null
- Believes a non-null variable cannot have a default
- Claims null always means use the default
- Relies only on resolver code to bound a page size
- Assumes validation rejects a null before execution regardless of type