better-drizzle

Reads

findMany, findFirst, findOne, findUnique, count, and exists - and how to choose between them.

Every table delegate exposes the same read methods. They differ in how many rows they return and how they handle "not found".

MethodReturnsNot-found behavior
findManyRow[]empty array
findFirstRow | nullnull, or .throw()
findOneRow | nullnull, or .throw() (alias of findFirst)
findUniqueRow | nullnull, or .throw()
countnumber0
existsbooleanfalse

findMany

Returns every matching row. The default when you want a list, a feed, or relation loading.

const users = await client.users.findMany({
	where: { active: true },
	orderBy: [{ id: 'asc' }],
	take: 25,
});

findMany accepts the full query surface: where, select / include, orderBy, take, skip, and cursor.

findFirst

The first row after filtering and ordering. Use it when the query can match many rows but you only want one.

const newest = await client.users.findFirst({
	where: { active: true },
	orderBy: [{ id: 'desc' }],
});

findOne

An alias for findFirst, read for intent: a single nullable row where the caller owns the not-found decision.

const user = await client.users.findOne({
	where: { id: 42 },
});

findUnique

Looks up one row by a key. where must pin a single row with an equality on every primary key column, on a column declared .unique(), or on every column of a composite unique() / uniqueIndex(). Other filters may sit next to the key, and a key inside AND counts.

const user = await client.users.findUnique({
	where: { email: 'alice@example.com' },
});

Any other where throws UNIQUE_WHERE_REQUIRED before any SQL runs, including from .explain() and .prepare(). An undefined value does not pin, so findUnique({ where: { id: undefined } }) throws instead of returning an arbitrary row. A param() counts as a value in prepared statements. Use findFirst for lookups by non-unique columns.

count and exists

count returns the number of matching rows. exists is a cheap existence check that never materializes a row.

const activeCount = await client.users.count({
	where: { active: true },
});

const taken = await client.users.exists({
	where: { email: 'alice@example.com' },
});

count and exists accept only where, cursor, and meta - projections and ordering do not apply to them.

Ordering, limit, and offset

const page = await client.posts.findMany({
	orderBy: [{ published: 'desc' }, { id: 'desc' }],
	skip: 20,
	take: 10,
});
  • orderBy takes one field map or an array for multi-column ordering.
  • Use { direction, nulls } when NULL placement matters: orderBy: { lastSeenAt: { direction: 'desc', nulls: 'last' } }.
  • take caps the number of rows. With cursor, a negative take reads the closest preceding rows and returns them in the requested order. Without cursor, the absolute value is used as the limit.
  • skip offsets from the start.

Ordering by relations

Added in 0.3.0

orderBy also takes relation keys, nested the same way as relation filters:

  • A one relation takes a field map of the related table. It can nest to any depth, and its fields accept 'asc' / 'desc' or { direction, nulls }.
  • A many or many-to-many (.through()) relation takes { count: 'asc' | 'desc' }.
const byAuthor = await client.posts.findMany({
	orderBy: [{ author: { name: 'asc' } }, { id: 'asc' }],
});

const mostActive = await client.users.findMany({
	orderBy: { posts: { count: 'desc' } },
	take: 10,
});

Each relation key compiles to a correlated subquery in the ORDER BY of the same statement, so the row count and the number of queries stay the same.

  • Deeper paths nest: orderBy: { author: { profile: { bio: { direction: 'desc', nulls: 'last' } } } }.
  • A one relation without a related record sorts as NULL. Use nulls: 'first' | 'last' to place these values; MySQL gets this through the same IS NULL term as scalar columns. A to-many count is 0 when no related rows match.
  • Mix relation and scalar keys in the array form, and end with a unique key such as id so rows that tie on the relation value keep a stable order.
  • Relation sorts work in findMany, findFirst, findOne, findUnique, and paginate, and inside nested include / select relation args, including ones with take / skip.
  • A scalar sort on a to-many relation ({ posts: { title: 'asc' } }) or an aggregate count on a one relation is a compile error, and throws INVALID_ARGS for untyped callers. If the related table has a scalar column named count, a one relation can sort by that column normally.
  • The subqueries do not apply soft-delete filters, the same as relation loads.
  • Relations that cannot be loaded (a relation-level where, or a one relation through a junction) cannot be sorted by either; they throw the same error as in where and include.

No relation sorts with cursors

cursor() and the cursor read argument throw INVALID_ARGS when orderBy contains a relation key, because cursor tokens hold only the row's own column values. Use paginate() or skip / take for pages sorted by a relation.

Index the foreign keys you sort by

Each subquery looks up related rows by the relation's key for every sorted row. Primary keys are indexed already; add an index on the foreign key column (such as posts.authorId) used by a count sort or by a one relation whose foreign key sits on the related table. For .through() counts, index the junction columns used to link both tables. Sorting still requires the database to evaluate these expressions; one statement does not imply the same cost as scalar ordering. Check the plan with .explain().

For full offset pages with totals, use paginate. For feed-style navigation with cursors, use cursor.

Throwing when a row is required

findFirst, findOne, and findUnique return a value that is both awaitable as Row | null and exposes .throw():

// Treat not-found as null
const maybeUser = await client.users.findUnique({
	where: { email },
});

// Treat not-found as exceptional
const user = await client.users
	.findUnique({ where: { email } })
	.throw(() => new Error('User not found'));

See throwing results for the full pattern.

Choosing a read method

Use…When
findManylists, feeds, admin tables, relation loading
findFirstthe first match after ordering/filtering
findOnea single nullable row, caller handles not-found
findUniquelookup by a unique field like email or slug
countan aggregate count for a filtered set
existsa cheap "is there at least one?" check

On this page