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.

Names from inside the generator. This page names types from graphitron’s own classification model. They are accurate today, and worth knowing when a rejection message quotes one back at you, but they are not part of the authoring contract: the classification walk that produces them is being drained, and these names retire with it. What stays stable is the schema you write, the directive reference that describes it, and the diagnostics glossary.

@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 (a Table-sourced BatchedTableField carrying the keyed lookup resolution: 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 class-backed 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 class-backed-parent table-bound fields.)

  • Rejected at build time ([deferred]) on a field whose return type is a plain-object nesting type (a directiveless object projected off the parent’s own table row). The batched launcher the split implies is not generated, and accepting the directive would silently ignore it. This is the line between the positionally inert cases on this page: where the fetch is already separate (root fields, argument-bearing fields, class-backed parents) the directive is redundant and at most warned about; where honoring it would require a delivery that does not exist (a nesting field) it is unimplemented and rejected. Remove the directive to keep the inline projection; @splitQuery on table-backed fields inside the nesting type, and on @pivot fields, is supported as usual.

  • Redundant but not rejected on a field returning a @table + @discriminate interface (a single-table discriminated interface). This sits on the redundant and at most warned about side of the line the nesting bullet above draws, and for the stronger of the two reasons: the discriminated re-projection is always its own statement, so the directive’s own meaning (stop projecting this child into its parent’s SELECT) is satisfied before it is applied, and the delivery an author reaching for it actually wants is already the default. List and connection cardinality batch the re-projection through a DataLoader on their own, one statement for the whole batch of parents (the connection form windows that statement into per-parent pages); single cardinality fetches per parent, which no directive changes. Writing @splitQuery here yields a lint warning (splitquery-redundant-on-discriminated-interface-child) offering to delete it, and the directive is ignored.

  • Self-referencing FKs work inline in the rewrite. the inline projection arm 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.

  • Class-backed 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.

  • LSP inlay hints and classification hover marks the separately fetched fields in the editor, for every reason a split happens, so a type’s round-trip cost can be read off the schema.