# Relation writes

> Attach, detach, and replace related rows from a single create, update, or upsert with connect, disconnect, and set.

URL: https://better-drizzle.com/docs/writing/relation-writes

Use relation commands when a single-row `create`, `update`, or `upsert` should also attach, detach, or replace related rows. You describe the link and better-drizzle resolves the foreign keys and junction rows inside one transaction.

The examples use `author` (a to-one relation on `posts`) and `labels` (a many-to-many relation from `posts` to `tags` through a `postTags` junction).



**better-drizzle**

```ts  
const post = await client.posts.create({
	data: {
		title: 'Relations without the glue',
		author: { connect: { email: 'alice@example.com' } },
		labels: { connect: [{ slug: 'drizzle' }, { slug: 'typescript' }] },
	},
	include: { author: true, labels: true },
});
```

**Drizzle**

```ts  
import { eq, inArray } from 'drizzle-orm';

const post = await db.transaction(async (tx) => {
	const [author] = await tx
		.select()
		.from(users)
		.where(eq(users.email, 'alice@example.com'));
	if (!author) throw new Error('Author not found');

	const tagRows = await tx
		.select()
		.from(tags)
		.where(inArray(tags.slug, ['drizzle', 'typescript']));
	if (tagRows.length !== 2) throw new Error('Tag not found');

	const [created] = await tx
		.insert(posts)
		.values({ title: 'Relations without the glue', authorId: author.id })
		.returning();
	await tx
		.insert(postTags)
		.values(tagRows.map((tag) => ({ postId: created.id, tagId: tag.id })));

	// assemble the relation payload by hand
	return { ...created, author, labels: tagRows };
});
```

No `authorId` in sight, no junction rows to insert by hand, and `post` comes
back typed with both relations attached.

## Attach existing rows with `connect`

`connect` links rows that already exist. On a to-one relation it takes a
single selector; on a to-many relation it takes one selector or an array.



**better-drizzle**

```ts  
// to-one: resolve the author and write the foreign key
await client.posts.create({
	data: {
		title: 'Hello',
		author: { connect: { email: 'alice@example.com' } },
	},
});

// to-many: attach three existing posts to a user
await client.users.update({
	where: { id: 1 },
	data: {
		posts: { connect: [{ id: 2 }, { id: 3 }, { id: 9 }] },
	},
});
```

**Drizzle**

```ts  
import { eq, inArray } from 'drizzle-orm';

// to-one: resolve the author and write the foreign key
await db.transaction(async (tx) => {
	const [author] = await tx
		.select({ id: users.id })
		.from(users)
		.where(eq(users.email, 'alice@example.com'));
	if (!author) throw new Error('Author not found');
	await tx.insert(posts).values({ title: 'Hello', authorId: author.id });
});

// to-many: attach three existing posts to a user
await db.transaction(async (tx) => {
	const [user] = await tx
		.select({ id: users.id })
		.from(users)
		.where(eq(users.id, 1));
	if (!user) throw new Error('User not found');
	const updated = await tx
		.update(posts)
		.set({ authorId: user.id })
		.where(inArray(posts.id, [2, 3, 9]))
		.returning({ id: posts.id });
	if (updated.length !== 3) throw new Error('Post not found');
});
```

For a many-to-many relation declared with `.through()` in `defineRelations`, `connect` inserts the junction rows for you, as in the `labels` example at the top: you never name the junction table.

## Detach rows with `disconnect`

`disconnect` breaks the link and leaves both rows in place. The shape differs
by cardinality, because a to-one relation has nothing to select:



**better-drizzle**

```ts  
// to-one: `true`, not a selector
await client.posts.update({
	where: { id: 10 },
	data: {
		author: { disconnect: true },
	},
});

// to-many: the rows to detach
await client.users.update({
	where: { id: 1 },
	data: {
		posts: { disconnect: [{ id: 2 }, { id: 3 }] },
	},
});
```

**Drizzle**

```ts  
import { and, eq, inArray } from 'drizzle-orm';

// to-one: null the foreign key
await db.update(posts).set({ authorId: null }).where(eq(posts.id, 10));

// to-many: the rows to detach; rows linked to another user are left alone
await db
	.update(posts)
	.set({ authorId: null })
	.where(and(eq(posts.authorId, 1), inArray(posts.id, [2, 3])));
```

A to-many `disconnect` selector must match an existing row, but a row that is
linked to a different parent is skipped rather than rejected.

## Replace a relation with `set`

`set` makes the relation exactly what you pass, connecting what is missing and
disconnecting what is no longer listed. It is the right command for "these are
the tags now" semantics:



**better-drizzle**

```ts  
// the post ends up with exactly these two tags
await client.posts.update({
	where: { id: 10 },
	data: {
		labels: { set: [{ slug: 'drizzle' }, { slug: 'orm' }] },
	},
});

// an empty array clears the relation
await client.posts.update({
	where: { id: 10 },
	data: { labels: { set: [] } },
});

// to-one: repoint it, or null it out
await client.posts.update({
	where: { id: 10 },
	data: { author: { set: { email: 'bob@example.com' } } },
});

await client.posts.update({
	where: { id: 10 },
	data: { author: { set: null } },
});
```

**Drizzle**

```ts  
import { eq, inArray } from 'drizzle-orm';

// the post ends up with exactly these two tags
await db.transaction(async (tx) => {
	const tagRows = await tx
		.select({ id: tags.id })
		.from(tags)
		.where(inArray(tags.slug, ['drizzle', 'orm']));
	if (tagRows.length !== 2) throw new Error('Tag not found');
	await tx.delete(postTags).where(eq(postTags.postId, 10));
	await tx
		.insert(postTags)
		.values(tagRows.map((tag) => ({ postId: 10, tagId: tag.id })));
});

// an empty array clears the relation
await db.delete(postTags).where(eq(postTags.postId, 10));

// to-one: repoint it, or null it out
await db.transaction(async (tx) => {
	const [author] = await tx
		.select({ id: users.id })
		.from(users)
		.where(eq(users.email, 'bob@example.com'));
	if (!author) throw new Error('Author not found');
	await tx.update(posts).set({ authorId: author.id }).where(eq(posts.id, 10));
});

await db.update(posts).set({ authorId: null }).where(eq(posts.id, 10));
```

Reaching for `set` where `connect` would do means re-resolving rows that were
already linked. Prefer `connect` / `disconnect` for incremental edits and keep
`set` for replace-everything flows.

## `upsert` follows its branch

`upsert` applies the rules of whichever branch runs. The `create` branch
accepts `connect` only; the `update` branch accepts all three commands:



**better-drizzle**

```ts  
await client.posts.upsert({
	where: { slug: 'hello-world' },
	create: {
		slug: 'hello-world',
		title: 'Hello world',
		author: { connect: { email: 'alice@example.com' } },
	},
	update: {
		title: 'Hello world (revised)',
		labels: { set: [{ slug: 'drizzle' }] },
	},
});
```

**Drizzle**

```ts  
import { eq } from 'drizzle-orm';

await db.transaction(async (tx) => {
	const [existing] = await tx
		.select({ id: posts.id })
		.from(posts)
		.where(eq(posts.slug, 'hello-world'));

	if (existing) {
		const [tag] = await tx
			.select({ id: tags.id })
			.from(tags)
			.where(eq(tags.slug, 'drizzle'));
		if (!tag) throw new Error('Tag not found');
		await tx
			.update(posts)
			.set({ title: 'Hello world (revised)' })
			.where(eq(posts.id, existing.id));
		await tx.delete(postTags).where(eq(postTags.postId, existing.id));
		await tx.insert(postTags).values({ postId: existing.id, tagId: tag.id });
		return;
	}

	const [author] = await tx
		.select({ id: users.id })
		.from(users)
		.where(eq(users.email, 'alice@example.com'));
	if (!author) throw new Error('Author not found');
	await tx.insert(posts).values({
		slug: 'hello-world',
		title: 'Hello world',
		authorId: author.id,
	});
});
```

## Edge cases

**Selectors must match exactly one row**

A relation selector is a partial of the related row, and it has to identify
exactly one row. Matching zero rows or more than one row is an error, not a
silent no-op:

```ts
// ✅ unique column
{ connect: { email: 'alice@example.com' } }

// ❌ throws: matches many users
{ connect: { active: true } }

// ❌ throws: matches no user
{ connect: { email: 'nobody@example.com' } }
```

Selecting on a unique or primary-key column is the reliable choice. See
[error handling](/docs/advanced/error-handling) for the structured error these
failures produce.

**Disconnecting a required foreign key**

Disconnecting a to-one relation nulls its foreign key, so it only works when the column is nullable. On a `notNull()` foreign key the operation fails and the whole write rolls back - use `set` to point it somewhere else instead.

**Everything runs in one transaction**

The root mutation and every relation change run in a single implicit
transaction, unless the call is already inside
[`client.transaction()`](/docs/advanced/transactions) - in which case it joins
the transaction you opened.

That means partial relation writes cannot be observed. A missing selector, an
ambiguous selector, a required foreign key, or an invalid command combination
fails the whole operation and rolls the scalar changes back with it.

```ts
await client.transaction(async (tx) => {
	const post = await tx.posts.create({
		data: {
			title: 'Draft',
			author: { connect: { email: 'alice@example.com' } },
		},
	});

	// same transaction - no nested implicit one is opened
	await tx.posts.update({
		where: { id: post.id },
		data: { labels: { connect: [{ slug: 'draft' }] } },
	});

	return post;
});
```

Delegate plugin state set with `$withState(...)` is preserved into the
implicit transaction, so soft delete, timestamps, and your own plugins behave
the same as in a plain write.

**Batch operations stay scalar**

`createMany`, `updateMany`, `updateEach`, and `upsertMany` do **not** accept
relation commands. They are native-first batch statements, and resolving
per-row relation selectors would quietly turn them into loops - exactly the
cost they exist to avoid.

When you need relation writes across many rows, loop the single-row API inside
one explicit transaction and keep the cost visible:

```ts
await client.transaction(async (tx) => {
	for (const input of inputs) {
		await tx.posts.create({
			data: {
				title: input.title,
				author: { connect: { email: input.authorEmail } },
			},
		});
	}
});
```

## Reference

Which commands a relation accepts depends on its cardinality and on whether
you are creating or updating.

| Command      | To-one on `create` | To-one on `update`     | To-many on `create`         | To-many on `update`         |
| ------------ | ------------------ | ---------------------- | --------------------------- | --------------------------- |
| `connect`    | Yes - one selector | Yes - one selector     | Yes - one or many selectors | Yes - one or many selectors |
| `disconnect` | -                  | `true`                 | -                           | Yes - one or many selectors |
| `set`        | -                  | one selector or `null` | -                           | an array of selectors       |

> **set is exclusive:** `set` replaces the entire relation, so it cannot be combined with `connect`
> or `disconnect` for that same relation. The type system enforces this -
> mixing them is a compile error, not a runtime surprise.
