← Back to blog

Custom Fields in Multi-Tenant SaaS: The Complete Architecture Guide

12 min read · March 27, 2026

Multi-tenant custom fields are one of the hardest infrastructure problems in B2B SaaS. Every customer wants different data fields, but they all share the same database, the same API, and the same UI. The challenge is storing, validating, querying, and rendering dynamic data that varies per tenant, per entity type, and per record, while keeping tenants completely isolated from each other.

This guide covers every major approach to the problem, with real performance numbers, architectural tradeoffs, and the maintenance implications that only become apparent after you ship.

Why Multi-Tenant Custom Fields Are an Unsolved Problem

Custom fields seem simple in isolation. A single-tenant app with 10 custom fields is straightforward: add 10 columns to your database table. The complexity comes from the combination of constraints that multi-tenant SaaS introduces:

ConstraintWhat It Means
Schema variabilityTenant A has 3 text fields. Tenant B has 12 fields including dropdowns and dates. Tenant C has none.
Data isolationTenant A must never see Tenant B's field definitions or values, even in error states.
Type safetyA "Contract Value" field must reject "hello" and accept 50000.00. Across all tenants, simultaneously.
Query performance"Show me all contacts where Industry = Healthcare" must return in milliseconds, not seconds.
Schema evolutionTenants add and remove fields constantly. The system cannot require downtime or migrations for this.
UI renderingYou cannot write a form component for fields that do not exist yet. The UI must be generated dynamically.

No single database pattern handles all six constraints well. That is why this remains an active design problem across the industry.

"You lose the ability to create proper indexes on specific fields, query performance degrades as the JSON grows, and data types or constraints cannot be enforced at the database level."
Source: CockroachDB Forum

Database Patterns Compared

Pattern 1: JSONB Columns

Add a custom_fields JSONB column to your existing tables.

ALTER TABLE contacts ADD COLUMN custom_fields JSONB DEFAULT '{}';

-- Write
UPDATE contacts
SET custom_fields = '{"industry": "Healthcare", "contract_value": 50000}'
WHERE id = 'contact-123' AND tenant_id = 'northstar';

-- Read
SELECT * FROM contacts
WHERE tenant_id = 'northstar'
  AND custom_fields->>'industry' = 'Healthcare';

Performance characteristics: GIN indexes on JSONB columns provide reasonable query performance for key-existence and equality checks. PostgreSQL 12+ supports jsonpath expressions. However, range queries on numeric values inside JSONB require casting, which bypasses indexes.

MetricJSONB Performance
Simple equality query~1-5ms with GIN index
Range query on nested numericFull table scan (no index utilization)
Insert/updateFast (single row, single column)
Schema change (add field)No migration needed
Max practical document size~255 MB per row (PostgreSQL limit), but performance degrades significantly above ~1 MB

Pros:

  • One ALTER TABLE to start
  • Flexible structure per row
  • Good PostgreSQL ecosystem support
  • No joins required to read values

Cons:

  • No database-level type enforcement
  • GIN indexes do not help with range queries or ordering
  • Complex nested queries are hard to optimize
  • All validation must live in application code
  • No foreign key constraints on values

Pattern 2: Entity-Attribute-Value (EAV)

Three tables: entities, field definitions (attributes), and field values.

CREATE TABLE field_definitions (
  id UUID PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  entity_type TEXT NOT NULL,
  field_key TEXT NOT NULL,
  field_label TEXT NOT NULL,
  field_type TEXT NOT NULL, -- 'string', 'number', 'date', 'select'
  validation JSONB,
  UNIQUE(tenant_id, entity_type, field_key)
);

CREATE TABLE field_values (
  id UUID PRIMARY KEY,
  field_definition_id UUID REFERENCES field_definitions(id),
  entity_id TEXT NOT NULL,
  tenant_id TEXT NOT NULL,
  value TEXT NOT NULL
);

CREATE INDEX idx_fv_lookup
ON field_values(tenant_id, entity_id, field_definition_id);

Performance characteristics: Reading all custom field values for a single entity requires one query with a JOIN. Querying "all contacts where Industry = Healthcare" requires a self-join for each field in the WHERE clause.

MetricEAV Performance
Read all values for one entity~2-10ms (single join)
Query by one field value~5-20ms (self-join)
Query by two field values~20-100ms (two self-joins)
Query by three+ field valuesDegrades rapidly
Insert/update single value~1-5ms
Schema change (add field)INSERT into field_definitions (no migration)
"Querying becomes a nightmare of self-joins, performance tanks as the attribute table grows, and you essentially give up on relational database benefits."
Source: CockroachDB Forum

Pros:

  • Clean relational model
  • Natural multi-tenancy (filter by tenant_id)
  • Easy to add/remove fields without schema changes
  • Standard SQL tooling works

Cons:

  • All values stored as TEXT (type safety lost at the database level)
  • Self-joins for multi-field queries destroy performance
  • Reporting is extremely difficult (pivot queries)
  • Row count grows multiplicatively (entities x fields)

Even Jira, which uses a variant of EAV, struggles at scale: "Too many custom fields can compromise the performance of Jira instances." Atlassian recommends staying under roughly 1,200 fields per instance.

Source: Atlassian

Pattern 3: Extension Tables

A hybrid approach. Each entity type gets a dedicated extension table with typed columns.

CREATE TABLE contact_extensions (
  contact_id UUID PRIMARY KEY REFERENCES contacts(id),
  tenant_id TEXT NOT NULL,
  ext_field_1 TEXT,
  ext_field_2 TEXT,
  ext_field_3 NUMERIC,
  ext_field_4 DATE,
  ext_field_5 BOOLEAN,
  -- ... up to N columns
);

-- Metadata table maps logical names to physical columns
CREATE TABLE extension_field_mappings (
  id UUID PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  entity_type TEXT NOT NULL,
  logical_name TEXT NOT NULL,  -- "industry"
  physical_column TEXT NOT NULL, -- "ext_field_1"
  field_type TEXT NOT NULL
);
MetricExtension Table Performance
Read values for one entity~1-3ms (simple join)
Query by field value~1-5ms (standard indexed query)
Insert/update~1-3ms
Schema change (add field)Requires ALTER TABLE if all slots used
Max fields per entityFixed at table creation time
"After a year with 20 clients, a users table can have 47 columns, and migrations become risky because you're constantly modifying core tables."
Source: V. Checha

Pros:

  • Real column types (not everything is TEXT)
  • Standard SQL queries and indexes
  • Best query performance of all patterns
  • Familiar to any SQL developer

Cons:

  • Fixed upper limit on custom fields per entity
  • Schema migrations across hundreds of tenants are operationally complex
  • Column slot management requires a metadata layer
  • Adding new slots requires downtime (ALTER TABLE on large tables)

Pattern 4: Salesforce Flex Columns

Salesforce uses a metadata-driven architecture with approximately 500 generic "flex columns" per object (Value0 through Value500). All stored as VARCHAR. A Universal Data Dictionary (UDD) maps logical field names to physical column slots at runtime.

Every query goes through a translation layer: SELECT industry, contract_value FROM Contact becomes SELECT Value42, Value187 FROM MT_Data WHERE ObjType = 'Contact'.

Source: Cirra - Salesforce Database Architecture

MetricSalesforce Approach
Max custom fields per object~500 (flex column limit)
Storage typeAll VARCHAR (runtime type coercion)
Query modelTranslated SOQL to physical SQL
IndexingSelective indexes on high-cardinality fields
Engineering investmentHundreds of engineers over 20+ years

Pattern 5: Dedicated Schema per Tenant

Each tenant gets their own PostgreSQL schema (or database) with physical columns for their custom fields.

-- Create schema for new tenant
CREATE SCHEMA tenant_northstar;

-- Each tenant has actual columns
CREATE TABLE tenant_northstar.contacts (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT,
  industry TEXT,          -- Custom field
  contract_value NUMERIC  -- Custom field
);
MetricSchema-per-Tenant Performance
Query performanceOptimal (native columns with native indexes)
Tenant isolationComplete (separate schemas)
Adding a custom fieldALTER TABLE on tenant's schema only
Connection managementComplex (connection pooling across N schemas)
Max tenantsPractical limit ~1,000-5,000 schemas per database

Pattern Comparison Summary

CriteriaJSONBEAVExtensionFlexSchema/Tenant
Setup complexityLowMediumMediumVery HighMedium
Query performanceMediumPoorGoodGoodExcellent
Type safety (DB)NoneNonePartialNoneFull
Max fields/entityUnlimitedUnlimitedFixed~500Unlimited
Maintenance burdenLowMediumHighVery HighHigh
Team size needed1-22-33-550+3-5

Validation at Scale

Regardless of which database pattern you choose, validation is an application-layer problem. The database cannot enforce that a "Contract Value" field contains a positive number, or that an "Industry" dropdown only accepts values from a predefined list.

A production validation system needs to handle:

Validation TypeExampleComplexity
Type coercion"50000" string to 50000 numberLow
Required fields"Industry" must have a valueLow
String constraintsMin length, max length, regex patternMedium
Numeric constraintsMin, max, decimal placesMedium
Option constraintsValue must be one of ["SMB", "Mid-Market", "Enterprise"]Medium
Cross-field validation"End Date" must be after "Start Date"High
Conditional fields"Insurance Provider" only required when "Has Insurance" is trueHigh

Each validation rule must be stored as metadata alongside the field definition, evaluated at write time (API and UI), and returned as structured errors that the UI can display per-field.

The validation engine must also handle schema evolution: if a tenant changes a field from optional to required, existing records with no value for that field must not break when viewed, only when edited.

Query Patterns

Searching across custom field values is where most implementations break down.

The common query: "Show me all contacts where Industry = Healthcare and Contract Value > 50000, sorted by Contract Value descending, paginated 20 per page."

With JSONB:

SELECT * FROM contacts
WHERE tenant_id = 'northstar'
  AND custom_fields->>'industry' = 'Healthcare'
  AND (custom_fields->>'contract_value')::numeric > 50000
ORDER BY (custom_fields->>'contract_value')::numeric DESC
LIMIT 20 OFFSET 0;

The cast to numeric prevents index usage. You need a functional index:

CREATE INDEX idx_contract_value
ON contacts ((custom_fields->>'contract_value')::numeric)
WHERE tenant_id IS NOT NULL;

This works for one field. If you have 100 custom fields across 50 tenants, you need a strategy for which fields get indexes. Most implementations use either:

  1. Selective indexing: Only index fields that are frequently queried. Requires usage analytics to determine which fields qualify.
  2. Materialized views: Periodically flatten custom field values into queryable views. Adds latency between write and query availability.
  3. Search index: Sync custom field values to Elasticsearch or similar. Adds infrastructure complexity but provides flexible querying.

Tenant Isolation

In multi-tenant custom fields, data isolation is not optional. A single bug that shows Tenant A's field definitions or values to Tenant B is a security incident.

Isolation must be enforced at multiple layers:

LayerMechanism
DatabaseEvery query includes WHERE tenant_id = ? or equivalent
APIMiddleware validates that the authenticated user belongs to the requested tenant
Token/SessionJWTs or session tokens are scoped to a specific tenant
Embed/IframeOrigin validation on postMessage communication
AuditAll access is logged with tenant context for forensic review

Row-level security (RLS) in PostgreSQL provides a database-level safety net:

ALTER TABLE field_values ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON field_values
  USING (tenant_id = current_setting('app.current_tenant'));

The Maintenance Burden

The initial build is 20% of the total cost. The remaining 80% is maintenance.

Ongoing costs include:

  • Backwards compatibility. When a tenant changes a field from "select" to "multiselect," existing values must not break. Migration logic for each type change combination is needed.
  • Security patches. Any endpoint that accepts or returns custom field data is a potential injection vector. JSONB columns in particular require careful sanitization.
  • Performance tuning. As data grows, queries that worked at 10,000 rows break at 10 million. Index strategies need periodic review.
  • New field type requests. Users will ask for file uploads, rich text, address fields, phone number fields with country codes, and currency fields with exchange rates. Each new type requires database changes, validation logic, and a UI component.
  • Database migrations. If you chose extension tables or schema-per-tenant, every structural change requires coordinated migrations across all tenants.

Building custom fields in-house typically costs 3-4x the original estimate once development, maintenance (15-20% of build cost annually), security, and training are accounted for.

Source: Appinventiv - Build vs Buy

How Kopra Addresses Each Challenge

Kopra is a purpose-built API for multi-tenant custom fields. Here is how it maps to the architectural challenges covered in this guide:

ChallengeKopra's Approach
Database patternManaged storage with tenant-scoped data isolation (clientId on every row, soft deletes)
ValidationServer-side validation engine with type coercion, required fields, option constraints, min/max
Query performanceIndexed search endpoint with pagination (/field-values/search)
Tenant isolationclientId-based isolation, JWT-scoped tokens, audit logging
UI renderingEmbeddable field editor (iframe + TypeScript SDK) with themeable components
MaintenanceManaged service. Security patches, performance tuning, and new field types are handled by Kopra.

Integration takes hours, not months. The TypeScript SDK (npm install @kopra-dev/sdk) loads both the field editor and the configuration panel. The REST API provides 26 endpoints for server-side integration. Webhooks with HMAC-SHA256 signatures enable real-time sync.

Free tier available at kopra.dev. No credit card required.

References and Further Reading