PostgreSQL

Keystone 6 Foreign Key NOT NULL on a Relationship Field

Turn a nullable Keystone relationship column into a NOT NULL foreign key, backfill the rows that block the push, and replace raw Prisma errors with clear messages.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
7 min read

TL;DR#

A Keystone 6 relationship() field always produces a nullable foreign key column, and it has no isRequired or isNullable option to change that. To get NOT NULL in PostgreSQL, use the field's db.extendPrismaSchema function to remove the ? from the two Prisma fields Keystone generates for the relationship. Backfill any rows that are still NULL first, or keystone dev refuses to push the schema. Then add validate hooks, so editors get a readable message rather than a raw Prisma error.

The question, and what the API offers today#

The Stack Overflow question asks how to add NOT NULL to a relationship field in a Keystone 6 schema backed by PostgreSQL and Prisma: "I can't create a relationship field with isRequired = true, which means NOT NULL." The answer from 2022 confirms that relationship fields "currently don't support the validation.isRequired or db.isNullable options".

That is still true. The package is still called @keystone-6/core, but its version number has moved on: the latest release is 8.1.0, from 31 August 2026. In the relationship field source on main, the db block of a to-one relationship accepts only extendPrismaSchema and foreignKey. A to-many relationship accepts relationName and extendPrismaSchema. The relationship field reference documents db.foreignKey, which only chooses which table stores the key, not whether it may be NULL.

TypeScript rejects both options in the same tsc run, one error per schema file tried:

text
schema.bad.ts(17,15): error TS2353: Object literal may only specify known properties, and 'isNullable' does not exist in type '{ extendPrismaSchema?: ((field: string) => string) | undefined; foreignKey?: true | { map: string; } | undefined; } | { relationName?: string | undefined; extendPrismaSchema?: ((field: string) => string) | undefined; }'.
schema.bad2.ts(16,9): error TS2353: Object literal may only specify known properties, and 'validation' does not exist in type '(CommonFieldConfig<BaseListTypeInfo, FieldTypeInfo> & { many?: boolean | undefined; ref: "Author"; ui?: { hideCreate?: boolean | undefined; } | undefined; } & OneDbConfig & SelectDisplayConfig<...>) | (CommonFieldConfig<...> & ... 2 more ... & SelectDisplayConfig<...>) | (CommonFieldConfig<...> & ... 2 more ... & Co...'.

Silencing that error with @ts-expect-error gains nothing. In the reproduction below, Keystone started without complaint, generated the same nullable column, and a post with no author was created as before.

Why the column is nullable#

Keystone writes the Prisma schema for you. For a two-sided Post.author ↔ Author.posts relationship, the generated model is:

text
model Post {
  id       String  @id @default(cuid())
  title    String  @default("")
  author   Author? @relation("Post_author", fields: [authorId], references: [id])
  authorId String? @map("author")
 
  @@index([authorId])
}

The ? on both lines makes the relation optional. Prisma maps the scalar authorId to a database column named author (note the @map), and PostgreSQL ends up with a nullable column and a foreign key that sets the column to NULL when the author is deleted:

text
 author | text |           |          | 
Foreign-key constraints:
    "Post_author_fkey" FOREIGN KEY (author) REFERENCES "Author"(id) ON UPDATE CASCADE ON DELETE SET NULL

Keep the two names apart: the Prisma field is authorId, the SQL column is author. Your regex in the fix targets the Prisma text, while psql and raw SQL use the column name.

Fix: make the relationship NOT NULL#

The second answer on the question points to db.extendPrismaSchema, and the official extend-prisma-schema example uses it on a relationship field. The function receives the Prisma text for that one field and returns the text Keystone should use instead. The example carries its own warning in a comment: "WARNING: this won't be easy to use, but if you know what you're doing...". The sections below show what that means in practice.

1. Strip the ? from both generated fields#

ts
import { list } from '@keystone-6/core'
import { text, relationship } from '@keystone-6/core/fields'
import { allowAll } from '@keystone-6/core/access'
 
export const lists = {
  Author: list({
    access: allowAll,
    fields: {
      name: text(),
      posts: relationship({ ref: 'Post.author', many: true }),
    },
  }),
  Post: list({
    access: allowAll,
    fields: {
      title: text(),
      author: relationship({
        ref: 'Author.posts',
        db: {
          extendPrismaSchema: field =>
            field
              .replace(/author Author\?/, 'author Author')
              .replace(/authorId String\?/, 'authorId String'),
        },
      }),
    },
  }),
}

The string your function receives is not aligned like the final schema.prisma. Logged from the function, it was:

text
FIELD>>"author Author? @relation(\"Post_author\", fields: [authorId], references: [id])\nauthorId String? @map(\"author\") \n@@index([authorId])"

Single spaces, so the patterns above match. If you rename the field, the patterns must change with it. String.prototype.replace returns the text unchanged when nothing matches, with no warning, which is why the verification step below matters.

2. Backfill the rows that are still NULL#

On a table that already holds posts without an author, restarting keystone dev stops at the database push:

text
Error: 
⚠️ We found changes that cannot be executed:
 
  • Made the column `author` on table `Post` required, but there are 2 existing NULL values.
 
You may use the --force-reset flag to drop the database before push like prisma db push --force-reset
All data will be lost.

Do not reach for the reset flag on a database with real content. Give every orphaned row a parent instead (or delete the rows), then start Keystone again:

sql
INSERT INTO "Author" (id, name) VALUES ('legacy-author', 'Legacy author');
UPDATE "Post" SET author = 'legacy-author' WHERE author IS NULL;

The next start printed Your database is now in sync with your Prisma schema.

3. Know what the migration does to the foreign key#

If you use Prisma migrations rather than db push, this is the SQL that prisma migrate diff produced for the same change:

text
-- DropForeignKey
ALTER TABLE "Post" DROP CONSTRAINT "Post_author_fkey";
 
-- AlterTable
ALTER TABLE "Post" ALTER COLUMN "author" SET NOT NULL;
 
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_author_fkey" FOREIGN KEY ("author") REFERENCES "Author"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

Two things change, not one. The column becomes NOT NULL, and the delete rule moves from SET NULL to RESTRICT, since a required column can no longer be set to NULL. On a large production table, the PostgreSQL ALTER TABLE reference says SET NOT NULL is checked "by scanning the entire table", unless a valid CHECK constraint already proves no NULL can exist, and that ALTER TABLE takes an ACCESS EXCLUSIVE lock unless a subform notes otherwise. The zero-downtime migrations guide shows the staged route: a CHECK (... IS NOT NULL) NOT VALID constraint, validated first, then a short final SET NOT NULL.

4. Turn raw Prisma errors into messages editors can read#

Once the column is required, Prisma rejects a post without an author, but the GraphQL client sees a generic Prisma error with code KS_PRISMA_ERROR. The detail sits in a stack trace, which Keystone only includes when graphql.debug is on. The config reference sets that default to process.env.NODE_ENV !== 'production', so a production client gets no detail at all. Keystone's validate hooks run before the database write and report through addValidationError. The same hooks protect the other side of the relationship:

ts
import { list } from '@keystone-6/core'
import { text, relationship } from '@keystone-6/core/fields'
import { allowAll } from '@keystone-6/core/access'
 
export const lists = {
  Author: list({
    access: allowAll,
    hooks: {
      validate: {
        delete: async ({ item, context, addValidationError }) => {
          const posts = await context.db.Post.count({
            where: { author: { id: { equals: item.id } } },
          })
          if (posts > 0) {
            addValidationError(`This author still has ${posts} post(s). Reassign or delete them first.`)
          }
        },
      },
    },
    fields: {
      name: text(),
      posts: relationship({ ref: 'Post.author', many: true }),
    },
  }),
  Post: list({
    access: allowAll,
    hooks: {
      validate: {
        create: async ({ resolvedData, addValidationError }) => {
          if (!resolvedData.author) addValidationError('A post needs an author.')
        },
      },
    },
    fields: {
      title: text(),
      author: relationship({
        ref: 'Author.posts',
        ui: { createView: { isRequired: true }, itemView: { isRequired: true } },
        db: {
          extendPrismaSchema: field =>
            field
              .replace(/author Author\?/, 'author Author')
              .replace(/authorId String\?/, 'authorId String'),
        },
      }),
    },
  }),
}

The ui.createView.isRequired and ui.itemView.isRequired options come from the field options reference, which says they make the Admin UI show the field as required. They only affect that display. The hooks and the column do the enforcing.

Verify the fix#

Check three things after the next start.

First, the generated schema.prisma should show the relation without ?:

text
  author   Author @relation("Post_author", fields: [authorId], references: [id])
  authorId String @map("author")

Second, \d "Post" in psql should show not null on the author column and ON DELETE RESTRICT on Post_author_fkey.

Third, send the mutations a client would send. In the run behind this article, a createPost with author: { connect: { id } } and one with a nested author: { create: { name: "Grace" } } both succeeded. A createPost without an author returned the validation hook's message:

text
error: You provided invalid data for this operation.
  - Post: A post needs an author. | code: KS_VALIDATION_FAILURE

Without the hooks, the same mutation returned Prisma error, and the debug stack trace of keystone dev ended in Argument `author` is missing.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

What NOT NULL changes for updates and deletes#

Making the key required changes the GraphQL operations that remain legal, and the errors you get without the hooks are not friendly.

  • Disconnecting the author. updatePost with author: { disconnect: true } fails in Prisma; under keystone dev the stack trace reads Unknown argument `disconnect`. Did you mean `connect`?. A required to-one relation has no disconnect. Moving a post to another author with connect still works.
  • Deleting an author who still has posts. With ON DELETE RESTRICT, PostgreSQL blocks it, and Keystone returns a KS_PRISMA_ERROR whose debug trace under keystone dev reads Foreign key constraint violated on the constraint: `Post_author_fkey` . Reassign or delete the posts first. This is the same parent-still-referenced situation covered in Prisma deleteMany: Foreign key constraint failed (P2003). The PostgreSQL side of it, error 23503 and the ON DELETE choices, is explained in Fix Foreign Key Constraint Violation in Supabase (23503).
  • Both sides required. Do not make both ends of a one-to-one relationship required. The 2022 answer warns that isNullable: false on both sides "would make inserting records impossible": neither row could be inserted first.

When you cannot change the column#

If a database change is not possible yet (shared database, slow approval), the hooks alone give you application-level enforcement. Drop extendPrismaSchema and add an update rule that refuses a disconnect. The Post list then reads (the Author list keeps its validate.delete hook from step 4):

ts
import { list } from '@keystone-6/core'
import { text, relationship } from '@keystone-6/core/fields'
import { allowAll } from '@keystone-6/core/access'
 
export const Post = list({
  access: allowAll,
  hooks: {
    validate: {
      create: async ({ resolvedData, addValidationError }) => {
        if (!resolvedData.author) addValidationError('A post needs an author.')
      },
      update: async ({ resolvedData, addValidationError }) => {
        if (resolvedData.author?.disconnect) addValidationError('A post cannot lose its author.')
      },
    },
  },
  fields: {
    title: text(),
    author: relationship({
      ref: 'Author.posts',
      ui: { createView: { isRequired: true }, itemView: { isRequired: true } },
    }),
  },
})

The hooks reference explains why this test works: for a to-one relationship, resolvedData holds { connect } or { disconnect: true }. With the column left nullable, the run rejected a create without an author, a disconnect, and the deletion of an author with posts, all with KS_VALIDATION_FAILURE. But it is a weaker guarantee, as the original answer says. Anything that bypasses Keystone's GraphQL API and hooks still writes NULL. In the same run, a plain INSERT from psql created a post with no author. Treat the hooks-only setup as a stopgap until the NOT NULL migration can ship.

How this was tested#

The generated schemas, the \d output, the failed push and the backfill, the migrate diff SQL, every mutation result and the type errors were reproduced on 10 October 2026. The run used @keystone-6/core 8.1.0, Prisma 7.10.0 and @prisma/adapter-pg, against PostgreSQL 18.6 from the official Docker image. Node 26 ran keystone dev --no-ui, and the mutations were sent over HTTP to /api/graphql. Each schema change was applied by restarting Keystone, which pushes the Prisma schema on start. The type checks used TypeScript 6.0.3. The Admin UI was not started, so its required marker comes from the documentation only. The same is true of the table-scan and lock behaviour (PostgreSQL documentation) and the one-to-one warning (the 2022 answer). If the push fails before it reaches your schema, with Prisma reporting an invalid port number in the connection string, the usual cause is an unescaped character in the password: see Prisma Can't Connect to PostgreSQL: Fix invalid port. If the backfill or the migration is the part that worries you on a busy table, plan it as an expand/contract change rather than a single ALTER.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote