# Data Management

> This skill should be used when the user asks to "enable soft delete in Payload", "trash and restore documents", "recover a deleted record", "set up query presets", "save and share list filters", "organize documents in folders", "group documents by a field", "use admin.groupBy", or wants the recent v3 data-management features (Trash, Query Presets, Folders, Group By).

- Skill: `agents-store/data-management` (Agent Skill)
- Install (CLI): `npx skillmds@latest add agents-store/data-management`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agents-store/data-management/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: agents-store (https://skillmd.com/u/agents-store)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agents-store/data-management

---


# PayloadCMS — Trash, Query Presets, Folders & Group By

Four data-management features shipped during Payload v3 (2025). Each is enabled by a single flag on a collection and, where it needs storage, auto-creates a managed collection. None require a custom UI.

## Feature Map

| Feature | Collection flag | Auto-created collection | Admin effect |
| --- | --- | --- | --- |
| Trash (soft delete) | `trash: true` | — (adds a `deletedAt` field) | Trash view + Restore / Permanently Delete actions |
| Query Presets | `enableQueryPresets: true` | `payload-query-presets` | Save/load/share named list-view configs |
| Folders | `folders: true` | `payload-folders` | "Browse by folder" view; folder field on docs |
| Group By | `admin.groupBy: true` | — | Grouped list view by a shared field |

> Each of these alters the database schema (a new column, table, or relationship). After enabling any of them in Postgres/SQLite, generate and run a migration — see the `cli-recipes` skill.

## Trash (Soft Delete)

Set `trash: true` to make deletes reversible. Payload injects a `deletedAt` timestamp field; "deleting" a document just stamps `deletedAt` instead of removing the row.

```ts
// src/collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  trash: true, // defaults to false
  fields: [
    { name: 'title', type: 'text', required: true },
  ],
}
```

**Admin:** A new `/collections/:slug/trash` route lists soft-deleted docs with bulk **Restore**, **Delete** (permanent), and **Empty Trash** actions. The list view's delete modal gains a checkbox to bypass trash and delete permanently. A trashed document's edit view is read-only, offering **Restore** and **Permanently Delete**.

**Local API** — control which set you query with the `trash` param:

```ts
// All docs including trashed
await payload.find({ collection: 'posts', trash: true })

// Only trashed
await payload.find({
  collection: 'posts',
  trash: true,
  where: { deletedAt: { exists: true } },
})

// Only non-trashed (default behavior)
await payload.find({ collection: 'posts', trash: false })
```

Restore by clearing the timestamp; permanently delete by passing `trash: true` to `delete`:

```ts
// Restore — clear deletedAt
await payload.update({ collection: 'posts', id, data: { deletedAt: null }, req })

// Permanent delete (skip trash)
await payload.delete({ collection: 'posts', id, trash: true, req })
```

**REST** mirrors the param: `GET /api/posts?trash=true`, `GET /api/posts?trash=true&where[deletedAt][exists]=true`, `GET /api/posts?trash=false`.

**Access control:** all three operations (soft delete, restore, permanent delete) run through the collection's `delete` access function. Differentiate by inspecting the `data` argument — on a soft-delete `data.deletedAt` is set; on a permanent delete `data` is `undefined`:

```ts
// src/collections/Posts.ts
access: {
  delete: ({ req: { user }, data }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true
    if (data?.deletedAt) return true   // allow trashing
    return false                       // deny permanent delete to non-admins
  },
}
```

When versions are enabled, a trashed document's versions cannot be restored until the document itself is restored, but version history stays visible in its edit view.

## Query Presets

Set `enableQueryPresets: true` so users can save a List View's **filters**, **columns**, and **sort order** as a named preset and reload it later. Presets are stored in the auto-created `payload-query-presets` collection, so users can define as many as they like.

```ts
// src/collections/Orders.ts
export const Orders: CollectionConfig = {
  slug: 'orders',
  enableQueryPresets: true,
  fields: [/* ... */],
}
```

**Sharing & access** is configured under the root `queryPresets` key in `payload.config.ts`:

```ts
// src/payload.config.ts
import { buildConfig } from 'payload'

export default buildConfig({
  collections: [Orders /* ... */],
  queryPresets: {
    // Static, config-level rules — NOT user-editable
    access: {
      read: ({ req: { user } }) => Boolean(user),
      update: ({ req: { user } }) => user?.roles?.includes('admin'),
    },
    // Constraint options users pick from in the UI when sharing a preset
    constraints: {
      read: [
        { label: 'Specific Roles', value: 'specificRoles' /* + custom fields/where */ },
      ],
    },
    // Dynamically filter which constraint options a user sees
    filterConstraints: ({ req, options }) => options,
    // Relabel the presets collection in the admin UI
    labels: { singular: 'Saved View', plural: 'Saved Views' },
  },
})
```

Out of the box each preset can be shared as **Only Me**, **Everyone**, or **Specific Users**; `queryPresets.constraints` extends this with role-based or custom sharing rules, while `queryPresets.access` defines static rules the user cannot override. Read presets via the Local API like any collection:

```ts
const { docs } = await payload.find({ collection: 'payload-query-presets' })
```

## Folders

Set `folders: true` to let editors organize documents into a folder tree. Payload adds a hidden relationship field on the document pointing to its parent folder (or `null`), and manages the tree in the auto-created `payload-folders` collection — which itself has a self-referencing folder field, allowing nested folders.

```ts
// src/collections/Media.ts
export const Media: CollectionConfig = {
  slug: 'media',
  folders: true, // defaults to false
  upload: true,
  fields: [{ name: 'alt', type: 'text' }],
}
```

**Admin:** enabling folders surfaces a **Browse by Folder** view (the global `browseByFolder` option, on by default). Editors drag documents between folders there.

Folders are configured globally under the `folders` key in `payload.config.ts`:

```ts
// src/payload.config.ts
export default buildConfig({
  folders: {
    browseByFolder: true,   // default true — show the folder browser
    fieldName: 'folder',    // default 'folder' — name of the doc→folder field
    slug: 'payload-folders',// default — the folder collection slug
    debug: false,           // default — reveal hidden folder fields
    collectionOverrides: [  // async functions that modify the auto-created folder collection
      async ({ collection }) => collection,
    ],
  },
})
```

Per-collection, `folders` may also be an object instead of `true` — e.g. `folders: { browseByFolder: false }` to keep the collection out of the global folder browser.

Because folders are built on relationship fields, you can scope a query to a folder with a normal `where` on the folder field (default name `folder`):

```ts
const { docs } = await payload.find({
  collection: 'media',
  where: { folder: { equals: folderId } },
})
```

Folders are marked **beta** in the docs and may change.

## Group By

Set `admin.groupBy: true` to add a grouped List View — results are bucketed by a shared field (e.g. counting items per category, grouping entries by status). The feature is marked **beta**.

```ts
// src/collections/Tickets.ts
export const Tickets: CollectionConfig = {
  slug: 'tickets',
  admin: {
    groupBy: true, // beta — enable grouping in the list view
    defaultColumns: ['title', 'status', 'priority'],
  },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'status', type: 'select', options: ['open', 'pending', 'closed'] },
    { name: 'priority', type: 'select', options: ['low', 'high'] },
  ],
}
```

**In the admin**, the list view gains a control to pick which field to group on and renders results in collapsible groups with per-group counts.

**Note on the option shape:** the boolean form `admin.groupBy: true` is confirmed in the docs and release notes. The collections config reference also describes it as "grouping by a field," implying a field name may be accepted; richer object forms (e.g. `{ default, options }`) are an open community proposal, **not** confirmed in stable docs. Use the boolean unless you verify the string/object form against your installed version. For aggregate reads in code, prefer the Local API with explicit grouping/counting rather than relying on the list-view UI.

<example>
**User**: "Make Articles soft-deletable, but only admins can permanently delete."

```ts
// src/collections/Articles.ts
export const Articles: CollectionConfig = {
  slug: 'articles',
  trash: true,
  access: {
    delete: ({ req: { user }, data }) => {
      if (!user) return false
      if (user.roles?.includes('admin')) return true
      return Boolean(data?.deletedAt) // non-admins may trash, not purge
    },
  },
  fields: [{ name: 'title', type: 'text', required: true }],
}
```
Editors trash via the normal delete; recover from `/collections/articles/trash`. Query live docs with `trash: false`, recoverable ones with `trash: true` + `where: { deletedAt: { exists: true } }`.
</example>

<example>
**User**: "Let the support team save and share their favorite ticket filters, and organize tickets into folders."

```ts
// src/collections/Tickets.ts
export const Tickets: CollectionConfig = {
  slug: 'tickets',
  enableQueryPresets: true, // save/share list-view filters, columns, sort
  folders: true,            // organize tickets into folders
  fields: [/* ... */],
}
```
Presets land in `payload-query-presets`; sharing options (Only Me / Everyone / Specific Users) come from `queryPresets.constraints`. Folder assignments live in `payload-folders`; query a folder with `where: { folder: { equals: folderId } }`.
</example>

## What this skill does NOT cover

- `collections` — the full `CollectionConfig` surface (auth, upload, versions, drafts, indexes, custom endpoints).
- `queries` — the complete `where` operator set, `depth`, `select`, pagination, REST/GraphQL query syntax used by all four features here.
- `access-control` — how `access.delete` / `overrideAccess` / `req.user` evaluate (Trash leans on `delete`; presets define their own `queryPresets.access`).
- `cli-recipes` — generating and running the migration each of these flags requires when they add a column, table, or relationship in Postgres/SQLite.

