Notion

Complete database schema for a Notion-like workspace platform featuring hierarchical page trees, block-based content model, inline relational databases with custom properties, workspace and member management, comments and mentions, version history, external integrations, and template management.

PostgreSQL15 tablesWeb App
productivitynoteswikidatabasecollaboration
Created with ERD Studio

About this schema

There are two trees here, not one. pages.parent_page_id nests pages inside pages, and blocks.parent_block_id nests blocks inside blocks. A page is where permissions and versions attach; a block is a paragraph, a toggle, a column — the thing that moves when you drag.

The inline database is the part worth studying. databases, database_properties, database_rows and row_property_values are an entity-attribute-value model: the columns of a user's table are rows in database_properties, and each cell is a row in row_property_values. That is how a user adds a column without a migration.

Every database row is also a page. database_rows.page_id means opening a row opens a real page with blocks, comments and permissions, which is why a Notion row has a body and a spreadsheet cell does not.

Key relationships

pages → pages
parent_page_id gives the sidebar tree. A workspace-level page has it null, and moving a page is one update rather than a rewrite of its children.
blocks → blocks
A second tree inside each page. parent_block_id is what makes a toggle contain paragraphs, and position orders siblings.
database_properties ↔ database_rows
row_property_values holds one row per cell, keyed by both. Adding a property writes one row here per existing database row, which is the cost of letting users define their own columns.
database_rows → pages
Each row points at a page, so a row has a body. This is the join that separates this model from a spreadsheet.
pages → page_permissions
Access is granted per page. Inheritance down the tree is a rule the application applies, not something the schema enforces.

Design decisions

Database cells are rows, not columns

A user who adds a property expects it to work immediately, and a relational table cannot gain a column per user without a migration per user. Entity-attribute-value is the standard answer: one row per cell in row_property_values, typed by the property it points at. What it costs is everything a real column gives — the database cannot enforce the type, cannot index a property usefully without a composite index, and reading one row of a 20-property database is 20 rows that have to be pivoted in the application. It is the right trade only because the alternative is not available.

A row is a page

database_rows could have held the cell values and stopped there. Pointing at a page instead means a row opens into a document with its own blocks, comments and sharing — which is the feature people actually use the product for. The cost is that creating a row creates two rows in two tables, and deleting one has to clean up the other.

Versions are snapshots, not diffs

page_versions.snapshot stores the whole page. Restoring is a read rather than a replay, and a corrupted entry costs one version instead of every version after it. For documents this size the storage is cheaper than the complexity of a diff chain, and the chain's advantage only appears at a volume this product does not reach per page.

Permissions sit on pages, not on blocks

Granting access per block would be a finer tool and an unusable one: every read would check permissions per node of the tree. Pages are the unit people think in — they share a page, not a paragraph — so page_permissions is where access lives, and blocks inherit by being inside.

Tables in this template

usersUsers
7 cols
workspacesWorkspaces
7 cols
workspace_membersWorkspace Members
6 cols
pagesPages
11 cols
page_permissionsPage Permissions
6 cols
blocksBlocks
9 cols
databasesDatabases
8 cols
database_propertiesDatabase Properties
8 cols
database_rowsDatabase Rows
7 cols
row_property_valuesRow Property Values
6 cols
commentsComments
9 cols
page_versionsPage Versions
5 cols
favoritesFavorites
5 cols
integrationsIntegrations
8 cols
templatesTemplates
10 cols

Frequently asked

Why is the inline database not just a table?
Because users define the columns at runtime. A real table would need a schema migration every time someone adds a property. row_property_values trades type safety and indexing for that freedom, which is the only reason to accept entity-attribute-value.
Why does a database row point at a page?
So the row has a body. Opening a row gives you blocks, comments and sharing — the thing that makes it a document rather than a spreadsheet cell. It costs a second row and a cleanup on delete.
Two trees — is that not one too many?
They answer different questions. The page tree is navigation and permissions; the block tree is layout within a document. Merging them would mean a paragraph could be shared and versioned on its own, which nobody asks for and everything would pay for.
How would I query 'all rows where Status is Done'?
A join to row_property_values filtered by property_id and value, which needs a composite index on (property_id, value) and still will not use a type-aware comparison. This is the point where EAV hurts, and where products add a search index alongside.
Where do the blocks' contents live?
blocks.content, as a single column. Its shape depends on the block type, so it is opaque to the database — which is consistent with the rest of this model: structure in rows, payload in a column the application understands.