
Sanity Content Lake Explained
The Sanity Content Lake is the hosted, real-time database where all content in a Sanity project is stored. It holds your content as JSON documents inside one or more datasets, keeps drafts and published versions side by side, records every change, and exposes everything through APIs for querying (GROQ or GraphQL), writing (mutations and actions), listening for changes and serving assets. You never install or scale it yourself. Sanity Studio writes to it as editors type, and your website reads from it through api.sanity.io or the cached apicdn.sanity.io.
Understanding the Content Lake explains a lot of Sanity behaviour that otherwise seems surprising, such as why there's no save button, why drafts don't appear on your site and why documents can hold fields that aren't in your schema. This article covers projects and datasets, the anatomy of a document, IDs and paths, drafts and versions, perspectives, the APIs for reading and writing, the API CDN, real-time features, assets, security and moving data in and out.
Where the Content Lake Fits
A Sanity project has three layers:
| Layer | What it is | Who runs it |
|---|---|---|
| Sanity Studio | The editing app | You configure it; you or Sanity host it |
| Content Lake | The datastore and APIs | Sanity |
| Your front end | Website, app or anything else | You |
The Studio and your front end are both just clients of the Content Lake. That's why you can replace either without touching the other, and why scripts, migrations and other apps can work with the same content. For the bigger picture, see what is Sanity.
Projects and Datasets
Every Sanity project has an ID, such as abc123xy, and contains one or more datasets. A dataset is a self-contained collection of documents. You can think of it as a database, with document types playing the role of tables.
Common reasons to use more than one dataset:
- Environments:
production,staginganddevelopmentwith the same schema. - Separate content: different sites or regions that share users and billing.
- Safe experiments: try a migration on a copy before running it on production.
Queries and references work within a dataset, not across them (cross-dataset references are a separate, plan-dependent feature). Dataset names must be 1 to 64 characters of lowercase letters, numbers, hyphens and underscores, and must start and end with a letter or number.
npx sanity datasets list
npx sanity datasets create staging
Public vs Private Datasets
- Public: anyone can query published documents without a token. This is the default and is fine for website content.
- Private: every read needs a valid token. Private datasets are a paid feature.
An unauthenticated query to a private dataset doesn't return an error. It returns an empty result in the expected shape, such as [], which is a common source of confusion. Also note that image and file assets are served publicly from the asset CDN even when the dataset is private, though their URLs contain a content hash and aren't guessable.
The Anatomy of a Document
A document is a JSON object. The Content Lake only requires a handful of system fields, and everything else comes from your schema:
{
"_id": "200e44f2-14a9-4c7a-a621-a4ca4d9b559c",
"_type": "author",
"_rev": "DOcXr0KgQH6faBzvMdKRNc",
"_createdAt": "2026-10-25T15:03:54Z",
"_updatedAt": "2026-11-02T20:44:48Z",
"name": "Sajjad",
"bio": [ ... ]
}
| Field | Meaning |
|---|---|
_id | Unique ID within the dataset. Can't be changed after creation |
_type | The document type, taken from the schema type's name |
_rev | Revision ID, which changes on every write. Useful for optimistic locking |
_createdAt | When the document was first created |
_updatedAt | When it was last written to, by anyone or anything |
One important point: the Content Lake doesn't know about your schema. The schema lives in the Studio and shapes the editing forms. The datastore accepts any valid JSON with an _id and _type. That means a script can write fields the Studio doesn't show, and removing a field from the schema doesn't delete the data. Content modelling is covered in content modelling best practices.
Don't Rely on _updatedAt as a Publish Date
_updatedAt changes on every write, including script edits and migrations, so it isn't a reliable "published on" date. Add your own publishedAt datetime field and fall back gracefully in queries:
*[_type == "post"] | order(coalesce(publishedAt, _createdAt) desc)
IDs and Paths
Document IDs can be up to 128 characters using a-z, A-Z, 0-9, ., _ and -. They can't start with a dash or contain two consecutive dots. The Studio generates random UUIDs, but you can set your own ID when creating documents through the API, such as siteSettings for a singleton.
Dots turn an ID into a path. Sanity uses this for internal data:
drafts.<id>holds the draft of a document.versions.<releaseId>.<id>holds a version of a document in a content release.
Unauthenticated users can only read documents at the root path, so anything with a dot in its ID is private by default. That's how drafts stay hidden from public queries. GROQ's path() function lets you target these documents when you're authenticated:
*[_id in path("drafts.**")]{ _id, _type }
Avoid putting personal or sensitive data in IDs. Every ID ever written to a dataset is retained until the dataset is deleted.
Drafts, Versions and Publishing
When an editor changes a published document in the Studio, the Content Lake stores a separate draft:
| Document | ID | Visible to public queries |
|---|---|---|
| Published | post-123 | Yes |
| Draft | drafts.post-123 | No |
| Release version | versions.rSpring.post-123 | No |
A document has at most one draft and one version per release. Publishing copies the draft's content to the published ID and removes the draft. Until then, your website keeps seeing the published version. This is why the Studio can autosave every keystroke safely: autosaving only ever touches the draft.
You can turn drafts off for the whole Studio, or make individual types write directly to the published document with liveEdit: true in the schema. That suits things like settings or comments where a review step isn't needed.
Perspectives
Because drafts and versions are separate documents, a naive query for *[_type == "post"] could return duplicates. Perspectives solve this by deciding which version of each document a query sees:
| Perspective | Returns |
|---|---|
published | Only published documents. The default from API version 2025-02-19 |
drafts | Drafts where they exist, otherwise the published version. Used for previews |
raw | Everything side by side, including drafts and versions. Requires a token |
| Release IDs | A stack of releases layered over published content |
Set the perspective on the client:
import { createClient } from '@sanity/client'
const config = {
projectId: 'abc123xy',
dataset: 'production',
apiVersion: '2026-10-01',
}
// Public site: cached, published only
export const client = createClient({ ...config, useCdn: true, perspective: 'published' })
// Preview: uncached, drafts overlaid, needs a read token
export const previewClient = createClient({
...config,
useCdn: false,
perspective: 'drafts',
token: process.env.SANITY_API_READ_TOKEN,
})
If you're on an older apiVersion (before 2025-02-19), the default is raw, which is why older code often filtered out drafts manually with !(_id in path("drafts.**")).
Reading Data: GROQ and GraphQL
The main way to read from the Content Lake is GROQ, sent to the query endpoint:
https://abc123xy.api.sanity.io/v2026-10-01/data/query/production?query=*[_type == "post"]{title}
In practice you'll use a client library rather than raw URLs:
const posts = await client.fetch(
`*[_type == "post" && author._ref == $authorId]{ title, "slug": slug.current }`,
{ authorId: 'author-sajjad' },
)
Parameters like $authorId keep queries safe and cacheable. The GROQ parameters post explains them, and what is GROQ introduces the language. A GraphQL API is also available, but you have to deploy it from your schema with sanity graphql deploy, and it reflects the schema at deploy time. For a comparison, see GROQ vs GraphQL.
Writing Data: Mutations and Transactions
Writes go through the Mutations API (and the newer Actions API, which the Studio uses for operations like publishing). With @sanity/client and a token that has write access:
import { createClient } from '@sanity/client'
const writeClient = createClient({
projectId: 'abc123xy',
dataset: 'production',
apiVersion: '2026-10-01',
useCdn: false,
token: process.env.SANITY_API_WRITE_TOKEN,
})
// Create a document with a known ID
await writeClient.createOrReplace({
_id: 'author-sajjad',
_type: 'author',
name: 'Sajjad',
})
// Patch specific fields
await writeClient
.patch('author-sajjad')
.set({ role: 'Editor' })
.setIfMissing({ tags: [] })
.append('tags', ['sanity'])
.commit()
// Several changes that succeed or fail together
await writeClient
.transaction()
.create({ _type: 'category', title: 'CMS' })
.patch('post-123', (p) => p.set({ featured: true }))
.delete('post-old')
.commit()
Mutations are atomic within a transaction, and each write produces a new _rev. Never ship a write token to the browser. Submit user-generated content through a server route or function that validates input first.
The API CDN
Every project has two read endpoints:
api.sanity.io: uncached, always fresh. Required for drafts, authenticated previews and reading right after a write.apicdn.sanity.io: cached at the edge, faster and cheaper. Selected withuseCdn: true.
The CDN caches GET requests and read-only POST queries, including authenticated ones (cached per token). It doesn't cache mutations, listeners, non-200 responses or very large responses. If the Content Lake is unreachable, the CDN can keep serving the last cached content for a while. Because CDN requests have a much larger allowance on every plan, using it for public traffic is the easiest way to stay within quotas. The Sanity pricing guide lists the numbers.
Real-Time: Listeners and the Live Content API
The Content Lake pushes changes as they happen. That's what powers live collaboration in the Studio, and you can use it too.
- Listeners:
client.listen(query)streams mutation events for matching documents. Good for scripts and internal tools. - Live Content API: designed for websites at scale. Your front end receives lightweight sync tags when content it depends on changes, then refetches only what's needed. It's available on all plans and
next-sanityintegrates it with Next.js caching.
const subscription = client
.listen(`*[_type == "post"]`)
.subscribe((event) => {
console.log(event.transition, event.documentId)
})
Assets
Images and files are stored as special asset documents (sanity.imageAsset and sanity.fileAsset) with metadata such as dimensions, a colour palette, a blur placeholder and the original filename. The binary is served from cdn.sanity.io, and images can be transformed on the fly with URL parameters for width, format, crop and quality. Your documents reference assets rather than embedding them. The Sanity images post explains the image pipeline.
History, Exports and Backups
Every transaction is recorded, so the Studio can show document history and restore earlier versions. How long that history is kept depends on your plan.
To take your content elsewhere or make a backup, export a dataset with the CLI:
npx sanity datasets export production production-backup.tar.gz
The archive is a gzipped tarball with an NDJSON file of documents plus asset binaries. Import it into another dataset with:
npx sanity datasets import production-backup.tar.gz --dataset staging
Exports count against your API request quota. These and other commands are covered in essential Sanity CLI commands.
FAQ: Sanity Content Lake
Yes, in practice. It's a hosted document store that holds JSON documents in datasets and provides query, mutation, real-time and asset APIs on top. You don't manage servers, indexes or scaling yourself.
No. The Content Lake is a hosted service run by Sanity. Sanity Studio is open source and can be self-hosted, but the datastore itself can't. You can export datasets at any time if you need a copy of your data.
Drafts are stored under the drafts path, which public queries can't read, and the default perspective for current API versions is published. To see drafts, use an authenticated client with the drafts perspective and useCdn set to false.
No. The schema lives in Sanity Studio and controls the editing forms and validation there. The Content Lake accepts any JSON document with an ID and type, so scripts can write data that doesn't match the schema.
api.sanity.io is the live, uncached API that always returns the latest data. apicdn.sanity.io is a cached edge layer that is faster and cheaper for read-heavy public traffic. Most clients switch between them with the useCdn option.
Run npx sanity datasets export with a dataset name and output file. The archive includes all documents and asset files, and you can restore it into any dataset with the datasets import command.
Conclusion
The Content Lake is the part of Sanity you never see but always use. It stores your content as schema-less JSON documents in datasets, keeps drafts and release versions under separate ID paths, uses perspectives to decide what each query sees, and offers APIs for querying, writing, listening and serving assets, with a CDN in front for public traffic.
Once you understand that the Studio and your website are both just clients of this datastore, most of Sanity's design makes sense. Pick the right perspective, use the CDN for public reads, keep write tokens on the server and export your datasets regularly, and the Content Lake will quietly do the rest.


