Where do @skip and @include fall short as a conditional mechanism in GraphQL?
answer
- One job only: is this selection sent?
- Pair include and skip on the same variable
- Arguments are not a directive location
- The condition is known before the request
- Validation never evaluates the condition
basics
~20 sThey gate whole selections and nothing else: no else keyword, no way to vary an argument, no condition that reads data in the same response. And a skipped field is still validated, so it cannot hide a removed one.
solid answer
~50 s`@skip`/`@include` decide whether a **selection** is in the request, and that is the whole of their power. There is no `else` syntax — the two-way idiom is `@include(if: $x)` on one selection and `@skip(if: $x)` on the alternative, and anything wider than two branches needs one variable per branch. They cannot vary an **argument**, because arguments are not a legal directive location, so "20 rows on mobile, 100 on desktop" is a variable, never a directive. The condition is a value the caller already holds, so it cannot depend on a field in the same response. And critically, **validation is static**: a field guarded by `@skip(if: true)` must still exist on its type with valid arguments, so the directive cannot shield a client pinned to a field the schema has removed. Downstream, every flag doubles the response shapes that generated types and normalized caches must tolerate.
code
graphql · 12 linesquery EventCard($eventId: ID!, $expanded: Boolean!) {
event(id: $eventId) {
title
... @include(if: $expanded) {
seatMap { sectionCount accessibleSeatCount }
doorsOpenAt
}
... @skip(if: $expanded) {
soldOut
}
}
}go deeper
Know the two hard limits first: there is no else keyword, and the condition is a value you send with the request. Anything that needs to look at the response before deciding is a second request.
Be able to write the two-branch idiom — @include(if: $x) on one selection, @skip(if: $x) on the other — and explain why arguments cannot be conditioned, since an argument is not a legal directive location.
Diagnose the case that catches teams out: a skipped field is still validated, so guarding a removed field does not save a pinned client. Talk through what flag count does to generated types and normalized caches.
Own where the line sits between one flagged document and several named operations, and say it in terms someone can apply: response-shape count, what the registry and the type generator have to carry, and what is realistically tested.
## The one thing they do `@skip` and `@include` answer exactly one question: *is this selection part of this request?* Every limitation below is a corollary of that being their only job, and a senior answer is mostly about knowing which apparently-reasonable requests fall outside it. ## There is no `else`, but there is an idiom There is no `else` keyword, no ternary, and no `@unless`. What there is, and what people mean when they say GraphQL "has" conditionals, is a pairing against one variable: ```graphql query EventCard($eventId: ID!, $expanded: Boolean!) { event(id: $eventId) { title ... @include(if: $expanded) { seatMap { sectionCount accessibleSeatCount } doorsOpenAt } ... @skip(if: $expanded) { soldOut } } } ``` One variable, two mutually exclusive branches. That is genuinely an if/else and it is worth naming in the interview, because "there is no else" alone is only half the truth. What it does not scale to is three branches: a compact/standard/expanded card needs three booleans that the caller must keep mutually exclusive by convention, because the document has no expression language and no enum-driven switch. Nothing stops a caller sending two of the three as `true` and getting a shape nobody designed. ## They cannot touch arguments The legal locations are field, fragment spread and inline fragment. An **argument is not a location**, so a directive can never change one. A ticketing client wanting 20 rows on a phone and 100 on a desktop does not reach for a directive at all — that is what a variable is for, `seats(first: $pageSize)`. The genuinely awkward case is wanting a *different* argument shape per branch, say `sections(sort: PRICE)` or `sections(sort: ROW)`. The document must then carry both, aliased, each guarded — and both branches are paid for only in the sense that only the included one executes, but the document is twice the size and generated code carries two response keys for one concept. ## The condition is known before the request The `if` value is a literal or a variable, and a variable's value is supplied by the caller alongside the document. So the condition can never depend on something in the same response. "Fetch the seat map only if the event is reserved-seating" is not expressible: you would need the event's seating type first, which means either two round trips or asking for both and letting the client ignore one. This is a real design constraint on documents that would otherwise be a single request. ## Validation does not skip skipped fields — the incident This is the sharpest limit and the one that shows up as a production failure. Document validation is **static**: it runs against the schema, before execution, without variable values. A field carrying `@skip(if: true)` is still checked for existence, for argument validity and for a legal selection set. A ticketing platform serving a graph in front of 11 internal services removed `Venue.legacySeatMapUrl` after a six-month deprecation. One mobile build was pinned to it, and the fix someone proposed in the incident channel was to guard the field: ```graphql query VenueHeader($venueId: ID!) { venue(id: $venueId) { name legacySeatMapUrl @skip(if: true) } } ``` That changes nothing. The response is still a **request error** — `Cannot query field "legacySeatMapUrl" on type "Venue"` — with no `data` key at all, so the whole screen fails rather than one field. `@skip` removes a selection from *execution*; it does not remove it from the *document*, and validation only ever sees the document. The only real fixes are a client release or restoring the field. ## What every flag costs downstream Each conditional selection doubles the set of response shapes one document can produce. Seven flags on an event-page document is 128 shapes. Three things have to absorb that: * **Generated types.** A typed client generator must model a guarded field as *possibly absent*, which is a different and weaker guarantee than nullable. Consuming code needs a presence check on every flagged branch, and if the generator instead emits it as required, the type lies whenever the flag is off. * **Normalized client caches.** A cache keyed by object identity now stores a partially-populated entity. A later read that expects the guarded field misses, and the client either refetches or renders a hole — the classic "it worked on the expanded screen, it flickers on the compact one". * **Reviewers and tests.** Nobody exercises 128 shapes. Realistically two or three combinations are tested and the rest are discovered by users. ## The judgement call So the senior position is: `@skip`/`@include` are right for a genuine viewport or role variation on a single screen, where the branch count is one or two and the document stays one artefact. Past that, two named operations are usually cheaper than one document with five flags — they type cleanly, they register cleanly, and each is testable on its own. The signal to split is when the flag combinations stop being explainable in a sentence.
- A client needs three card sizes, not two. How far do @skip and @include get you?Not far. You get one branch per boolean and no mutual exclusion, so three sizes means three variables the caller must keep consistent by convention — nothing in the document or the schema stops two being true at once. At that point separate named operations are usually the better artefact: each has one fixed shape, types cleanly, and can be tested and registered on its own.
- Why does a normalized client cache find guarded fields awkward?Because it stores one entity per identity, and a request with the flag off writes a partially-populated entity. A later screen that needs the guarded field reads that entity, misses the key, and must either refetch or render without it. The cache cannot tell "we never asked" from "there is nothing there" unless the client tracks which selections each write covered.
- Can a conditional selection depend on a field returned in the same response?No. The `if` value is a literal or a variable, and variable values are supplied by the caller with the document, so every condition is fixed before execution starts. A dependency on returned data means either two round trips, or asking for both shapes and discarding one client-side. It is one of the clearest boundaries between GraphQL's document language and a general query language.
- Does a field carrying @skip(if: true) still have to exist in the schema?Yes. Validation is static and runs before execution without variable values, so every selected field is checked for existence, argument validity and selection-set shape regardless of its directives. A guarded field that no longer exists produces a request error and the response carries no `data` at all — the directive cannot shield a client pinned to a removed field.
They are the tear-off perforations on a form, not a flowchart. You can remove a section, but you cannot make one section's wording depend on how another was filled in.
saying these in an interview costs you the question
- Thinks @skip(if: true) exempts a field from validation
- Expects an else argument or an @unless directive
- Believes a directive can change a field's argument
- Says the condition can read another field's result
- Treats a guarded field as nullable rather than absent
- Adds flags indefinitely instead of splitting the operation