@reference declares a foreign-key path between the field’s parent table and its target table. The generator threads the path through jOOQ’s catalog and emits a JOIN whenever the field is selected. The reference page covers signature and constraints; this recipe walks the variants the directive surface supports: when you can omit it entirely, multi-hop chains and junction-table traversals, the two ways to pin a hop (by key: or by table:), inline cross-table column references, the input-field position, and the current state of the condition: form.
For the surrounding question of "when does the join fire vs DataLoader-batch instead of inline", see How-to: When to split queries. This recipe assumes the field is inline-joinable (no @splitQuery); the path mechanics are the same either way.
Single-hop FK references
The canonical case: name the FK constraint, the generator joins through it.
type Customer @table(name: "customer") {
address: Address @reference(path: [{key: "customer_address_id_fkey"}])
}
customer_address_id_fkey is the constraint declared in the schema’s DDL. A query selecting customers { address { district } } emits one statement:
SELECT customer.first_name AS "firstName",
address.district AS "district"
FROM customer
LEFT JOIN address ON customer.address_id = address.address_id
The JOIN fires only when the selection set traverses through address; selecting only firstName skips it. This is the projection-narrowing the rewrite applies to every reachable path: nothing is fetched that the request didn’t ask for.
The key: value accepts two forms, both resolving to the same FK:
-
Lowercase SQL constraint name.
"customer_address_id_fkey"matches whatpsql \d customershows. -
Java-constant style.
"CUSTOMER__CUSTOMER_ADDRESS_ID_FKEY"matches the constant generated into jOOQ’sKeysclass.
Either works; pick whichever your team’s style guide prefers. The classifier walks both indices in parallel.
Implicit references: when the catalog disambiguates
When exactly one foreign key exists between two tables, @reference is unnecessary and the generator picks the path automatically:
type Store @table(name: "store") {
customers: [Customer!]! @defaultOrder(primaryKey: true)
}
There is exactly one FK between customer and store (customer.store_id), so the join is implicit. The @table-bound parent type plus the @table-bound return type plus a unique FK between them is enough; the catalog finds the path. This is the recommended shape when it applies; explicit @reference is noise when the catalog can resolve the path.
The generator does not guess when more than one FK exists. The Sakila store ↔ staff pair has two FKs (store.manager_staff_id and staff.store_id), so any field crossing it must declare which one:
type Store @table(name: "store") {
manager: Staff @splitQuery @reference(path: [{key: "store_manager_staff_id_fkey"}])
}
Without @reference here, the build fails at classify time with an ambiguous-reference diagnostic listing both candidate FKs. The fix is always one of two things: name the FK with key:, or use table: if the disambiguation goes the other direction.
Multi-hop chains
@reference accepts a list of hops; the generator chains them into one JOIN sequence.
type Customer @table(name: "customer") {
storeAddress: Address @reference(path: [
{key: "customer_store_id_fkey"},
{key: "store_address_id_fkey"}
])
}
The path is customer.store_id → store.store_id → store.address_id → address.address_id: two FKs and three tables touched in one SQL statement. Each hop’s key: pins one of the FKs; the generator infers the intermediate tables from the FK target metadata.
Multi-hop chains are how junction tables work too. A many-to-many through film_actor is two FKs in sequence:
type Film @table(name: "film") {
actors(actor_id: [Int!] @lookupKey): [Actor!]! @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
])
}
The first hop joins film to film_actor via the film_id FK; the second joins film_actor to actor via the actor_id FK. The junction table itself does not appear in the GraphQL schema; it’s purely path infrastructure. The same shape works on connections (actorsConnection: ActorsConnection!) and under @splitQuery (actorsBySplitLookup: …).
The number of hops is unbounded; chains of three, four, or more FKs work the same way. Each hop is independent and resolves against the previous hop’s destination table.
table: as a short form
The reference’s ReferenceElement accepts table: as an alternative to key:. When exactly one FK exists between the source and destination tables for that hop, table: is enough:
type Customer @table(name: "customer") {
district: String @reference(path: [{table: "address"}])
}
This resolves to the same FK as {key: "customer_address_id_fkey"} because customer ↔ address has exactly one FK between them. When the table pair is ambiguous, table: fails:
# Customer ↔ Address has only one FK; this works.
district: String @reference(path: [{table: "address"}])
# Film ↔ Language has only one FK; this works.
languageName: String @field(name: "name") @reference(path: [{table: "language"}])
But two language-pointing FKs (e.g. a hypothetical original_language_id alongside language_id) would make the second example ambiguous, and the build would reject it as an UnclassifiedField with the same ambiguous-reference diagnostic. The table: form is the catalog-driven shortcut; the key: form is the explicit one. Use whichever expresses the intent more clearly:
-
key:when the constraint name is the source of truth (DBA-managed schemas, named-constraint conventions). -
table:when the destination is what’s interesting and the FK choice is unambiguous.
Setting both key: and table: is allowed but redundant; the generator validates they agree.
Inline cross-table columns
@reference is most often seen on object-typed fields, but it works on scalar fields too: the parent type pulls a column from a referenced table directly into its own shape, with no intermediate object.
type Film @table(name: "film") {
languageName: String @field(name: "name") @reference(path: [{key: "film_language_id_fkey"}])
}
Film.languageName projects language.name (joined through film.language_id) directly onto the film type. The classifier produces a ColumnReferenceField; the emitter joins through the FK and projects the target column under the requested alias. No intermediate language object appears; clients see Film { languageName } as a flat scalar.
This is useful when the schema wants to expose a foreign column without the indirection cost of nesting through the linked type. The trade-off: every Film selection that includes languageName triggers the join. A field that already nests through language (e.g. Film { language { name } }) is preferred when other language columns are also exposed; the languageName shortcut is for the "expose exactly one column" case.
The shortcut also accepts the multi-hop and table: forms:
type Customer @table(name: "customer") {
storeManagerName: String
@field(name: "first_name")
@reference(path: [
{key: "customer_store_id_fkey"},
{key: "store_manager_staff_id_fkey"}
])
}
Same shape as a multi-hop object reference; the only difference is the parent type’s projection list at SQL time, which gets staff.first_name aliased to storeManagerName.
Input-field references
@reference also applies to INPUT_FIELD_DEFINITION (and ARGUMENT_DEFINITION), letting an input shape carry a value that resolves through a foreign-key path against the implicit return table.
input Input {
district: String! @reference(path: [{table: "address"}])
}
type Customer @table(name: "customer") {
customerId: Int! @field(name: "customer_id")
}
type Query {
query(in: Input!): Customer
}
The query’s return type Customer has @table(name: "customer"), so the input type Input is implicitly bound to the customer table for the duration of this argument site. The @reference(path: [{table: "address"}]) on the district input field resolves the customer→address FK and threads the input value as a predicate against address.district. The classifier emits a ColumnReferenceField for input fields the same way it emits one for object fields, just with the predicate-shape instead of projection-shape at runtime.
This is the input-side mirror of the inline-column shape: the input field carries a value that the generator resolves through the FK path, not by name on the parent table. The full set of input-field directives (@field, @condition, @reference) interoperate; see How-to: Stacking and overriding conditions for the input-carrier rules and how the input’s @table cascade interacts with the per-field directives.
The condition: form (classify-only today)
ReferenceElement has a third option: condition: is an ExternalCodeReference to a Java method that returns a org.jooq.Condition to use as the join predicate. The classifier accepts it, but the generator’s emitter for ConditionJoin paths is currently a runtime-throwing stub:
type Category @table(name: "category") {
similar: Category @reference(path: [{condition: {
className: "no.sikt.graphitron.rewrite.test.conditions.CategoryConditions",
method: "sameNamePrefix"
}}])
}
The Java side:
public static Condition sameNamePrefix(Table<?> src, Table<?> tgt) {
return DSL.noCondition();
}
The classifier produces a JoinStep.ConditionJoin and the schema validates, but selecting Category.similar at request time throws UnsupportedOperationException until the emitter ships a real body. This shape is reserved for non-FK joins: catalog-derived predicates (e.g. "category whose name shares a prefix with this one"), date-range overlaps, computed predicates with no FK constraint to anchor against. Today, treat the schema as a placeholder; production schemas should not include condition:-only references.
When the runtime arm lands, the contract will be: the method receives the source and target table aliases (both as Table<?>) and returns the Condition to use on the JOIN’s ON clause. The framework wraps that into the JOIN at SQL emit time. Mixed paths that combine a key: hop with a condition:-only terminal hop are also reserved.
@splitQuery on a referenced field
Adding @splitQuery switches the field from inline-JOIN dispatch to per-parent DataLoader batching. The @reference path stays the same; only the runtime shape changes.
type Customer @table(name: "customer") {
address: Address @reference(path: [{key: "customer_address_id_fkey"}])
addressSplit: Address @splitQuery @reference(path: [{key: "customer_address_id_fkey"}])
}
address resolves inline as part of the customer query (one SQL statement, JOIN-projected columns).
addressSplit resolves via a DataLoader: the customer query runs first, then a per-request batched address lookup keyed on customer.address_id runs in a second statement, fanned out to all parents that selected addressSplit.
The choice is operational, not semantic; How-to: When to split queries covers when round-trip cost beats fan-out cost (typically: deep paths, many sibling parents, large parent projections). The path itself is identical.
Pitfalls
-
path:must be non-empty.@reference(path: [])fails graphql-java parsing. Every reference declares at least one hop. -
Multi-FK pairs require explicit
key:ortable:. When more than one FK exists between two tables for a given hop, the build fails with an ambiguous-reference diagnostic. Pick one withkey:(preferred when the FK has a stable name) ortable:(preferred when the FK is unique-but-unnamed in your conventions, and the destination disambiguates). -
table:requires unique-FK-between-the-pair. Otherwise the field classifies asUnclassifiedFieldand the build rejects. The diagnostic spells out the candidate FKs; switch tokey:to disambiguate. -
Implicit references work only when one FK exists. The example schema’s
Store.customersworks without@referencebecausecustomer ↔ storehas exactly one FK;Store.managercannot omit@referencebecausestore ↔ staffhas two FKs. -
Inline column references join the linked table.
languageName: String @field(name: "name") @reference(…)adds a JOIN tolanguagewheneverlanguageNameis selected. If the schema also exposesFilm.language { name }and clients tend to select via the nested form, the inline shortcut is redundant cost. -
condition:-only paths classify but throw at request time today. Use only as a placeholder pending the runtime arm; production schemas should stick tokey:andtable:forms. -
Junction tables are path-only, never types. The
film_actorjoin table is two FKs in sequence; it does not appear as a GraphQL type. Adding it as a@table-bound type would be valid (FilmActor exists in the example schema for@nodeIdpurposes), but client-facing many-to-many fields use the path form. -
FK constraint names are catalog-bound. A migration that renames a constraint without updating the schema’s
key:value breaks the build. Thetable:form is more migration-resilient when the FK pair stays unique; thekey:form is more explicit when constraint names are the contract.
See also
-
@referenceis the directive surface this recipe expands. -
@tableestablishes the table binding both endpoints rely on. -
How-to: Polymorphic types for the union/interface variant, modelled through
@discriminate/@discriminator. -
How-to: When to split queries covers the inline-JOIN vs per-parent batch trade-off; the
@referencepath is shared across both. -
How-to: Stacking and overriding conditions covers the input-carrier rules and how input-field references interact with the condition cascade.
-
Tutorial page 4: Joining tables introduces single-hop and multi-hop references in narrative form.