Core concepts

Row locks

Control concurrent row access with FOR UPDATE, FOR SHARE, skipLocked, noWait, table targeting, and transaction-only enforcement.

Row locks let you control concurrent access to rows during reads. Better Drizzle wraps Drizzle's FOR UPDATE, FOR SHARE, and related clauses behind a typed lock option on every read helper.

Locks are supported on PostgreSQL and MySQL only. SQLite does not support row-level locking and will throw if you try.

Basic locked read

The simplest form is a string shorthand:

const users = await client.users.findMany({
	where: { active: true },
	lock: 'update',
});

This generates SELECT ... FOR UPDATE.

Object form

The full form gives you access to all lock options:

const users = await client.users.findMany({
	where: { active: true },
	lock: {
		mode: 'update',
		skipLocked: true,
	},
});

Lock modes

ModeSQL (PostgreSQL)SQL (MySQL)
'update'FOR UPDATEFOR UPDATE
'share'FOR SHAREFOR SHARE
'noKeyUpdate'FOR NO KEY UPDATEnot supported
'keyShare'FOR KEY SHAREnot supported

noKeyUpdate and keyShare are PostgreSQL-specific. Using them on MySQL throws LOCK_NOT_SUPPORTED.

skipLocked and noWait

These control what happens when a locked row is already held by another transaction:

  • skipLocked: true — skip rows that are locked, return only unlocked rows
  • noWait: true — fail immediately if any requested row is locked

They are mutually exclusive. Enabling both throws an error.

// Skip locked rows
const available = await client.jobs.findMany({
	where: { status: 'pending' },
	lock: {
		mode: 'update',
		skipLocked: true,
	},
});
// Fail fast if locked
try {
	const row = await client.jobs.findFirst({
		where: { id: 1 },
		lock: {
			mode: 'update',
			noWait: true,
		},
	});
} catch (error) {
	// LOCK_TIMEOUT — the row was locked by another transaction
}

Lock specific tables (PostgreSQL only)

On PostgreSQL you can scope the lock to specific tables using tables. This is useful in joins where you only want to lock certain tables:

const posts = await client.posts.findMany({
	where: { published: true },
	include: { author: true },
	lock: {
		mode: 'update',
		tables: ['posts'],
	},
});

tables accepts both TypeScript table keys and database table names. Duplicate table references (by database name) are deduplicated automatically.

PostgreSQL only

tables is only supported on PostgreSQL. Using it on MySQL or SQLite throws LOCK_NOT_SUPPORTED.

Which methods accept lock?

Locks work on every read helper that accepts QueryArgs:

MethodAccepts lock
findManyYes
findFirstYes
findOneYes
findUniqueYes
paginateYes
cursorYes
countNo
existsNo
all write operationsNo

Enforcing transaction-only locks

Row locks outside a transaction hold until the end of the connection's implicit transaction. This can cause contention. You can enforce that locks only run inside explicit transactions:

const client = better(db, {
	schema,
	locks: {
		transactionsOnly: true,
	},
});

With this config, any lock outside client.transaction(...) throws LOCK_REQUIRES_TRANSACTION:

// This throws
const users = await client.users.findMany({
	lock: 'update',
});

// This works
await client.transaction(async (tx) => {
	return tx.users.findMany({
		lock: 'update',
	});
});

Locked reads inside transactions

The most common pattern is combining locks with transactions for atomic read-then-write:

await client.transaction(async (tx) => {
	const job = await tx.jobs.findFirst({
		where: { status: 'pending' },
		lock: {
			mode: 'update',
			noWait: true,
		},
	});

	if (!job) return null;

	await tx.jobs.update({
		where: { id: job.id },
		data: { status: 'processing' },
	});

	return job;
});

Cursor pagination with locks

Locks propagate to cursor pagination, including the internal hasPrevious/hasNext probe queries:

const page = await client.users.cursor({
	where: { active: true },
	lock: {
		mode: 'update',
		skipLocked: true,
	},
	limit: 25,
	after: previousCursor,
});

Single-relation includes with locks

Locks work with a single One relation include on the fast path:

const posts = await client.posts.findMany({
	where: { published: true },
	include: { author: true },
	lock: 'update',
});

General relation loading (multiple include fields or relation select) is intentionally rejected when lock is present. This avoids silently dropping the lock on the relation query:

// This throws LOCK_NOT_SUPPORTED
const posts = await client.posts.findMany({
	include: { author: true, tags: true },
	lock: 'update',
});

Error handling

Lock acquisition failures are normalized to BetterDrizzleError with code LOCK_TIMEOUT:

import { BetterDrizzleError, BetterDrizzleErrorCode } from 'better-drizzle';

try {
	await client.transaction(async (tx) => {
		return tx.users.findMany({
			where: { id: 1 },
			lock: {
				mode: 'update',
				noWait: true,
			},
		});
	});
} catch (error) {
	if (error instanceof BetterDrizzleError && error.code === BetterDrizzleErrorCode.LockTimeout) {
		console.log('Row is locked by another transaction');
	}
}

Dialect support

FeaturePostgreSQLMySQLSQLite
lock: 'update'YesYesrejected
lock: 'share'YesYesrejected
lock: 'noKeyUpdate'Yesrejectedrejected
lock: 'keyShare'Yesrejectedrejected
skipLockedYesYesN/A
noWaitYesYesN/A
tablesYesrejectedN/A
transactionsOnlyYesYesN/A

On this page