Skip to main content

Document Service API: publicationFilter

The status parameter selects which row slice to read for each document: draft rows have publishedAt: null, and published rows have a non-null publishedAt.

The optional publicationFilter parameter selects a derived publication cohort first: a set of (documentId, locale) pairs (or documentId only when Internationalization (i18n) is disabled) defined by how draft and published rows relate. Strapi then returns the row that matches both the cohort and the resolved status.

Prerequisites

The Draft & Publish feature must be enabled on the content-type. If Draft & Publish is disabled, publicationFilter has no effect.

publicationFilter is supported on findOne(), findFirst(), findMany(), and count(). It can be combined with filters, populate, and other query parameters. Invalid values raise a validation error.

Default status when publicationFilter is used

publicationFilter is applied after status is resolved (explicitly or by default). Defaults differ by API surface:

API surfaceDefault status when omitted
Document Service API (direct)'draft'
REST API'published'
GraphQL APIPUBLISHED

The following example compares Document Service and REST behavior when only publicationFilter: 'modified' is passed:

// Document Service API → draft rows in the modified cohort
await strapi.documents('api::restaurant.restaurant').findMany({
publicationFilter: 'modified',
});

// REST: GET /api/restaurants?publicationFilter=modified → published rows in the modified cohort

Pair-scoped modes such as never-published only include draft rows in the cohort. With REST or GraphQL defaults (status=published), those queries return an empty result set unless you pass status=draft / status: DRAFT.

Available values

REST and the Document Service API use kebab-case strings. GraphQL exposes the same cohorts through the PublicationFilter enum.

ValueScopeCohort definition (which (documentId, locale) pairs match)
never-publishedPairNo row with non-null publishedAt exists for the same (documentId, locale)
has-published-versionPairBoth a draft row and a published row exist for the same (documentId, locale)
modifiedPairBoth slices exist and draft.updatedAt > published.updatedAt
unmodifiedPairBoth slices exist and draft.updatedAt <= published.updatedAt
never-published-documentDocumentNo row with non-null publishedAt exists for the same documentId in any locale
has-published-version-documentDocumentAt least one published row exists for the same documentId in any locale
published-without-draftPairA published row exists for the pair and no draft row exists for the same (documentId, locale)
published-with-draftPairA published row exists for the pair and a draft row also exists for the same (documentId, locale)

For content-types without i18n, read (documentId, locale) as documentId only.

Semantics notes

  • has-published-version excludes orphan published rows: If only a published row exists for a pair (no draft sibling), that pair is not in the has-published-version cohort. Orphan published rows can appear under published-without-draft when querying with status: 'published'.
  • modified / unmodified require both slices: Pairs with only a draft or only a published row are not included.
  • modifiedunmodified = has-published-version (for the same status): The two modes partition pairs that have both slices.
  • Document-scoped modes: Existence checks use documentId only. A document with draft EN + published NL qualifies for has-published-version-document even though EN is never published at the pair level.
  • Published-slice diagnostics (published-without-draft, published-with-draft): Only select published rows. They return no rows when status is 'draft'.

Content Manager list filters

The Content Manager Status filter (__status) is translated server-side. Only the Draft (never published) option uses publicationFilter:

Content Manager filterDocument Service query equivalent
Draft (never published)status: 'draft', publicationFilter: 'never-published-document'
Published (all)status: 'published' (no publicationFilter)
Published (modified)Internal publicationStatusFilter (not a public REST/GraphQL parameter); similar intent to status: 'published' + publicationFilter: 'modified' but implemented separately in the Content Manager API
Published (unmodified)Internal publicationStatusFilter (not a public REST/GraphQL parameter)

The Draft (never published) filter is document-scoped (never-published-document), not pair-scoped never-published.

Combine status and publicationFilter

statuspublicationFilterRows returned
draftnever-publishedDraft rows for pairs never published in that locale
publishednever-publishedEmpty
drafthas-published-versionDraft rows for pairs that also have a published version
publishedhas-published-versionPublished rows for pairs that also have a draft version (excludes orphan published-only pairs)
draftmodifiedDraft rows newer than their published peer
publishedmodifiedPublished rows whose draft peer is newer
draftunmodifiedDraft rows not newer than their published peer
publishedunmodifiedPublished rows whose draft peer is not newer
draftnever-published-documentDraft rows whose document has no published row in any locale
publishednever-published-documentEmpty
drafthas-published-version-documentDraft rows whose document has at least one published row (any locale)
publishedhas-published-version-documentPublished rows whose document has at least one draft row (any locale)
publishedpublished-without-draftPublished rows with no draft sibling for the same pair
draftpublished-without-draftEmpty
publishedpublished-with-draftPublished rows that have a draft sibling for the same pair
draftpublished-with-draftEmpty
Note

Valid but empty combinations do not return validation errors.

Query never-published drafts

Return draft rows for (documentId, locale) pairs with no published version for that locale:

const documents = await strapi.documents('api::restaurant.restaurant').findMany({
status: 'draft',
publicationFilter: 'never-published',
});

Query has-published-version drafts

Return draft rows where a published row also exists for the same (documentId, locale). Orphan published-only pairs are excluded:

const documents = await strapi.documents('api::restaurant.restaurant').findMany({
status: 'draft',
publicationFilter: 'has-published-version',
});

Query modified or unmodified documents

Compare updatedAt on the draft and published rows for the same pair:

// Draft side of modified pairs
await strapi.documents('api::restaurant.restaurant').findMany({
status: 'draft',
publicationFilter: 'modified',
});

// Published side of unmodified pairs
await strapi.documents('api::restaurant.restaurant').findMany({
status: 'published',
publicationFilter: 'unmodified',
});

Query document-scoped cohorts

Return draft rows for documents that have never been published in any locale:

await strapi.documents('api::restaurant.restaurant').findMany({
status: 'draft',
publicationFilter: 'never-published-document',
});

A multi-locale document with one published locale is excluded entirely, including its draft-only locales.

Return draft rows for documents that have at least one published row in any locale:

await strapi.documents('api::restaurant.restaurant').findMany({
status: 'draft',
publicationFilter: 'has-published-version-document',
});

This is broader than pair-scoped has-published-version.

Query published rows without or with a draft peer

published-without-draft and published-with-draft partition published rows per (documentId, locale) (excluding pairs with no published row):

// Orphan published rows (published row, no draft sibling for the same pair)
await strapi.documents('api::restaurant.restaurant').findMany({
status: 'published',
publicationFilter: 'published-without-draft',
});

// Published rows that still have a draft sibling
await strapi.documents('api::restaurant.restaurant').findMany({
status: 'published',
publicationFilter: 'published-with-draft',
});

Use with findOne() and findFirst()

publicationFilter applies the same cohort rules. If the requested document (and locale, when applicable) is not in the cohort, findOne() and findFirst() return null even when the documentId exists:

await strapi.documents('api::restaurant.restaurant').findOne({
documentId: 'a1b2c3d4e5f6g7h8i9j0klm',
status: 'draft',
publicationFilter: 'never-published',
});

Combine with filters and populate

publicationFilter is merged with other query filters (logical AND). When populating relations, nested queries on draft & publish content-types inherit the same cohort logic so populated results stay consistent with the parent query.

Count documents in a cohort

Count draft rows in the never-published cohort:

const neverPublishedCount = await strapi
.documents('api::restaurant.restaurant')
.count({
status: 'draft',
publicationFilter: 'never-published',
});

Without publicationFilter, count({ status: 'draft' }) still counts every draft row, including drafts whose document already has a published version. Use publicationFilter: 'never-published' or 'never-published-document' to count only never-published cohorts (see status documentation).

Validation

Unknown publicationFilter values are rejected:

  • Document Service API: throws a validation error.
  • REST API: returns HTTP 400.
  • GraphQL: invalid enum values fail at query validation.

Deprecated hasPublishedVersion parameter

The boolean hasPublishedVersion parameter is deprecated in favor of publicationFilter. Strapi still accepts it on the REST API, GraphQL, and Document Service API and maps it to document-scoped modes:

hasPublishedVersionMaps to
false (or string 'false')never-published-document
true (or string 'true')has-published-version-document

If both publicationFilter and hasPublishedVersion are passed, publicationFilter takes precedence.

REST and GraphQL examples: REST API: publicationFilter, GraphQL API: publicationFilter.

Why not filter on publishedAt alone?

A single row's publishedAt only describes that row. Cohorts such as never-published, has-published-version, and modified require comparing or correlating two rows for the same (documentId, locale). publicationFilter encodes those rules in one server-side query instead of multiple client round-trips.