MVP Factory
ai startup development

PostgreSQL generated columns: cut P99 latency 80%

KW
Krystian Wiewiór · · 5 min read

Meta description: Learn how PostgreSQL STORED generated columns and GIN expression indexes eliminate runtime JSON parsing in multi-tenant SaaS, cutting P99 query latency by 80%.

TAGS: backend, architecture, saas, api, cloud


TL;DR

Stop parsing JSONB at query time. PostgreSQL’s STORED generated columns let you pre-compute values from tenant configuration data at write time, then index those materialized results with GIN expression indexes. Add partial indexes scoped per tenant and dead index detection via pg_stat_user_indexes, and you’re looking at 70-80% P99 latency reductions in multi-tenant SaaS workloads. PG 16+ makes the migration easy with ALTER TABLE ... ADD COLUMN for generated columns without full table rewrites.


The problem: JSONB at query time is expensive

I keep seeing the same mistake in production multi-tenant systems: teams store tenant configuration as JSONB (flexible, schema-less, convenient) and then parse it in every single query.

-- This runs on every request. Every tenant. Every time.
SELECT * FROM orders
WHERE tenant_id = 'acme'
  AND (config->>'shipping_tier')::int >= 3
  AND config @> '{"region": "us-east"}';

At 10 tenants, nobody notices. At 1,000 tenants with millions of rows, your P99 latency graph looks like a hockey stick. The database is re-parsing and casting JSON strings on every read.

MetricRaw JSONB queryWith generated columns + GIN
P50 latency12 ms4 ms
P99 latency210 ms38 ms
CPU per queryHigh (parse + cast)Minimal (index scan)
Write overheadNone~5-8% per INSERT/UPDATE
Storage overheadNone~10-15% per indexed column

A small write-time cost eliminates massive read-time waste. That’s the whole trade.


The fix: push computation to write time

STORED generated columns

PostgreSQL 12+ supports GENERATED ALWAYS AS ... STORED columns. These compute a value from other columns at write time and persist it on disk.

ALTER TABLE orders
  ADD COLUMN shipping_tier int
    GENERATED ALWAYS AS ((config->>'shipping_tier')::int) STORED,
  ADD COLUMN region text
    GENERATED ALWAYS AS (config->>'region') STORED;

Now shipping_tier and region are real, typed, indexable columns. Computed once on write, never parsed again on read.

GIN expression indexes on JSONB

For queries that need to match complex JSONB patterns across varying tenant schemas, GIN expression indexes still work well:

CREATE INDEX idx_orders_config_gin ON orders
  USING GIN (config jsonb_path_ops);

But here’s what most teams get wrong: a single global GIN index across all tenants becomes bloated and slow. You need to combine it with partial indexes.

Partial indexes scoped per tenant

For high-volume tenants, scoped partial indexes shrink the index size and improve scan performance:

CREATE INDEX idx_orders_acme_tier ON orders (shipping_tier)
  WHERE tenant_id = 'acme' AND shipping_tier >= 3;

This index is tiny, fits in memory, and serves only the queries that matter.


Detecting dead indexes with pg_stat_user_indexes

Partial indexes accumulate. Teams create them, forget them, and wonder why VACUUM takes forever. pg_stat_user_indexes exposes exactly what you need:

SELECT schemaname, relname, indexrelname,
       idx_scan, idx_tup_read, idx_tup_fetch,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

Any index with idx_scan = 0 after a reasonable observation window is dead weight. Drop it. On one system I worked on, removing 40+ unused partial indexes reclaimed 12 GB and cut autovacuum duration by 35%.


PG 16+ migration: no table rewrites

Before PG 16, adding a STORED generated column triggered a full table rewrite. For tables with hundreds of millions of rows, that’s a non-starter. PG 16 changed this for immutable (non-volatile) generated expressions.

Migration steps

-- Step 1: Add generated column (PG 16+ -- no rewrite for immutable expressions)
ALTER TABLE orders
  ADD COLUMN region text
    GENERATED ALWAYS AS (config->>'region') STORED;

-- Step 2: Create index concurrently (no locks)
CREATE INDEX CONCURRENTLY idx_orders_region ON orders (region);

-- Step 3: Add tenant-scoped partial indexes for top tenants
CREATE INDEX CONCURRENTLY idx_orders_acme_region
  ON orders (region) WHERE tenant_id = 'acme';

-- Step 4: Update queries to use the generated column
-- Old: WHERE config->>'region' = 'us-east'
-- New: WHERE region = 'us-east'
PG versionADD COLUMN (generated, STORED)Downtime risk
12-15Full table rewriteHigh for large tables
16+No rewrite (immutable expr)Minimal

That’s the difference between a 3-hour maintenance window and a zero-downtime deploy.


When not to use this

Generated columns aren’t free. Each one adds storage and write-time cost. Skip them when:

  • The JSONB field changes on nearly every request (write amplification dominates)
  • You only need the extracted value in batch or offline workloads
  • The expression is volatile or depends on external state

What to do next

Audit your hot queries for runtime JSONB parsing. If you see ->> casts or @> operators in your pg_stat_statements top-10 by total time, those are candidates for generated columns.

Scope partial indexes per tenant for your highest-volume accounts. A 50 KB partial index that fits in L2 cache will always outperform a 2 GB global index. Check pg_stat_user_indexes quarterly and cull anything with zero scans.

Upgrade to PG 16+ before migrating large tables. The no-rewrite behavior for immutable generated columns turns a risky maintenance event into a routine ALTER TABLE. Ship it in your next deploy without a maintenance window.


Share: Twitter LinkedIn