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.
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:
| Constraint | What It Means |
|---|---|
| Schema variability | Tenant A has 3 text fields. Tenant B has 12 fields including dropdowns and dates. Tenant C has none. |
| Data isolation | Tenant A must never see Tenant B's field definitions or values, even in error states. |
| Type safety | A "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 evolution | Tenants add and remove fields constantly. The system cannot require downtime or migrations for this. |
| UI rendering | You 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
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.
| Metric | JSONB Performance |
|---|---|
| Simple equality query | ~1-5ms with GIN index |
| Range query on nested numeric | Full table scan (no index utilization) |
| Insert/update | Fast (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:
Cons:
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.
| Metric | EAV 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 values | Degrades 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:
Cons:
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
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
);| Metric | Extension 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 entity | Fixed 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:
Cons:
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
| Metric | Salesforce Approach |
|---|---|
| Max custom fields per object | ~500 (flex column limit) |
| Storage type | All VARCHAR (runtime type coercion) |
| Query model | Translated SOQL to physical SQL |
| Indexing | Selective indexes on high-cardinality fields |
| Engineering investment | Hundreds of engineers over 20+ years |
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
);| Metric | Schema-per-Tenant Performance |
|---|---|
| Query performance | Optimal (native columns with native indexes) |
| Tenant isolation | Complete (separate schemas) |
| Adding a custom field | ALTER TABLE on tenant's schema only |
| Connection management | Complex (connection pooling across N schemas) |
| Max tenants | Practical limit ~1,000-5,000 schemas per database |
| Criteria | JSONB | EAV | Extension | Flex | Schema/Tenant |
|---|---|---|---|---|---|
| Setup complexity | Low | Medium | Medium | Very High | Medium |
| Query performance | Medium | Poor | Good | Good | Excellent |
| Type safety (DB) | None | None | Partial | None | Full |
| Max fields/entity | Unlimited | Unlimited | Fixed | ~500 | Unlimited |
| Maintenance burden | Low | Medium | High | Very High | High |
| Team size needed | 1-2 | 2-3 | 3-5 | 50+ | 3-5 |
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 Type | Example | Complexity |
|---|---|---|
| Type coercion | "50000" string to 50000 number | Low |
| Required fields | "Industry" must have a value | Low |
| String constraints | Min length, max length, regex pattern | Medium |
| Numeric constraints | Min, max, decimal places | Medium |
| Option constraints | Value 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 true | High |
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.
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:
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:
| Layer | Mechanism |
|---|---|
| Database | Every query includes WHERE tenant_id = ? or equivalent |
| API | Middleware validates that the authenticated user belongs to the requested tenant |
| Token/Session | JWTs or session tokens are scoped to a specific tenant |
| Embed/Iframe | Origin validation on postMessage communication |
| Audit | All 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 initial build is 20% of the total cost. The remaining 80% is maintenance.
Ongoing costs include:
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
Kopra is a purpose-built API for multi-tenant custom fields. Here is how it maps to the architectural challenges covered in this guide:
| Challenge | Kopra's Approach |
|---|---|
| Database pattern | Managed storage with tenant-scoped data isolation (clientId on every row, soft deletes) |
| Validation | Server-side validation engine with type coercion, required fields, option constraints, min/max |
| Query performance | Indexed search endpoint with pagination (/field-values/search) |
| Tenant isolation | clientId-based isolation, JWT-scoped tokens, audit logging |
| UI rendering | Embeddable field editor (iframe + TypeScript SDK) with themeable components |
| Maintenance | Managed 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.