Splits the annotated field’s resolution out of the surrounding parent SELECT into a separate, batched query. The rewrite emits a DataLoader-backed fetcher: per request, sibling parents' keys are gathered, one batched SELECT runs against the child table with a VALUES-keyed join, and rows scatter back to their original parent positions.

@splitQuery is the directive that decouples query depth from query breadth. Without it, child references inline into the parent SELECT (one round-trip, joined SQL). With it, each @splitQuery boundary becomes a deferred DataLoader call (more round-trips, narrower SQL, batched across parents). It composes with @reference, @lookupKey, @asConnection, and @service.

SDL signature

directive @splitQuery on FIELD_DEFINITION

The directive takes no arguments. The cardinality (single vs list), the FK direction (parent-holds-FK vs child-holds-FK), and the path’s hop count are all read from the surrounding @reference / catalog inference; the rewrite picks the appropriate DataLoader shape from those signals.

Canonical examples

List child, child-holds-FK (@splitQuery keyed on parent’s PK). The DataLoader’s batch is parent_id IN (…​), distributing rows to parents by FK:

type Language @table(name: "language") {
    films: [Film!]! @splitQuery @reference(path: [{key: "film_language_id_fkey"}])
}

Single-cardinality, parent-holds-FK (@splitQuery keyed on parent’s FK column). At least one parent may carry NULL in the FK; the rewrite short-circuits those to null without dispatching to the DataLoader:

type Customer @table(name: "customer") {
    addressSplit: Address @splitQuery @reference(path: [{key: "customer_address_id_fkey"}])
}

type Store @table(name: "store") {
    manager: Staff @splitQuery @reference(path: [{key: "store_manager_staff_id_fkey"}])
}

Store.manager traverses a nullable FK; the §4 null-FK short-circuit returns null without a round-trip when the column is NULL. The @reference is required even though Customer.address_id would auto-infer, because store and staff have two FKs between them and inference would be ambiguous.

Multi-hop list with @lookupKey (SplitLookupTableField — flat SELECT + VALUES+ON keyset + per-parent scatter):

type Film @table(name: "film") {
    actorsBySplitLookup(actor_id: [Int!] @lookupKey): [Actor!]! @splitQuery @reference(path: [
        {key: "film_actor_film_id_fkey"},
        {key: "film_actor_actor_id_fkey"}
    ])
}

Self-referencing FK (split is available for fan-out reduction; not structurally required since the inline emitter handles self-joins via parent-prefixed alias names):

type Film @table(name: "film") {
    sequel: Film @splitQuery @reference(path: [{key: "FILM__SEQUEL_FKEY"}])
}

Non-root @service (resolver-style fields without an FK chain back to the parent’s table need an explicit split):

type City @table(name: "city") {
    filmsFromCity: [Film!]! @splitQuery @service(service: {className: "com.example.MockService"})
}

Combined with @asConnection (per-parent paginated child connection: ROW_NUMBER() OVER (PARTITION BY parentInput.idx) envelope gives each parent its own slice):

type Film @table(name: "film") {
    actorsConnection(first: Int, after: String): ActorsConnection!
        @splitQuery
        @reference(path: [
            {key: "film_actor_film_id_fkey"},
            {key: "film_actor_actor_id_fkey"}
        ])
        @defaultOrder(primaryKey: true)
}

Constraints

  • Single-cardinality @splitQuery is restricted to a single-hop @reference path. Multi-hop single-cardinality is rejected at build time with the offending path’s hop count in the message.

  • List-cardinality @splitQuery supports any hop count, including junction-table chains (@reference(path: […​, …​])).

  • Implicit on @table-typed fields of a @record parent reachable from @service: re-entry queries are emitted automatically. Explicit @splitQuery on those fields is redundant but not rejected. (classifyChildFieldOnResultType never inspects @splitQuery on record-parent table-bound fields.)

  • Self-referencing FKs work inline in the rewrite. InlineTableFieldEmitter prefixes alias names with the parent alias’s runtime name so recursive subselects don’t collide; Category.parent and Category.children in the example schema cover this end-to-end. @splitQuery is available on self-FKs for the same fan-out-reduction reasons as any other reference, but is not required.

  • Required when @service is used on a non-root field (the service fetches the row independently of the parent’s SELECT; the split is the contract).

  • Composes with @asConnection: the per-parent partition envelope is added to the connection’s pagination shape.

  • Composes with @lookupKey: the lookup key narrows the per-parent batch (each parent’s child list filtered by the caller-provided keys).

  • On root (Query/Mutation) fields and on fields with arguments, @splitQuery is irrelevant; those always have explicit data fetchers. The directive is silently ignored there.

  • Nullable FKs on parent-holds-FK single-cardinality fields short-circuit to null before dispatching to the DataLoader (saves a round-trip when no parents have a non-null FK value).

See also

  • @reference supplies the FK chain; @splitQuery decides whether to inline or batch it.

  • @asConnection adds per-parent Relay pagination on top of a split fetch.

  • @lookupKey narrows the per-parent batch to caller-provided keys.

  • @record parents reachable from @service get implicit @splitQuery on @table-typed children.

  • How-to: When to split queries covers the round-trip-vs-fan-out trade-off, self-FK rules, and DataLoader caching behaviour.