# Collection methods (/docs/client/sdk/collections)

---
title: Collection methods
description: What each method on a collection client does, what it sends over the wire, and what comes back.
kind: guide
package: questpie
---

You have the method list. This page is what each one actually does, and the
two or three places where the shape is not what you would guess.

## Reading

Three reads. `find` gives you a page, `findOne` gives you one row or `null`,
`count` gives you a number.

```ts
const result = await client.collections.posts.find({
	where: { published: true, author: { is: { role: "editor" } } },
	with: { author: true },
	orderBy: { createdAt: "desc" },
	limit: 20,
	offset: 0,
});

const post = await client.collections.posts.findOne({ where: { id } });
const total = await client.collections.posts.count({
	where: { published: true },
});
```

`find` takes `where`, `with`, `orderBy`, `limit`, `offset`, `search`,
`groupBy`, `includeDeleted`, `locale`, `localeFallback` and `stage`. It all
goes into the query string. `findOne` takes the same list minus `limit`,
`offset` and `groupBy`. `count` takes `where` and `includeDeleted` only.

<Callout type="warn" title="`columns` does not reach a collection route">
	The type accepts it and narrows the row you get back. The route never reads
	it, so the server sends every field anyway. Project server-side instead,
	through `app.collections.<name>.find()`. A global's `get()` does read it.
</Callout>

<Callout type="info" title="`find()` hands back a page, not an array">
	It resolves to `{ docs, totalDocs, totalPages, page, limit, pagingCounter,
	hasPrevPage, hasNextPage, prevPage, nextPage }`. Your rows are on `docs`.
	`count()` is the exception and unwraps to a plain number for you.
</Callout>

Pass `groupBy` and the shape changes. You get `groups`, an array of
`{ key, value, count, docs }`, plus `groupBy` and `totalGroups`. In that mode
`limit` and `offset` paginate the groups, not the rows. Both shapes are
inferred from the options you passed, so TypeScript already knows which one
you are holding.

### The findOne shortcut

`findOne` calls `GET /:collection/:id` when `where` has exactly one key and
that key is `id`. Otherwise it runs a `find` at `limit: 1` and hands you
`docs[0]` or `null`.

<Callout type="warn" title="The shortcut puts your `id` straight in the URL">
	`where: { id }` is fine. `where: { id: { in: [a, b] } }` is not, because the
	single-key check still passes and the operator object lands in the path. Add
	a second condition, or use `find`.
</Callout>

## Writing one row

```ts
const created = await client.collections.posts.create({
	title: "Hello",
	slug: "hello",
	author: authorId, // belongsTo takes a raw id
	tags: { create: [{ name: "intro" }] },
});

const updated = await client.collections.posts.updateById({
	id: created.id,
	data: { published: true },
});

await client.collections.posts.deleteById({ id: created.id });
```

`create` takes the data first and the locale options second. `updateById` takes
`{ id, data }`. Nested relation writes accept `connect`, `create`,
`connectOrCreate` and `set`, and a belongsTo field also takes the raw id. See
[Relations](/docs/schema/relations).

`deleteById` resolves to `{ success, data }`, where `data` is the row as it
stood. It soft-deletes when the collection has soft delete on. Then
`restoreById({ id })` brings the row back and `purgeById({ id })` removes it
for good. Purge has its own access rule, so delete rights do not grant it, and
it rejects a row that is still active.

## Writing many rows

```ts
// One `data`, applied to every row that matches. Returns the rows it wrote.
const written = await client.collections.posts.updateMany({
	where: { published: false },
	data: { published: true },
});

// Different data per row, all in one transaction.
await client.collections.posts.updateBatch({
	updates: [
		{ id: a, data: { title: "A" } },
		{ id: b, data: { title: "B" } },
	],
});

const { count } = await client.collections.posts.deleteMany({
	where: { published: false },
});
```

<Callout type="warn" title="`updateMany` and `deleteMany` are claim-checked">
	Both lock the matching rows and re-run `where` inside the transaction.
	`updateMany` returns only the rows it actually wrote, so an empty array means
	nothing matched at write time. It is not an error. `deleteMany`'s `count` is
	the rows that still matched at delete time.
</Callout>

`deleteMany` goes to `POST /:collection/delete-many` rather than a `DELETE`,
because it carries a body.

## Versions and workflow

These need `versioning` on the collection, and `transitionStage` needs a
workflow too. See [Options](/docs/schema/collections/options).

```ts
const versions = await client.collections.posts.findVersions({ id, limit: 10 });
// each row is the row itself plus versionId, versionNumber, sourceRevision,
// versionOperation, versionUserId and versionCreatedAt

await client.collections.posts.revertToVersion({ id, version: 3 });
await client.collections.posts.transitionStage({ id, stage: "published" });
```

Pass either `version` or `versionId` to revert. A `Date` in `scheduledAt` is
sent as an ISO string. Leave it out and the transition happens now.

Turn on `optimisticConcurrency` and rows carry a `revision`. Every by-id
mutation then requires `expectedRevision`. `updateMany` and `deleteMany` take
an `expectedRevisions` array. `updateBatch` puts one `expectedRevision` on each
entry instead. A stale value comes back as a conflict. See
[Optimistic concurrency](/docs/schema/collections/optimistic-concurrency).

## Introspection

```ts
const meta = await client.collections.posts.meta();
const schema = await client.collections.posts.schema();
```

`meta()` is the light one: field names, the title field, timestamps, soft
delete, relation names, and which fields are localized or virtual. `schema()`
is the full picture. Its relations carry their type and target collection, not
just a name. It also adds a validation JSON Schema for insert and update, and
access **evaluated for the current session**. The admin panel builds its forms
and tables from `schema()`.

## Live reads

`live()` and `liveIter()` take the same query and push you `find()`-shaped
snapshots as rows change. They carry `where`, `with`, `limit`, `offset`,
`orderBy` and `locale` only. Anything else you pass is dropped rather than
applied. See [Realtime](/docs/client/realtime).

## Next

**[Uploads](/docs/client/sdk/uploads)** covers `upload` and `uploadMany`, which
take a different code path from everything here.
