Nested Writes

A nested write resolves relation fields in your data argument into the right INSERTs, UPDATEs, and DELETEs — so you can build or mutate a whole object graph in one create() or update() call instead of orchestrating the statements by hand.

The whole tree is written inside one transaction (all-or-nothing) and walked depth-first, capped at depth 10 — past that Turbine throws CircularRelationError (E007). After the write completes, Turbine reads the row back with the touched relations populated, so the return value is the full tree.

When it triggers

A key in data is treated as a nested write when it matches a relation name and its value is a plain object (not null, not an array, not a Date). Everything else is a scalar column. No flag to flip — relation fields just work.

The examples below assume this shape:

// users  hasMany posts, hasOne profile, belongsTo organization
// posts  hasMany comments

Operations at a glance

OpContextRelationsWhat it does
createcreate + updateallInsert new related row(s) and link them
connectcreate + updateallLink existing row(s) by a unique where
connectOrCreatecreate + updateallConnect the row matching where, else create it
disconnectupdate onlyallNull the foreign key (FK must be nullable)
setupdate onlyhasMany / hasOneReplace the whole set — disconnect current, connect listed
deleteupdate onlyallDelete the related row(s) matching where
updateupdate onlyallUpdate the related row(s) (where is optional for belongsTo)
upsertupdate onlyallUpdate the row matching where, else create it

create, connect, and connectOrCreate are valid in both create() and update(). The other five are update-only — using them inside create() throws ValidationError (E003). An unknown operation name throws too.

Create context

create accepts a single object or an array. For a hasMany/hasOne relation the parent is inserted first, then the children inherit the parent's primary key as their foreign key.

const user = await db.users.create({
  data: {
    email: 'alice@example.com',
    name: 'Alice',
    posts: {
      create: [{ title: 'First post' }, { title: 'Second post' }],
    },
  },
});
 
// user.posts is populated — the tree is read back after the write

For a hasOne relation the shape is the same, just a single object:

await db.users.create({
  data: {
    email: 'grace@example.com',
    profile: {
      create: { bio: 'Building things with Postgres.' },
    },
  },
});

For a belongsTo relation the foreign key lives on the row you're creating, so Turbine resolves the related row before the parent INSERT and folds its key in. That means a NOT NULL FK column is satisfied on the first insert.

await db.users.create({
  data: {
    email: 'erin@example.com',
    organization: {
      create: { name: 'Acme Inc' },
    },
  },
});

connect takes a where (one object or an array) that must match existing rows. If a target doesn't exist, Turbine throws ValidationError.

await db.users.create({
  data: {
    email: 'bob@example.com',
    posts: {
      connect: [{ id: 10 }, { id: 11 }],
    },
    organization: {
      connect: { id: 1 }, // belongsTo — sets users.org_id
    },
  },
});

connectOrCreate — connect or insert

Each item is { where, create }: Turbine looks up where, links it if found, otherwise inserts the create payload (with the FK injected for hasMany/hasOne).

await db.users.create({
  data: {
    email: 'carol@example.com',
    posts: {
      connectOrCreate: {
        where: { slug: 'welcome' },
        create: { slug: 'welcome', title: 'Welcome' },
      },
    },
  },
});

Going deeper

Nested writes recurse. A create payload can itself contain relation ops, up to depth 10:

await db.users.create({
  data: {
    email: 'frank@example.com',
    posts: {
      create: {
        title: 'Hello',
        comments: {
          create: [{ body: 'First!' }, { body: 'Nice post.' }],
        },
      },
    },
  },
});

Update context

Inside update(), the create-context ops (create / connect / connectOrCreate) behave exactly as above, and five more become available.

await db.users.update({
  where: { id: 1 },
  data: {
    name: 'Alice R.',
    posts: {
      create: { title: 'Fresh post' }, // create still works in update()
    },
  },
});

disconnect — null the foreign key

disconnect sets the child's foreign key to NULL. The FK column must be nullable — otherwise Turbine throws ValidationError telling you to use delete instead.

await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      disconnect: [{ id: 10 }], // posts.user_id = NULL
    },
  },
});

For a belongsTo relation, disconnect nulls the FK on the parent row instead (again requiring a nullable column):

await db.users.update({
  where: { id: 1 },
  data: {
    organization: { disconnect: {} }, // users.org_id = NULL
  },
});

set — replace the whole collection

set takes an array and makes the relation contain exactly those rows: every current child is disconnected, then the listed rows are connected. hasMany/hasOne only.

await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      set: [{ id: 12 }, { id: 13 }], // these become the user's only posts
    },
  },
});
await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      delete: { id: 10 }, // single object or array
    },
  },
});

Each item is { where, data }. For belongsTo, where is optional — Turbine derives it from the parent's foreign key.

await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      update: { where: { id: 10 }, data: { title: 'Edited title' } },
    },
    organization: {
      update: { data: { name: 'Acme LLC' } }, // where derived from users.org_id
    },
  },
});

upsert — update or insert

Each item is { where, create, update }: update the row matching where, or create it if it doesn't exist.

await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      upsert: {
        where: { slug: 'changelog' },
        create: { slug: 'changelog', title: 'Changelog' },
        update: { title: 'Changelog (updated)' },
      },
    },
  },
});

Guarantees

  • Atomic. Every statement runs inside a single transaction. A failure anywhere rolls the whole tree back.
  • Ordered correctly. belongsTo targets are resolved before the parent insert (so NOT NULL FKs hold); hasMany/hasOne children are written after the parent exists.
  • Depth-capped at 10. Deeper trees throw CircularRelationError (E007) with the full relation path.
  • Validated. Update-only ops in create(), unknown op names, non-nullable disconnect, and missing connect targets all throw ValidationError (E003) before anything commits.
  • Read back. The return value is the parent row with the touched relations loaded via Turbine's json_agg machinery.

See also

  • Relations — how hasMany / hasOne / belongsTo are inferred and read.
  • Transactions & Pipelines — the transaction that wraps every nested write.
  • Typed ErrorsValidationError, CircularRelationError, and the constraint errors a write can raise.
  • API Referencecreate, update, and the full query surface.