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.
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:
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:
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:
author | text | | |
Foreign-key constraints:
"Post_author_fkey" FOREIGN KEY (author) REFERENCES "Author"(id) ON UPDATE CASCADE ON DELETE SET NULLKeep 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#
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:
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:
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:
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:
-- 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:
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 ?:
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:
error: You provided invalid data for this operation.
- Post: A post needs an author. | code: KS_VALIDATION_FAILUREWithout 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.
updatePostwithauthor: { disconnect: true }fails in Prisma; underkeystone devthe stack trace readsUnknown argument `disconnect`. Did you mean `connect`?. A required to-one relation has no disconnect. Moving a post to another author withconnectstill works. - Deleting an author who still has posts. With
ON DELETE RESTRICT, PostgreSQL blocks it, and Keystone returns aKS_PRISMA_ERRORwhose debug trace underkeystone devreadsForeign 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 theON DELETEchoices, 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: falseon 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):
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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Prisma deleteMany: Foreign key constraint failed (P2003)
Why profile.deleteMany() is blocked by Project_profile_email_fkey, which common tweaks leave the error in place, and how to prove the child rows are really gone.
Prisma $queryRaw TS2345: 'string' not assignable to Sql
Why a NestJS PrismaService rejects a template literal in brackets, three fixes that compile and run on Prisma 7, and the two errors that come next.
Fix Prisma "Could not figure out an ID in create" panic
Find which of the two empty-insert causes you have in one psql query, apply the one-line fix, and see why Prisma 7 returns null instead of crashing.


