Note: This document describes an earlier design exploration for a pure-Postgres approach with per-field columns. The final decided architecture is in
04-decisions.md, which uses a singlevaluesJSONB column for user fields and delegates all filtering to OpenSearch (see02-postgres-plus-opensearch.md). This document is retained as reference for the pure-PG approach and its trade-offs.
Replace DynamoDB (+ optional OpenSearch) with a single Postgres database for all CMS storage operations: CRUD, filtering, sorting, full-text search, and aggregations.
Target scale: Tens of millions of records per model.
Each CMS content model gets its own Postgres table. When a user creates a model called Article, the system creates a table like webiny_cms_article.
Every model table has two categories of columns:
System columns (fixed, same for every model):
CREATE TABLE webiny_cms_{modelId} (
-- Identity
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
-- Versioning
version INTEGER NOT NULL,
is_latest BOOLEAN NOT NULL DEFAULT false,
is_published BOOLEAN NOT NULL DEFAULT false,
locked BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'draft',
-- Tenant / Locale
tenant TEXT NOT NULL,
locale TEXT NOT NULL,
-- Location (folder)
location_folder_id TEXT,
-- Timestamps
created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
modified_on TIMESTAMPTZ,
saved_on TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_on TIMESTAMPTZ,
first_published_on TIMESTAMPTZ,
last_published_on TIMESTAMPTZ,
-- Identity references (stored as JSONB — { id, displayName, type })
created_by JSONB NOT NULL,
modified_by JSONB,
saved_by JSONB NOT NULL,
deleted_by JSONB,
-- Soft delete
wby_deleted BOOLEAN NOT NULL DEFAULT false,
-- Revision description
revision_description TEXT
);User-defined field columns (dynamic, based on model definition):
Top-level scalar fields become real typed columns:
| CMS Field Type | Postgres Column Type |
|---|---|
| text | TEXT |
| long-text | TEXT |
| rich-text | JSONB |
| number | DOUBLE PRECISION |
| boolean | BOOLEAN |
| datetime | TIMESTAMPTZ |
| file | TEXT (file ID) |
| ref | TEXT (entry ID) |
| object | JSONB |
| dynamic-zone | JSONB |
Multi-value (list) fields use Postgres arrays (TEXT[], DOUBLE PRECISION[], etc.) for scalars or JSONB for complex types.
Model "Article" with fields: title (text), price (number), published (boolean), metadata (object), content (dynamic-zone):
CREATE TABLE webiny_cms_article (
-- system columns (as above)
...
-- user-defined columns
title TEXT,
price DOUBLE PRECISION,
published BOOLEAN,
metadata JSONB, -- object field -> JSONB
content JSONB -- dynamic-zone -> JSONB
);CREATE TABLEwith system columns + user-defined field columns.- Create system indexes (see Indexing section).
- Create field-level indexes for all user-defined fields.
- Create full-text search vector column + index.
ALTER TABLE webiny_cms_{modelId} ADD COLUMN {fieldId} {type};ADD COLUMNwith no default = instant (Postgres 11+, metadata-only change).ADD COLUMNwithDEFAULTvalue = instant (Postgres 11+).- Then create indexes for the new field.
ALTER TABLE webiny_cms_{modelId} DROP COLUMN {fieldId};DROP COLUMN= instant (marks column invisible, space reclaimed on next VACUUM/rewrite).- Drop associated indexes.
This is the hard case:
ALTER TABLE webiny_cms_{modelId} ALTER COLUMN {fieldId} TYPE {newType};- At 30M rows: full table rewrite + ACCESS EXCLUSIVE lock. Table is locked for writes during rewrite.
- Mitigation strategies:
- Add new column, backfill in batches, swap (application-level migration).
- Use
pg_repackor similar for online migration. - Discourage type changes on large models (UI warning).
DROP TABLE webiny_cms_{modelId};Instant.
-- Primary lookups
CREATE INDEX idx_{modelId}_entry_latest
ON webiny_cms_{modelId} (tenant, locale, entry_id, is_latest)
WHERE is_latest = true;
CREATE INDEX idx_{modelId}_entry_published
ON webiny_cms_{modelId} (tenant, locale, entry_id, is_published)
WHERE is_published = true;
-- Listing (most common query pattern)
CREATE INDEX idx_{modelId}_list_latest
ON webiny_cms_{modelId} (tenant, locale, created_on DESC)
WHERE is_latest = true AND wby_deleted = false;
CREATE INDEX idx_{modelId}_list_published
ON webiny_cms_{modelId} (tenant, locale, created_on DESC)
WHERE is_published = true AND wby_deleted = false;
-- Revisions for an entry
CREATE INDEX idx_{modelId}_revisions
ON webiny_cms_{modelId} (tenant, locale, entry_id, version);
-- Trash bin
CREATE INDEX idx_{modelId}_deleted
ON webiny_cms_{modelId} (tenant, locale, deleted_on DESC)
WHERE wby_deleted = true;
-- Folder listing
CREATE INDEX idx_{modelId}_folder
ON webiny_cms_{modelId} (tenant, locale, location_folder_id)
WHERE is_latest = true AND wby_deleted = false;For top-level scalar fields (text, number, boolean, datetime, ref, file):
-- B-tree index per field (supports =, <, >, <=, >=, BETWEEN, ORDER BY)
CREATE INDEX CONCURRENTLY idx_{modelId}_{fieldId}
ON webiny_cms_{modelId} ({fieldId});For nested paths inside JSONB columns (object fields, dynamic zones):
-- Expression index for a specific nested path
CREATE INDEX CONCURRENTLY idx_{modelId}_{path_slug}
ON webiny_cms_{modelId} ((metadata->>'seoScore')::integer);
-- GIN index on the whole JSONB column (for @> containment / equality queries)
CREATE INDEX CONCURRENTLY idx_{modelId}_{fieldId}_gin
ON webiny_cms_{modelId} USING gin ({fieldId} jsonb_path_ops);When a model is created or updated, the system must:
- Walk the full field tree (including object children, dynamic-zone templates).
- For every field at every nesting level, determine the JSONB path.
- Create an expression index for each filterable/sortable nested path.
- Use
CREATE INDEX CONCURRENTLY(no table lock, safe for production). - When fields are removed, drop the corresponding indexes.
Concern: Index count. A model with 10 top-level fields + 3 objects with 5 nested fields each + 2 dynamic zones with 3 templates and 4 fields each = 10 + 15 + 24 = 49 field indexes + 7 system indexes = ~56 total indexes per model table. At 30M rows:
- Each B-tree index size depends on field cardinality, data distribution, and NULL density.
- 56 indexes = significant storage overhead (estimate 15-25 GB per model, varies widely).
- Every INSERT/UPDATE touches all indexes — substantial write amplification.
INSERTlatency increases significantly compared to a table with 3-5 indexes.
Direct column access. Standard SQL:
WHERE title = 'Hello'
WHERE price > 100 AND price < 500
WHERE published = true
WHERE created_on > '2024-01-01'All use B-tree indexes. Fast at any scale.
-- Equality (uses GIN index)
WHERE metadata @> '{"seo": {"score": 80}}'
-- Range (uses expression index if one exists)
WHERE (metadata->'seo'->>'score')::integer > 80
-- Text contains inside nested (uses expression index)
WHERE metadata->'seo'->>'title' ILIKE '%postgres%'
-- Dynamic zone field
WHERE (content->0->>'heading') = 'Welcome'WHERE (title = 'Hello' OR price > 100)
AND published = true
AND (metadata->'seo'->>'score')::integer > 50Postgres query planner handles AND/OR with index scans + bitmap merges.
| CMS Operator | SQL |
|---|---|
| eq | = value |
| not_eq | != value |
| in | = ANY(array) |
| not_in | != ALL(array) |
| contains | ILIKE '%value%' |
| not_contains | NOT ILIKE '%value%' |
| startsWith | LIKE 'value%' |
| gt | > value |
| gte | >= value |
| lt | < value |
| lte | <= value |
| between | BETWEEN a AND b |
ORDER BY title ASC
ORDER BY price DESC, created_on ASCUses B-tree indexes. Multi-field sort works if a composite index exists or via index scan + sort.
ORDER BY (metadata->'seo'->>'score')::integer DESCRequires expression index on that path. Without index = sort in memory (fine for small result sets after WHERE filtering, catastrophic for full table scans).
Keyset (cursor-based) pagination — required for 30M rows. Offset pagination degrades linearly.
-- First page
SELECT * FROM webiny_cms_article
WHERE tenant = 't1' AND locale = 'en-US' AND is_latest = true AND wby_deleted = false
ORDER BY created_on DESC, id DESC
LIMIT 50;
-- Next page (cursor = last row's created_on + id)
SELECT * FROM webiny_cms_article
WHERE tenant = 't1' AND locale = 'en-US' AND is_latest = true AND wby_deleted = false
AND (created_on, id) < ('2024-06-15T10:00:00Z', 'abc123')
ORDER BY created_on DESC, id DESC
LIMIT 50;Keyset pagination is O(1) regardless of page depth. Requires the sort column(s) + tie-breaker (id) to be indexed together.
-- Add a search vector column
ALTER TABLE webiny_cms_article
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(long_description, '')), 'B')
) STORED;
-- GIN index on it
CREATE INDEX idx_article_search ON webiny_cms_article USING gin (search_vector);SELECT *, ts_rank(search_vector, query) AS rank
FROM webiny_cms_article, plainto_tsquery('english', 'postgres performance') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 50;JSONB extraction operators (->>, ->) are immutable, so they can be used in generated columns:
ALTER TABLE webiny_cms_article
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(metadata->>'description', '')), 'B')
) STORED;For deeply nested or variable-structure JSONB (dynamic zones), a trigger is needed since the paths are not statically known:
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector := /* dynamically built tsvector from all text fields */;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;- Trigger overhead: At 30M rows, maintaining tsvector on every INSERT/UPDATE adds measurable CPU cost. Benchmark needed for specific field counts.
- No fuzzy/typo-tolerant search. Need
pg_trgmextension for that. - No relevance tuning comparable to OpenSearch (no field boosting, analyzers, synonyms).
- Rebuilding search vector on model change: When fields are added/removed, the generated column or trigger must be regenerated and the search vector backfilled across all rows.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Trigram index for ILIKE queries
CREATE INDEX idx_article_title_trgm
ON webiny_cms_article USING gin (title gin_trgm_ops);
-- Query
SELECT * FROM webiny_cms_article
WHERE title % 'postges' -- fuzzy match with similarity threshold
ORDER BY similarity(title, 'postges') DESC;Table names include tenant context via table prefix or Postgres schemas:
Option A: Table prefix
webiny_t1_cms_article
webiny_t2_cms_article
Option B: Postgres schemas
CREATE SCHEMA tenant_t1;
CREATE TABLE tenant_t1.webiny_cms_article (...);Schemas provide better isolation and simpler SET search_path switching.
- 30-50 indexes per model table at 30M rows = significant index storage per model. Actual size depends on field cardinality, data distribution, and whether NULLs are indexed.
- Write amplification: every INSERT/UPDATE maintains all indexes.
- Mitigation: only index fields marked "filterable"/"sortable" in model definition. Rich-text and large text fields should not get B-tree indexes.
- NULL handling: Postgres includes NULLs in B-tree indexes by default. For sparse optional fields (e.g., dynamic zone fields that most entries don't have), use partial indexes:
CREATE INDEX ... WHERE field IS NOT NULLto avoid bloated indexes. - Runtime index strategy needed: What happens when a user filters on a field that was not marked "filterable"? Options: (a) reject the query, (b) fall back to sequential scan with warning, (c) auto-create index on first filter use (dangerous at 30M rows —
CREATE INDEX CONCURRENTLYtakes minutes).
- Full table rewrite + exclusive lock.
- At 30M rows: minutes to hours depending on hardware.
- Mitigation: warn user in UI, use background migration strategy.
- Range queries on nested paths need per-path expression indexes.
- Without index: sequential scan at 30M rows = seconds to minutes.
- GIN only helps with equality/containment, not range or sort.
- Postgres tsvector is functional but basic compared to OpenSearch.
- No built-in synonyms, stemming customization is limited, no field-level boosting.
- No "did you mean?" / fuzzy matching without pg_trgm.
- Dynamic zone content is inherently variable-structure JSONB.
- Querying "find entries where the 3rd content block's heading contains X" requires knowing the array position or scanning all elements.
jsonb_path_query(SQL/JSON Path, Postgres 12+) helps but is not indexable for array element searches.- Possible workaround: JSONB array containment with GIN, but limited to equality.
- Note: Dynamic zone filtering is not yet exposed in Webiny's GraphQL API. Data is indexed in OpenSearch but enabling filtering requires GraphQL schema changes + OpenSearch query builder implementation for dynamic zone field types. This is a pure Postgres limitation analysis — in the Postgres + OpenSearch architecture (doc 02), the storage layer already supports dynamic zone indexing.
- With 30-50 indexes per table and heavy UPDATEs from the versioning pipeline (draft -> published -> new revision), autovacuum will struggle to keep bloat down.
- Consider: tuning
autovacuum_vacuum_scale_factorlower for CMS tables, adjustingFILLFACTORfor tables with frequent updates, periodicREINDEX CONCURRENTLYfor index bloat reclamation. - Dead tuple accumulation from UPDATE-heavy workloads degrades index scan performance over time.
- Complex queries on large tables can hold connections for seconds.
- Need PgBouncer or built-in connection pooling.
- Estimate: minimum pool size should match expected concurrent users performing list/filter operations.
| Aspect | Capability | Concern Level |
|---|---|---|
| CRUD | Native SQL. Fast. | None |
| Top-level field filtering | B-tree indexes. Fast at any scale. | None |
| Nested field equality filter | GIN on JSONB. Works. | Low |
| Nested field range filter | Needs per-path expression index. | Medium |
| Nested field sorting | Needs per-path expression index. | Medium |
| Full-text search | tsvector + GIN. Functional. | Medium |
| Fuzzy search | pg_trgm. Works but separate index. | Medium |
| Dynamic zone querying | JSONB path queries. Limited indexing. | High |
| Index management | Must build lifecycle manager. | High |
| Write performance at scale | Many indexes = write amplification. | High |
| Schema evolution (add/drop field) | Instant. | None |
| Schema evolution (change type) | Table rewrite + lock. | High |