Robustech
Plan your CRM fields, tables, history: Built around how you work. An orange cube linked by glowing lines to glass data cubes and database cylinders

Custom CRM & Micro-SaaS

Custom CRM Data Model: Tables, Fields and Audit Trails

How to design a custom CRM data model: core objects, custom fields (columns vs JSONB vs EAV), activity logging, audit trails, permissions and reporting.

By Issa Badran · Published · 11 min read

A good CRM data model has a small set of core objects (contacts, companies, deals, pipelines and activities) connected by explicit relationship tables, plus a deliberate plan for custom fields, change history and permissions. The decisions that hurt most later are storing custom fields the wrong way, tying each contact to only one company and skipping an audit trail. In PostgreSQL, a practical default is real columns for core fields, a JSONB column for user-defined custom fields, triggers for the audit log and row-level security for record access. This guide walks through each piece with illustrative SQL.

Key Takeaways

  • Model CRM data the way established platforms do: objects, records, properties, associations, pipelines with ordered stages and activities.
  • Use link tables for many-to-many relationships such as contacts to companies and contacts to deals.
  • PostgreSQL caps tables at 1,600 columns, and dropped columns still count, so adding a column per custom field doesn't scale.
  • JSONB with a GIN index is the practical default for custom fields; promote fields you report on heavily to real columns.
  • Keep activities (what happened with a customer) separate from the audit trail (who changed which data).

Start With How Established CRMs Model Data

You don't need to invent CRM concepts. HubSpot's developer documentation describes its CRM as objects (contacts, companies, deals, tickets and custom objects), records (individual entries), properties (fields), associations between records (which can carry labels), pipelines with ordered stages, and activities such as calls, emails, meetings, notes, tasks and postal mail (HubSpot Developers). Salesforce follows the same broad pattern of standard objects, custom objects, fields and relationships.

Borrowing this vocabulary has two benefits. Your team already understands it, and data maps cleanly if you're migrating off HubSpot or GoHighLevel. The value of a custom build comes from modeling your specific business on top of these basics, not from reinventing them.

Core Objects and Relationships

Here's the core of a typical custom CRM, shown as a table instead of a diagram. Each row is a database table.

Table What it holds Key columns Relates to
tenants Each organization using the CRM (if multi-tenant) id, name, plan Everything below via tenant_id
users People who log in id, tenant_id, email, role Owns contacts, deals and activities
companies Organizations you sell to id, tenant_id, name, domain, owner_id Contacts (via contact_companies), deals
contacts Individual people id, tenant_id, name, email, phone, owner_id Companies, deals, activities
contact_companies Link between contacts and companies tenant_id, contact_id, company_id, role, is_primary Many-to-many
pipelines A sales or service process id, tenant_id, name Stages, deals
pipeline_stages Ordered steps in a pipeline id, pipeline_id, name, position, probability Deals
deals Opportunities id, tenant_id, company_id, stage_id, amount, close_date, owner_id Contacts (via deal_contacts), activities
deal_contacts Who is involved in each deal tenant_id, deal_id, contact_id, role Many-to-many
deal_stage_history Every stage change deal_id, from_stage_id, to_stage_id, changed_at Reporting on pipeline speed
activities Calls, emails, meetings, notes, tasks id, tenant_id, type, occurred_at, user_id Records via activity_links
custom_field_definitions Fields each tenant adds tenant_id, object_type, key, data_type Contacts, companies, deals
audit_log Every change to important data table_name, row_id, operation, old and new values All audited tables

The tenants table only matters if several organizations share the system. If you sell the CRM as a product, its plan column will be driven by your billing provider; our Stripe subscription billing guide explains how webhooks keep it in sync.

An illustrative sketch of the contact and company relationship:

-- Illustrative only
create table contacts (
  id         uuid primary key default gen_random_uuid(),
  tenant_id  uuid not null references tenants(id),
  first_name text,
  last_name  text,
  email      text,
  owner_id   uuid references users(id),
  custom     jsonb not null default '{}',
  created_at timestamptz not null default now(),
  unique (tenant_id, email)
);

create table contact_companies (
  tenant_id  uuid not null references tenants(id),
  contact_id uuid not null references contacts(id),
  company_id uuid not null references companies(id),
  role       text,                 -- e.g. 'Decision maker', 'Billing'
  is_primary boolean not null default false,
  primary key (contact_id, company_id)
);

Consider composite foreign keys on (tenant_id, id), backed by a unique constraint on those columns in the parent table, so a link row can't point at a record in another tenant; foreign-key checks bypass row-level security, so the database won't catch a cross-tenant link any other way.

The tempting shortcut is a single company_id column on each contact. It breaks as soon as you meet a consultant who works with three clients, a board member, or someone who changes jobs and stays a good lead. A link table handles all of these, and the role column gives you something like HubSpot's association labels.

Pipelines and Stages Are Data, Not Code

Store pipelines and their ordered stages as rows, not as hard-coded values. That lets sales managers add a stage without a deployment, and it lets you run several pipelines (new business, renewals, service) side by side. Record every stage change in a history table. It's the simplest reliable way to answer "how long do deals sit in proposal?" later.

Custom Fields: Columns vs JSONB vs EAV

Every CRM eventually needs fields you didn't plan for: industry, lead source detail, roof type, contract renewal date. There are three common ways to store them in PostgreSQL.

Approach How it works Strengths Weaknesses Best for
Real columns Add a column to the table per field Fast, typed, easy to report on and index Schema change per field; PostgreSQL caps tables at 1,600 columns; poor fit for per-tenant fields Core fields everyone uses
JSONB column One custom column holding key-value pairs Flexible, indexable, no schema change per field Types enforced in the app; reporting needs indexes or promotion User-defined custom fields
EAV (entity-attribute-value) A separate row per field value Very flexible, no schema changes Complex queries, weak typing, harder reporting Rarely the right choice for new builds

Real Columns

Columns are the best choice for fields you filter, sort and report on constantly. PostgreSQL makes adding them cheap in many cases: ADD COLUMN with no default, or with a non-volatile default, needs no table rewrite and is "very fast even on large tables." Volatile defaults, stored generated columns and identity columns do trigger a rewrite (PostgreSQL ALTER TABLE).

The limits show up with user-defined fields. A table can have at most 1,600 columns (fewer if rows don't fit an 8 KB page), and dropped columns still count toward that limit (PostgreSQL limits). For multitenant systems, Microsoft warns against "column-level tenant customization" and recommends a dedicated table for tenant custom data instead (Microsoft Learn).

JSONB

PostgreSQL recommends jsonb over json for most applications. It's stored in a decomposed binary format that is "significantly faster" to process and can be indexed. It doesn't preserve whitespace, key order or duplicate keys (PostgreSQL JSON types), which doesn't matter for custom fields.

Indexing choices matter. The default GIN operator class, jsonb_ops, supports the ?, ?|, ?&, @>, @? and @@ operators. The jsonb_path_ops class supports only @>, @? and @@, but is "usually much smaller and faster" for containment queries. An expression index on a single key is smaller still, but only helps queries on that key (PostgreSQL JSON types).

-- Illustrative only
create table custom_field_definitions (
  id          uuid primary key default gen_random_uuid(),
  tenant_id   uuid not null references tenants(id),
  object_type text not null,   -- 'contact', 'company', 'deal'
  key         text not null,   -- 'industry'
  label       text not null,   -- 'Industry'
  data_type   text not null,   -- 'text', 'number', 'date', 'select'
  options     jsonb,           -- allowed values for 'select'
  unique (tenant_id, object_type, key)
);

create index contacts_custom_idx on contacts using gin (custom jsonb_path_ops);

-- Find roofing contacts for one tenant
select id, first_name, last_name
from contacts
where tenant_id = $1
  and custom @> '{"industry": "roofing"}';

Two cautions from the PostgreSQL docs. Any update locks the whole row, so keep JSON documents "to a manageable size." And each document should ideally hold data that business rules can't sensibly split apart (PostgreSQL JSON types). In practice: a modest set of custom field values per record is fine; a growing history or log is not, so give that its own table.

EAV

Entity-attribute-value stores each field value as its own row: record ID, field name, value. It looks flexible, but reasoning through real use shows the problems. A simple report that filters on three custom fields needs three joins, every value tends to be stored as text so type checks move into application code, and the database can't enforce required fields. JSONB gives you most of the flexibility with far simpler queries.

A Practical Hybrid

A practical hybrid is columns for core fields, JSONB for custom fields and a definition table that tells the interface how to display and validate each field. This is a pragmatic trade-off: PostgreSQL's docs suggest each JSON document ideally holds one atomic datum, and a bag of custom field values bends that advice in exchange for flexibility. When a custom field becomes central to reporting, promote it to a real column with a migration.

Activity Logging: Calls, Emails, Meetings and Notes

Activities record what happened with a customer. Use one activities table with a type column, plus a link table so a single activity can attach to many records.

-- Illustrative only
create table activities (
  id          uuid primary key default gen_random_uuid(),
  tenant_id   uuid not null references tenants(id),
  type        text not null check (type in ('call','email','meeting','note','task')),
  occurred_at timestamptz not null,
  user_id     uuid references users(id),
  subject     text,
  body        text,
  details     jsonb not null default '{}',  -- call duration, meeting link, email message ID
  created_at  timestamptz not null default now()
);

create table activity_links (
  tenant_id   uuid not null references tenants(id),
  activity_id uuid not null references activities(id),
  record_type text not null,  -- 'contact', 'company', 'deal'
  record_id   uuid not null,
  primary key (activity_id, record_type, record_id)
);
create index on activity_links (record_type, record_id);

Because record_id points at different tables, the database can't enforce it with a foreign key; validate it in the application or use separate nullable foreign-key columns per record type.

An email to three people about one deal becomes one activity with four links, so it shows on all three contacts and the deal. For synced email and calendar data, store the provider's message or event ID in details and check it before inserting, so re-syncs don't create duplicates.

Building an Audit Trail

The audit trail answers a different question: who changed which data, and what was it before? This matters when a deal amount changes unexpectedly, a contact's owner is reassigned, or a customer asks you to prove what happened.

The PostgreSQL documentation includes a row-level audit trigger pattern that records the operation, timestamp, user and row values. It also notes that statement-level triggers using transition tables "can be significantly faster" when a statement changes many rows, such as a bulk import (PostgreSQL triggers).

-- Illustrative only, adapted from the row-level pattern in the PostgreSQL docs
create table audit_log (
  id         bigint generated always as identity primary key,
  table_name text not null,
  row_id     uuid not null,
  operation  text not null,  -- INSERT, UPDATE, DELETE
  changed_by text default current_setting('app.user_id', true),
  changed_at timestamptz not null default now(),
  old_values jsonb,
  new_values jsonb
);

create function log_change() returns trigger as $$
begin
  if (tg_op = 'DELETE') then
    insert into audit_log (table_name, row_id, operation, old_values)
    values (tg_table_name, old.id, tg_op, to_jsonb(old));
  elsif (tg_op = 'UPDATE') then
    insert into audit_log (table_name, row_id, operation, old_values, new_values)
    values (tg_table_name, new.id, tg_op, to_jsonb(old), to_jsonb(new));
  else
    insert into audit_log (table_name, row_id, operation, new_values)
    values (tg_table_name, new.id, tg_op, to_jsonb(new));
  end if;
  return null;
end;
$$ language plpgsql;

create trigger deals_audit
after insert or update or delete on deals
for each row execute function log_change();

Note the app.user_id setting. Most applications connect to the database with one shared account, so without passing the real user's ID into the session, every change would appear to come from the same user. Make the audit table append-only for the application role.

Permissions: Who Can See Which Records

CRM permissions usually work at two levels. Role permissions decide what a user can do (edit deals, export contacts, manage settings). Record permissions decide which records they can see: everything, their team's, or only their own.

PostgreSQL row-level security can enforce record-level rules in the database. USING controls which rows are visible, WITH CHECK controls which rows can be written, and multiple policies combine: permissive policies with OR, restrictive policies with AND (PostgreSQL row security).

-- Illustrative only
alter table deals enable row level security;
alter table deals force row level security;  -- apply to the table owner too

-- Restrictive: must always be in the current tenant
create policy deals_tenant on deals as restrictive
  using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

-- Permissive: reps see their own deals, managers see all
create policy deals_visibility on deals
  using (
    owner_id = nullif(current_setting('app.user_id', true), '')::uuid
    or current_setting('app.role', true) = 'manager'
  )
  with check (
    owner_id = nullif(current_setting('app.user_id', true), '')::uuid
    or current_setting('app.role', true) = 'manager'
  );

Settings such as app.role and app.tenant_id can be set by any database session, so they're only trustworthy when your application server is the only thing connecting to the database. Alternatively, have the policy check the user's role against the memberships table instead of a session setting.

Keep the same rules in your application code as well, and test them. Our multi-tenant SaaS architecture guide covers the ways RLS can be bypassed.

Designing for Reporting

Reporting is where data model shortcuts show up. A few practitioner rules:

  • Keep history, not just current state. The stage history table powers win rates, stage duration and forecast accuracy.
  • Make custom fields reportable. JSONB custom fields make reporting harder unless they're indexed or promoted to columns. Promote the fields managers filter on every week.
  • Store lead source data on the contact or deal. Campaign, source and click identifiers let you connect closed revenue back to marketing spend.
  • Separate heavy reporting from daily use. Run large reports against a read replica or summary tables so they don't slow down reps logging calls.

When to Just Configure HubSpot or Salesforce

A custom data model isn't always the answer. If your process fits standard objects, a standard pipeline and standard reports, configuring HubSpot or Salesforce is usually cheaper than building. Building means you own everything those platforms handle for you: data migration, duplicate detection and merging, email and calendar sync, integrations and ongoing maintenance.

Custom makes sense when your core workflow doesn't fit standard objects, when you're paying for workarounds and add-ons to approximate it, or when per-seat costs keep climbing. If you plan to sell your CRM to other businesses rather than use it internally, the scope grows into a full product; our guide on how to build a SaaS platform covers what that adds.

Getting the Model Right Before the Interface

A CRM's screens are relatively easy to redesign; its data model shapes everything for years. Start with proven objects, use link tables for relationships, choose a deliberate custom field strategy, log activities and changes separately and enforce permissions in both code and database. If you're planning a CRM built around your own process, Robustech's custom CRM development service designs and builds data models you own outright.

Frequently asked questions

What is the minimum set of tables for a custom CRM?

Most CRMs start with users, companies, contacts, a contact-to-company link table, pipelines, pipeline stages, deals, activities and an audit log. Add a tenants table if more than one organization will use the system. Custom field definitions and a deal stage history table are often worth adding early.

Should CRM custom fields be columns, JSONB or EAV?

Use real columns for core fields every account shares and that you filter or report on heavily. Use a JSONB column with a field-definition table for user-defined custom fields, indexed with GIN. Avoid EAV for most new builds, because reporting queries become complex and type checking moves into application code.

How should I log emails, calls and meetings against contacts and deals?

Store every interaction in one activities table with a type (call, email, meeting, note, task), a timestamp, the user who did it and type-specific details. Link each activity to any number of contacts, companies and deals through a separate association table, so one email to three people on a deal appears on every relevant record.

How do I keep a full audit history of CRM changes?

Use database triggers that write the old and new values, the operation, the time and the acting user into an append-only audit table whenever important records change. PostgreSQL documents this trigger pattern. Pass your application's user ID into the database session so the log records the real person, not just the shared database account.

Can a contact belong to more than one company?

Yes, and your schema should allow it. Instead of a single company_id on the contact, use a link table between contacts and companies with fields such as role, a primary flag and start and end dates. That handles consultants, board members and people who change jobs without losing history.

Keep reading

Ready to own your revenue engine?

Book a free strategy call. We will audit your ads, site, CRM and follow-up and show you where leads are leaking.