Skip to main content
All posts

PostgreSQL JSONB Indexing for Multi-Tenant AI Feature Metadata: Querying 100K Feature Configs Without Full Table Scans

2 June 2026

PostgreSQL JSONB Indexing for Multi-Tenant AI Feature Metadata: Querying 100K Feature Configs Without Full Table Scans

When you’re building a SaaS with AI features, you face a brutal choice: normalize everything into relational tables and spend your weeks writing migrations, or throw it all into a settings TEXT column and query like you’re grep-ing production logs.

I’ve done both. The migrations nearly killed me. The grep approach nearly killed performance.

There’s a third way: JSONB with GIN indexes. It’s how CitizenApp stores heterogeneous feature configurations—prompts, temperature settings, usage limits, custom parameters—for each tenant, without schema sprawl or full table scans. And it actually performs.

Why JSONB Instead of Normalized Tables

Here’s the reality: AI feature definitions change weekly. Last month you needed temperature and max_tokens. This week it’s system_prompt, retrieval_config, and output_validator_rules. Next week? Who knows.

Normalizing this means:

JSONB lets you store arbitrary schemas per tenant, per feature. Your schema evolves in application code, not database migrations. When your AI engineer decides to add top_p sampling alongside temperature, you update your TypeScript types and ship. No downtime.

But here’s the catch: unindexed JSONB is a full table scan trap. You need GIN indexes.

The Schema: Simple, Flexible, Indexed

CREATE TABLE feature_configs (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  feature_key TEXT NOT NULL,
  -- Store the entire feature definition as JSONB
  config JSONB NOT NULL DEFAULT '{}',
  version INT NOT NULL DEFAULT 1,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, feature_key)
);

-- GIN index on the config JSONB column
CREATE INDEX idx_feature_configs_config_gin ON feature_configs USING GIN (config);

-- Composite index: tenant_id + GIN on config
-- This is critical for multi-tenant workloads
CREATE INDEX idx_feature_configs_tenant_config_gin 
  ON feature_configs (tenant_id) 
  INCLUDE (config);

-- Partial index: only active features
CREATE INDEX idx_feature_configs_active 
  ON feature_configs (tenant_id, feature_key) 
  WHERE (config->>'enabled')::BOOLEAN = true;

That’s it. One table. No migrations when features evolve. But the indexes matter—without them, you’re scanning the whole table.

Querying JSONB: The Operators You Need

PostgreSQL gives you several JSONB operators. I use three constantly:

-- Find all features where temperature is > 0.8
-- This uses the GIN index
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
  AND config->'parameters'->>'temperature' > '0.8';

-- Find all features that have a 'retrieval_config' key
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
  AND config @> '{"retrieval_config": {}}'::JSONB;

-- Find all features where the enabled flag is true
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
  AND (config->>'enabled')::BOOLEAN = true;

The Python/FastAPI Side: Type-Safe, Versioned

I prefer Pydantic for schema validation before it hits the database. Your feature definitions live in code, not scattered across spreadsheets.

from pydantic import BaseModel, Field
from sqlalchemy import Column, String, Integer, JSONB, BIGINT, UUID, DateTime, func
from sqlalchemy.orm import declarative_base
from datetime import datetime
from uuid import UUID as PyUUID

Base = declarative_base()

# Schema definitions—living in code, not the database
class TextGenerationParams(BaseModel):
    model: str = "claude-3-5-sonnet-20241022"
    temperature: float = Field(default=0.7, ge=0.0, le=2.0)
    max_tokens: int = Field(default=1024, ge=100, le=4096)
    system_prompt: str

class RetrievalConfig(BaseModel):
    enabled: bool = False
    vector_store: str | None = None
    top_k: int = 5

class FeatureConfig(BaseModel):
    enabled: bool = True
    feature_type: str  # "text_generation", "retrieval", etc.
    parameters: TextGenerationParams | dict
    retrieval_config: RetrievalConfig | None = None
    usage_limits: dict = Field(default_factory=dict)
    version: int = 1

# SQLAlchemy model
class FeatureConfigModel(Base):
    __tablename__ = "feature_configs"
    
    id = Column(BIGINT, primary_key=True)
    tenant_id = Column(UUID, nullable=False)
    feature_key = Column(String(255), nullable=False)
    config = Column(JSONB, nullable=False, default={})
    version = Column(Integer, nullable=False, default=1)
    created_at = Column(DateTime(timezone=True), nullable=False, default=func.now())
    updated_at = Column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now())

Now the FastAPI endpoints:

from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import and_, select

app = FastAPI()

@app.get("/tenants/{tenant_id}/features/{feature_key}")
async def get_feature(tenant_id: PyUUID, feature_key: str, db: Session = Depends(get_db)):
    """Fetch a single feature config and validate it"""
    stmt = select(FeatureConfigModel).where(
        and_(
            FeatureConfigModel.tenant_id == tenant_id,
            FeatureConfigModel.feature_key == feature_key
        )
    )
    row = db.execute(stmt).scalar_one_or_none()
    
    if not row:
        raise HTTPException(status_code=404, detail="Feature not found")
    
    # Validate against schema
    config = FeatureConfig(**row.config)
    return config

@app.post("/tenants/{tenant_id}/features/{feature_key}")
async def upsert_feature(
    tenant_id: PyUUID,
    feature_key: str,
    payload: FeatureConfig,
    db: Session = Depends(get_db)
):
    """Upsert a feature config"""
    # Validation happens automatically via Pydantic
    stmt = select(FeatureConfigModel).where(
        and_(
            FeatureConfigModel.tenant_id == tenant_id,
            FeatureConfigModel.feature_key == feature_key
        )
    )
    row = db.execute(stmt).scalar_one_or_none()
    
    if row:
        row.config = payload.model_dump()
        row.version += 1
    else:
        row = FeatureConfigModel(
            tenant_id=tenant_id,
            feature_key=feature_key,
            config=payload.model_dump(),
            version=1
        )
        db.add(row)
    
    db.commit()
    return {"id": row.id, "version": row.version}

@app.get("/tenants/{tenant_id}/features/search")
async def search_features(
    tenant_id: PyUUID,
    enabled_only: bool = True,
    db: Session = Depends(get_db)
):
    """Find all enabled features for a tenant"""
    query = select(FeatureConfigModel).where(
        FeatureConfigModel.tenant_id == tenant_id
    )
    
    if enabled_only:
        # Uses the partial index
        query = query.where(
            (FeatureConfigModel.config["enabled"].astext).cast(bool) == True
        )
    
    rows = db.execute(query).scalars().all()
    return [FeatureConfig(**row.config) for row in rows]

Performance: Real Numbers

With a GIN index on config and 100K+ rows:

That’s a 150x difference. Not theoretical—measured on CitizenApp’s staging.

The key is that the GIN index doesn’t store the entire JSONB; it creates an index entry for every key and value. When you query with config->>'enabled', PostgreSQL uses the index to find matching rows instantly.

Gotcha: Type Casting and Query Planning

Here’s what burned me: type casting in WHERE clauses disables indexes.

# SLOW: Cast happens after the WHERE is evaluated
query = db.query(FeatureConfigModel).filter(
    cast(FeatureConfigModel.config["temperature"], Float) > 0.8
)

# FAST: Compare as strings, PostgreSQL handles the coercion
query = db.query(FeatureConfigModel).filter(
    FeatureConfigModel.config["parameters"]["temperature"].astext > "0.8"
)

The string comparison works because JSONB stores numbers as JSON numbers, and > comparisons work across types. But explicit CAST forces a full evaluation. Check EXPLAIN ANALYZE output—if you see “Seq Scan” instead

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch