Skip to content

Items

Items are the rows in your user-defined collections. The platform exposes them through a single REST endpoint family at /items/<collection> and a corresponding set of GraphQL operations on /graphql. The same query DSL works across both. See Filters and queries for the full reference on query parameters.

This page covers the endpoint shapes, the batch and query-based variants, and how relational writes work.

MethodPathPurpose
GET/items/<collection>List items, optionally filtered/paginated/sorted.
SEARCH/items/<collection>Read items with the request body — either a query object (same shape as GET parameters) or a keys array to fetch a specific set of primary keys.
GET/items/<collection>/<id>Read a single item.
POST/items/<collection>Create one item or many (array body).
PATCH/items/<collection>/<id>Update a single item.
PATCH/items/<collection>Update many items (three body shapes).
DELETE/items/<collection>/<id>Delete a single item.
DELETE/items/<collection>Delete many items (three body shapes).

<collection> is the collection’s API name. The endpoint is reserved for user-defined collections; collection names that begin with directus_ are not accepted on /items/<collection>. System collections have their own top-level paths (/users, /files, /roles, etc.). See System collections for those.

<id> is the primary key. CairnCMS supports integer and UUID primary keys; the type matches whatever the collection was created with.

GET /items/articles?fields=id,title,author.name&filter[status][_eq]=published&sort=-published_at&limit=20

Returns items as an array, wrapped in the standard envelope:

{
"data": [
{ "id": 1, "title": "First", "author": { "name": "Alex" } },
{ "id": 2, "title": "Second", "author": { "name": "Jamie" } }
]
}

When the request includes meta=* (or specific keys like meta=total_count,filter_count), the envelope adds a meta object:

{
"data": [ ... ],
"meta": {
"total_count": 142,
"filter_count": 23
}
}

total_count is the count of items in the collection regardless of filter; filter_count is the count after the request’s filter is applied. Both ignore pagination.

For deep filters or long fields[] arrays that bump up against URL length limits, use SEARCH with the query in the body. See SEARCH method.

GET /items/articles/42?fields=id,title,body,author.name

Returns the item:

{
"data": {
"id": 42,
"title": "How something works",
"body": "...",
"author": { "name": "Alex" }
}
}

Items the caller cannot read return 403 FORBIDDEN. Items that genuinely do not exist also return 403, intentionally, distinguishing “no permission” from “no such item” would leak which IDs exist.

A single item:

POST /items/articles
Content-Type: application/json
{
"title": "A new article",
"body": "..."
}

Many items in one call — pass an array:

POST /items/articles
Content-Type: application/json
[
{ "title": "First" },
{ "title": "Second" }
]

The response is the created item (or items), re-read through the sanitized query so fields and deep apply to the returned shape (meta is a list-only concept and does not appear on item-creation responses):

POST /items/articles?fields=id,title,slug
Content-Type: application/json
{ "title": "A new article" }
{
"data": { "id": 51, "title": "A new article", "slug": "a-new-article" }
}

If the caller has permission to create but not to read the row they just created, the body is omitted from the response (the create still happens). This matters for service accounts with narrow permissions. Verify the read path explicitly if your client expects a read-back.

PATCH /items/articles/42
Content-Type: application/json
{
"title": "Updated title"
}

The body is a partial update; only the fields you include are touched. Returns the updated item, re-read through the sanitized query.

Three body shapes, picked by the structure you send:

PATCH /items/articles
Content-Type: application/json
{
"keys": [42, 43, 44],
"data": { "status": "published" }
}

Applies the same patch (data) to every item in keys. Returns the updated items.

PATCH /items/articles
Content-Type: application/json
{
"query": { "filter": { "status": { "_eq": "draft" } } },
"data": { "status": "review" }
}

Applies the patch to every item that matches the query’s filter. Use carefully because there is no preview step, and the update affects exactly the rows the filter selects, including ones added since you last read the collection. Pair with a dry_run-style read against the same filter when the change is significant.

PATCH /items/articles
Content-Type: application/json
[
{ "id": 42, "title": "First updated" },
{ "id": 43, "title": "Second updated" }
]

When the body is an array, each element must include the primary key and gets its own update. This is the right shape when you need different per-item changes in a single request.

A single item:

DELETE /items/articles/42

Returns 204 No Content on success.

Many items by primary-key array:

DELETE /items/articles
Content-Type: application/json
[42, 43, 44]

By keys + data:

DELETE /items/articles
Content-Type: application/json
{ "keys": [42, 43, 44] }

By query:

DELETE /items/articles
Content-Type: application/json
{ "query": { "filter": { "status": { "_eq": "draft" } } } }

Same warning as update-by-query: filter-based deletes affect every row the filter currently matches, with no preview. For destructive operations against meaningful data, run the equivalent GET with the same filter first to confirm the result set.

A collection marked as a singleton (the meta.singleton flag in the schema) holds at most one item. The intended shape is unkeyed:

MethodPathPurpose
GET/items/<collection>Read the singleton.
PATCH/items/<collection>Upsert the singleton — creates the row if it does not exist, updates it if it does.

POST /items/<collection> and PATCH /items/<collection>/<id> are explicitly blocked for singleton collections (they return 404 ROUTE_NOT_FOUND) because they don’t fit the one-row model. The other <id>-keyed shapes (GET /items/<collection>/<id>, DELETE /items/<collection>/<id>) are not blocked, but they are not part of the intended interface. Treat the unkeyed pair above as the contract.

The platform supports four relation types: many-to-one, one-to-many, many-to-many, and many-to-any. Each accepts the same write shapes that match how it reads.

Submit the related item under the relation field. To create a new related item inline:

{
"featured_article": { "title": "A new featured article" }
}

To assign an existing related item, pass the primary key:

{
"featured_article": 17
}

To update an existing related item inline, pass an object that includes the primary key:

{
"featured_article": { "id": 17, "title": "Updated title" }
}

To clear the relation, pass null.

These accept either of two shapes. The simpler form is an array containing primary keys (to assign existing items) or objects (to create new items inline):

{
"children": [
2,
{ "name": "A new child" },
{ "id": 7, "name": "Update existing child 7" }
]
}

Items omitted from the array are removed from the relationship. For collections where the related items have many fields and you want explicit control over the change set, use the detailed form instead:

{
"children": {
"create": [{ "name": "A new child" }],
"update": [{ "id": 7, "name": "Update existing child 7" }],
"delete": [3]
}
}

The detailed form is also clearer in version control when these payloads are committed as fixtures. The diffs read as intent rather than as full-array replacement.

Many-to-any items carry both a collection reference and an item payload. Each entry in the array specifies which collection it belongs to and the item to create or assign in that collection:

{
"sections": [
{
"collection": "headings",
"item": { "title": "Welcome" }
},
{
"collection": "paragraphs",
"item": { "body": "..." }
}
]
}

Updating and deleting many-to-any entries follow the same pattern as many-to-many. Use the detailed form (create, update, delete) for explicit control.

Every items request is filtered by the caller’s role permissions:

  • Reads return only the rows the role is permitted to read, and only the fields the role is permitted to read on those rows. Items that do not match the role’s item-level filter are simply absent from the result, not 403’d.
  • Writes apply the role’s create/update/delete permissions per row. A batch update against rows the role cannot update fails the entire request rather than partially applying.
  • Field allow-lists apply on writes too; patches that touch fields outside the allow-list return FORBIDDEN.

For the full permissions model, see Permissions.

Each user collection generates a corresponding GraphQL type and a set of operations on /graphql:

query {
articles(filter: { status: { _eq: "published" } }, limit: 20, sort: "-published_at") {
id
title
author { name }
}
articles_by_id(id: 42) {
id
title
body
}
}
mutation {
create_articles_item(data: { title: "A new article" }) {
id
title
}
update_articles_item(id: 42, data: { title: "Updated" }) {
id
title
}
delete_articles_item(id: 42) {
id
}
}

Batch equivalents (create_articles_items, update_articles_items, delete_articles_items) take and return arrays. The query options (filter, sort, limit, offset, page, search) work the same way they do in REST.

GraphQL query depth and complexity are bounded by GRAPHQL_QUERY_TOKEN_LIMIT. Very deep nested queries against highly relational schemas may bump up against the limit; see GraphQL for the bounds and how to raise them.

  • Filters and queries — the full DSL for filter, fields, sort, limit, offset, page, search, aggregate, and groupBy.
  • Files — uploading, transforming, and downloading assets, which are themselves items in the system directus_files collection.
  • GraphQL — schema introspection, the user/system endpoint split, and feature differences from REST.
  • Permissions — the operator-side model that governs what every items request can read and write.