Skip to content
beginner Phase 5 · Nested Selections and Object Relationships

Nested Field Selection

Requesting related objects several levels deep within a single GraphQL query.

40m
1 problems
Topic Progress 0%

One Request Instead of a Chain of Endpoints

One Request Instead of a Chain of Endpoints

A product page needs a post, its comments, and each comment's author. Over REST that is at least three calls, and the client stitches the pieces by id:

GET /posts/42                 → the post
GET /posts/42/comments        → 12 comments
GET /users?ids=3,7,9,11,...   → the authors (a batching trick the client invented itself)

GraphQL asks for the whole tree in one document:

query ProductPage {
  post(id: "42") {
    title
    comments(limit: 5) {
      body
      author {
        name
        avatarUrl
      }
    }
  }
}

The response mirrors the selection set key for key, so no stitching code exists:

{
  "data": {
    "post": {
      "title": "Designing Data-Intensive Applications",
      "comments": [
        { "body": "The replication chapter…", "author": { "name": "Ada", "avatarUrl": "/a.png" } }
      ]
    }
  }
}

Four properties of this shape are worth locking in:

  • Depth is chosen by the client. Nothing stops a caller from continuing the tree with author { posts { comments { … } } }. The server cannot assume a query stops where your screen stops.
  • Only selected fields execute. A selection of title alone never touches comment resolvers, so cost is driven by depth and fan-out together, not by the schema's size.
  • The schema guarantees the shape, not the resolvers. comments is typed [Comment!]!, so the client can rely on structure without null-checking every level.
  • One round trip, no matter how deep. Nesting converts a client-side join problem into a single server-side traversal.

See query variables for how the value of id: "42" reaches the server without being baked into the document text.

What Runs at Each Level

What Runs at Each Level

A nested selection is not one resolver walking a tree. Every field has its own resolver, and each receives the object its parent field just produced.

type Query {
  post(id: ID!): Post
}
type Post {
  id: ID!
  title: String!
  comments(limit: Int): [Comment!]!
}
type Comment {
  id: ID!
  body: String!
  author: User!
}
const resolvers = {
  Query: {
    post: (_root, { id }) => db.post.findUnique({ where: { id } }),
  },
  Post: {
    // parent is the post Query.post just returned — its id is already in memory
    comments: (parent, { limit }) =>
      db.comment.findMany({ where: { postId: parent.id }, take: limit ?? 20 }),
  },
  Comment: {
    author: (parent) => db.user.findUnique({ where: { id: parent.authorId } }),
  },
};

Dispatch order for the query above:

Level Field First argument
1 Query.post the root value, usually undefined
2 Post.comments the resolved post
3 Comment.author the resolved comment

The parent is handed down by value from the previous resolver, never re-read by id. That has one important consequence: if the object arriving at level 2 carries no authorId, the Comment.author resolver reads undefined. Validation already passed — it only ever compares the document against the schema — so the failure surfaces at execution, as null on a nullable field or an error that propagates upward on a non-null one. Null propagation covers exactly how far that error travels.

The part reviewers miss is multiplication: one Query.post call, one Post.comments query, then one author query per comment. Twelve comments mean thirteen database reads behind a single HTTP request. The query is three lines long and still expensive.

Depth Limits and the Cost Hiding in Plain Sight

Depth Limits and the Cost Hiding in Plain Sight

Count what a motivated client can reach:

post                      depth 1
  comments                depth 2
    author                depth 3
      posts               depth 4
        comments          depth 5
          author          depth 6

None of that is invalid — it is a finite document over a legal schema. But each level multiplies the work of the one above it: a catalog page with 50 posts, 20 comments each, and 3 followed users per author turns into thousands of resolver calls behind one request. The query reads as harmless because each level looks harmless alone.

Two controls, applied together:

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(6)], // reject deeper documents before execution
});

Depth counting is blunt: posts { id } and posts { comments { author { … } } } cost the same at each level even though their fan-out differs wildly. Pair it with cost analysis, which weights each field by how much it multiplies — see cost analysis and budgets and query depth limits.

Review checklist for nested selections:

  • Ask what the fan-out product is at the deepest level, not whether each level reads clearly.
  • Every list field on a deep path should accept limit/first or sit inside a paginated connection — see Relay-style pagination.
  • Treat a three-level selection containing two list fields as a performance change, not a formatting change.
  • Check that the depth limit is actually wired into the server's validation rules; installing the package alone does nothing.

Interview framing: state that the client controls depth, name the two server-side controls (depth limit plus cost budget), and walk one concrete fan-out multiplication — 50 × 20 × 3 — to show you can price a query by reading it.

Practice Problems

0 / 1 solved
Write a three-level nested query
Nested selection set

Given the schema below, write one document that fetches a post, its five newest comments, and each comment author name and avatar in a single request. Then state how many times the Comment.author resolver runs for a post with five comments.

Example:

Input: type Query { post(id: ID!): Post } type Post { id: ID! title: String! comments(limit: Int, orderBy: Order): [Comment!]! } type Comment { id: ID! body: String! author: User! } type User { id: ID! name: String! avatarUrl: String }

Output: query PostPage($id: ID!) { post(id: $id) { id title comments(limit: 5, orderBy: NEWEST) { body author { name avatarUrl } } } }

One document covers all three levels, the response mirrors the selection set, and the client never joins authors by id.

Optimal Solution — O(c) time, O(c) space

Nest the relationship selections so each level narrows the fields fetched. c = comments selected; the server performs one author lookup per comment.

query PostPage($id: ID!) {
  post(id: $id) {
    id
    title
    comments(limit: 5, orderBy: NEWEST) {
      body
      author { name avatarUrl }
    }
  }
}

Edge Cases:

  • post(id:) returns null — the entire selection collapses to null and no comment resolver runs
  • A post with zero comments returns comments: [] with no author entries to resolve
  • A comment with a dangling authorId resolves author to null (or errors if author were non-null)

Quiz

1. For the query post { comments { author { name } } } on a post with 12 comments, how many times does the Comment.author resolver execute?

Question 1 options

2. What is the first argument passed to the Post.comments resolver?

Question 2 options

3. Why can over-deep nesting slip past code review?

Question 3 options

4. Which control rejects a 12-level document before a single resolver runs?

Question 4 options

Flashcards

Question

Why one nested query beats three REST calls

Answer

The server traverses relationships for you and returns the requested tree in one round trip; the client never joins by id.

Question

First argument of a field resolver

Answer

The parent object produced by the field above it; the root value (usually undefined) for root fields.

Question

Who decides query depth

Answer

The client writes it, so the server must enforce a maximum depth as a validation rule before execution.

Question

Fan-out product

Answer

Multiply the list sizes along the deepest path (50 × 20 × 3) to estimate resolver calls for one request.

Question

Validation vs runtime for missing parent data

Answer

Validation only compares document to schema; a parent missing a property fails at execution, yielding null or an error.

Revision Notes

Key Takeaways

  • 1. A nested selection returns the requested tree in one round trip, and the response shape mirrors the document
  • 2. Every field runs its own resolver with the parent object as argument one, so list levels multiply database work
  • 3. Validation never inspects data — missing parent properties fail at execution, not before
  • 4. Depth limit and cost budget together are the server-side answer to client-chosen depth

Interview Tips

  • • Walk a query level by level: which resolver runs, what it receives as parent, and how many times it runs for a given data size
  • • Estimate fan-out out loud when shown a deep query — interviewers are checking whether you multiply list sizes or just count lines
  • • Name both controls (depth limit, cost budget) and say where they run: validation, before any resolver executes

Cheat Sheet

Nested Selection Cheat Sheet

query ProductPage {
  post(id: "42") {            # depth 1 — Query.post
    title
    comments(limit: 5) {      # depth 2 — Post.comments(parent)
      body
      author { name avatarUrl } # depth 3 — Comment.author(parent)
    }
  }
}
  • Response shape mirrors the selection set; only selected fields execute
  • Dispatch order: root resolver (parent = root value) → each level passes its object down
  • Resolver cost = product of list sizes on the deepest path (50 × 20 × 3 = 3000 calls)
  • Missing parent property → runtime null/error, never a validation error
  • Server controls: depth limit + cost budget, both as validation rules before execution
  • Every list field on a deep path needs limit/first or a paginated connection

Continue Learning