When JSONB is the right call
1. Truly dynamic attributes. A product catalog where different categories have different fields. A t-shirt has size and color. A laptop has RAM and storage. Normalizing this into an EAV table is worse than JSONB in every way.
2. External API responses. Store the raw webhook payload alongside your parsed version. When the provider changes their schema (and they will), you have the original data to re-parse.
3. User preferences and settings. Shape varies per user, changes frequently during development, and is always read as a whole. Perfect JSONB use case.
When to normalize instead
1. You query the field frequently. JSONB indexes work, but they are slower and larger than B-tree indexes on regular columns. If you filter by data->>'status' on every request, that should be a column.
2. You need referential integrity. JSONB cannot enforce foreign keys. If your JSON contains user IDs, nothing stops them from pointing to deleted users.
3. You aggregate on the field. SUM(data->>'amount') requires casting and cannot use regular indexes efficiently.
Indexing JSONB correctly
The GIN index covers the entire document:
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
This supports @> (contains) queries well:
SELECT * FROM products WHERE attributes @> '{"color": "red"}';
For specific key lookups, an expression index is smaller and faster:
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
The hybrid approach
My go-to pattern: normalize the fields you query, keep JSONB for the rest. A product has name, price, category as columns and an attributes JSONB column for everything else. Best of both worlds.
Migrations with JSONB
The biggest risk: schema drift. Without a schema definition, different parts of the codebase write different shapes. I use Zod to validate JSONB content at the application layer before every write. If the shape is wrong, it fails loudly instead of silently corrupting data.
