A common dilemma in AI-powered SaaS development is balancing flexibility with performance when storing feature configurations. Many teams face a brutal choice: either invest weeks in complex database migrations to normalize every AI setting, or cram everything into a single TEXT column and hope queries don’t cripple production.
There’s a better approach that avoids both extremes. CitizenApp successfully manages heterogeneous AI feature configurations—prompts, temperature settings, usage limits, and custom parameters—for thousands of tenants while maintaining peak performance. Their solution relies on PostgreSQL’s JSONB data type paired with GIN indexes, enabling schema flexibility without sacrificing query efficiency.
The pitfalls of traditional approaches
When building AI features, configuration requirements evolve rapidly. Last month you might need temperature and max_tokens; this week, your AI engineers add system_prompt, retrieval_config, and output_validator_rules. Next week? Who knows?
Normalizing these into relational tables creates three major problems:
- Schema sprawl: Creating new tables for each feature type leads to migration nightmares and technical debt.
- JOIN complexity: Generic EAV (Entity-Attribute-Value) tables introduce performance-killing joins and unpredictable query plans.
- Downtime risks: Adding a column to a large table with 100,000+ rows triggers an
ALTER TABLElock that brings your SaaS to a halt for minutes.
These challenges force teams to choose between agility and stability—until now.
Building a flexible schema with JSONB and GIN indexes
The key insight is simple: store your feature configurations as JSONB, but index them properly. CitizenApp’s schema demonstrates this elegantly:
CREATE TABLE feature_configs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
feature_key TEXT NOT NULL,
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)
);
-- Essential GIN index for JSONB queries
CREATE INDEX idx_feature_configs_config_gin ON feature_configs USING GIN (config);
-- Critical for multi-tenant performance
CREATE INDEX idx_feature_configs_tenant_config_gin ON feature_configs (tenant_id) INCLUDE (config);
-- Partial index for active features only
CREATE INDEX idx_feature_configs_active ON feature_configs (tenant_id, feature_key)
WHERE (config->>'enabled')::BOOLEAN = true;This single-table approach eliminates migrations entirely. When your AI team decides to add top_p sampling alongside temperature, you update your application’s TypeScript types and ship—without touching the database. The schema evolves in code, not in painful database operations.
Mastering PostgreSQL’s JSONB operators
PostgreSQL provides powerful operators for working with JSONB data. Three operators handle most use cases:
->(text key): Returns JSONB for further nesting. Use when drilling into nested objects.->>(text key): Returns TEXT for direct filtering. Ideal for comparing concrete values.@>(contains): Checks if the left JSONB contains the right JSONB. Perfect for matching nested conditions.
Here are practical examples that leverage these operators:
-- Find features with high temperature settings (uses GIN index)
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
AND config->'parameters'->>'temperature' > '0.8';
-- Locate features with retrieval configurations
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
AND config @> '{"retrieval_config": {}}'::JSONB;
-- Filter for enabled features (uses partial index)
SELECT * FROM feature_configs
WHERE tenant_id = 'abc-123'::UUID
AND (config->>'enabled')::BOOLEAN = true;The GIN indexes ensure these queries perform efficiently even at scale, avoiding full table scans that would devastate performance.
Implementing type-safe configurations in Python
Strong typing prevents runtime errors and improves developer experience. Pydantic models provide runtime validation while SQLAlchemy handles persistence:
from pydantic import BaseModel, Field
from typing import Optional, Union
from uuid import UUID
from datetime import datetime
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: Optional[str] = None
top_k: int = 5
class FeatureConfig(BaseModel):
enabled: bool = True
feature_type: str # "text_generation", "retrieval", etc.
parameters: Union[TextGenerationParams, dict]
retrieval_config: Optional[RetrievalConfig] = None
usage_limits: dict = Field(default_factory=dict)
version: int = 1This structure ensures your application validates configurations before they reach the database, preventing invalid data from corrupting your system.
The future of AI configuration management
As AI features become more sophisticated, the need for flexible yet performant configuration management will only grow. PostgreSQL’s JSONB with GIN indexing offers a compelling middle ground—eliminating the trade-off between agility and stability.
Teams that adopt this approach gain the freedom to experiment with new AI capabilities without fear of database bottlenecks or migration disasters. The next time your AI engineers request a new configuration parameter, you can respond with confidence: it’s just another field in your JSONB document, already indexed and ready for production.
The future of AI-powered SaaS belongs to those who can adapt configurations as quickly as their models evolve—without sacrificing the performance that keeps customers happy.
AI summary
Learn how to use PostgreSQL JSONB with GIN indexes to manage 100,000 AI feature configurations per tenant without schema migrations or performance bottlenecks.