Your data

How to design a simple CRM data model: tables, fields and relationships

A simple CRM data model: Companies, Contacts, Deals, Activities and Users, how they relate, pipeline stages with history, common mistakes and Postgres DDL.

Founder, Emberblocks

Published 10 min read

On this page
  1. What tables does a CRM database need?
  2. Which fields go in each table?
  3. How should CRM tables relate to each other?
  4. How do you model pipeline stages?
  5. Activities or notes: what is the difference?
  6. Which computed fields are worth adding?
  7. What are the most common CRM data model mistakes?
  8. A Postgres schema for a simple CRM
  9. How to build this CRM in Emberblocks

A simple CRM data model needs five tables: Users (the people who own records), Companies, Contacts, Deals and Activities. Contacts belong to a company, deals belong to a company and an owner, activities attach to a deal or a contact, and the pipeline stage is an ordered single select on the deal. Add a stage history table if you want conversion and cycle-time reports, and a deal contacts table if several people matter on one deal.

Below is each table with its fields, the relationships between them, the mistakes that make a CRM unreliable, and a Postgres schema you can run as is. The model works in any database or no-code builder.

What tables does a CRM database need?

TableOne row isPoints at
UsersA person on your team who owns recordsNothing
CompaniesAn organisation you sell toUsers (account owner)
ContactsA person, usually at a companyCompanies
DealsOne sales opportunity with a value and a stageCompanies, Users (owner)
ActivitiesA call, email, meeting, task or noteDeals or Contacts, Users
Deal contacts (optional)One contact's role on one dealDeals, Contacts
Stage history (optional)One deal entering one stageDeals

Leads are deliberately missing. Many CRMs keep a separate Leads table and convert leads into contacts, which copies data and loses history at the moment of conversion. For a small team, a lead is a contact with no deal yet, or a deal in the first stage.

Which fields go in each table?

These are the fields worth having on day one. Anything else can wait until someone asks for it twice. Users needs only a name, a unique email and an active flag. In an app builder it can double as the list of people who sign in, so owner filters can match the signed-in person.

Companies

FieldTypeWhy
NameText, requiredWhat everyone searches by
DomainText, uniqueThe best duplicate check for companies: acme.com is acme.com however the name is spelled
IndustrySingle selectSegment reports; free text gives Software, software and SaaS
Size bandSingle select1-10, 11-50, 51-200 and so on; exact headcounts go stale
Account ownerReference to UsersWho answers for the relationship

Contacts

FieldTypeWhy
Full nameText, requiredSplit into first and last only if you mail-merge
EmailEmail, uniqueThe duplicate check and the matching key for imports
PhonePhoneTap to call on a phone
CompanyReference to CompaniesNever typed text (see the mistakes below)
Job titleTextContext for the conversation
StatusSingle select: Active, Left companyPeople change jobs; keep the record and its history

Deals

FieldTypeWhy
NameText, required"Acme, 40 seats" reads better than an ID
CompanyReference to Companies, requiredEvery deal belongs to an account
StageSingle select, ordered, requiredDrives the pipeline board and every report
AmountNumberOne currency per CRM unless you truly sell in several
Expected closeDateForecasts and closing-this-month lists
OwnerReference to UsersMy deals filters and per-rep reports
Stage entered onDateDays in stage, the clearest sign of a stuck deal
Lost reasonSingle selectRequired when Stage is Lost, or nobody fills it in

Activities

FieldTypeWhy
TypeSingle select: Call, Email, Meeting, Task, NoteFilters and counts by kind
SubjectText, requiredThe line people scan
Deal, ContactReferences, at least one setWhere the activity shows up
OwnerReference to UsersWho did it, or who has to
WhenDate-timePast for logged activities, future for tasks
DoneBooleanSeparates the to-do list from the history
NotesLong textContext, tied to a date and an author

How should CRM tables relate to each other?

Most CRM relationships are one-to-many, and the reference always lives on the "many" side: each contact stores its company, each deal stores its company, each activity stores its deal. A company never stores a list of its contacts, because that list is a query.

The interesting case is many-to-many. A deal involves several contacts (the champion, the budget holder, the person who signs), and one contact can be on several deals. There are two ways to model it:

ApproachHow it worksUse it when
Multi-reference fieldDeals has a Contacts field that holds several linksYou only need to know who is involved
Join tableA Deal contacts table with one row per deal and contact, plus fields of its ownThe link has data: role on this deal, date added, who signs

The join table wins as soon as the relationship has attributes. "Maria is the decision maker on the Acme renewal but only an end user on the Acme pilot" cannot live in a list of links; it needs a row with a Role field. Join tables also make counting easy, because each link is a row you can filter and aggregate.

How do you model pipeline stages?

Stage is a single select on the deal with a fixed, ordered list: Lead, Qualified, Proposal, Negotiation, Won, Lost. Order matters because boards, funnels and reports read left to right. Give each stage an exit rule someone can check ("Proposal: a priced proposal has been sent"), or stages turn into moods.

Keep the list short: four to six open stages plus Won and Lost is a sensible ceiling for a small team. If two stages never get different treatment (a different owner, next step or probability), merge them.

Overwriting the stage field throws away the pipeline's history. You know where each deal is now, but not when it got there, so you cannot answer "what share of proposals are won?" or "how long do deals sit in Negotiation?". A stage history table fixes that with one row each time a deal enters a stage: deal, stage, entered at. Conversion rates, average days per stage and the pipeline as it stood on any past date all come from that one table.

Activities or notes: what is the difference?

Activities are structured events: a type, a date, an owner and a link to a deal or contact. Because they are structured, they drive the useful lists: deals with no activity in 14 days, overdue follow-ups, calls logged per rep this week.

Notes are the free text that explains what happened. Store them on the activity they belong to (a note is an activity of type Note), not in one long Notes field on the contact. A single notes field has no dates, no author and no link to a deal, and two people editing it at once overwrite each other.

Which computed fields are worth adding?

Compute anything that can be derived. A number someone types by hand goes stale the day after they type it.

FieldOnComputed fromUsed for
Days in stageDealsToday minus Stage entered onSpotting stuck deals
Deal ageDealsToday minus the created dateCycle time of open deals
Last activity dateDeals, contactsLatest When among done activitiesLists of deals going cold
Next activity dateDealsEarliest When among open activitiesDeals with no next step
Open dealsCompaniesCount of deals not Won or LostAccount health
Weighted amountDealsAmount times the stage probabilityForecasts

What are the most common CRM data model mistakes?

  • Company name typed as text on each contact. "Acme", "ACME Inc" and "Acme Corp" become three companies in every report. Use a reference.
  • One big notes field. No dates, no author, no filtering, and edits that overwrite each other.
  • Losing stage history. Overwriting the stage is fine for the board and fatal for reporting. Log stage changes from day one; you cannot reconstruct them later.
  • Duplicate contacts. Without a unique email, imports and quick adds create the same person twice and activities split between the copies. Make email unique and compare it case-insensitively.
  • Too many statuses. Twelve stages nobody agrees on, or a Stage, a Status and a Phase on the same deal. Use one ordered stage field and a Lost reason.
  • Owners stored as names. "Dana" in a text field breaks the day a second Dana joins. Reference the Users table.

A Postgres schema for a simple CRM

The DDL below creates the core tables with primary keys, foreign keys, check constraints for stage and activity type, case-insensitive unique emails, triggers that keep the stage history, and a view with the computed fields. It runs on PostgreSQL 11 or later.

-- PostgreSQL 11 or later
create table users (
  id         bigint generated always as identity primary key,
  full_name  text not null,
  email      text not null,
  active     boolean not null default true
);
create unique index users_email_key on users (lower(email));

create table companies (
  id          bigint generated always as identity primary key,
  name        text not null,
  domain      text,
  industry    text,
  size_band   text check (size_band in ('1-10', '11-50', '51-200', '201-1000', '1000+')),
  owner_id    bigint references users (id) on delete set null,
  created_at  timestamptz not null default now()
);
create unique index companies_domain_key on companies (lower(domain));
create index companies_owner_id_idx on companies (owner_id);

create table contacts (
  id          bigint generated always as identity primary key,
  company_id  bigint references companies (id) on delete set null,
  full_name   text not null,
  email       text,
  phone       text,
  job_title   text,
  status      text not null default 'Active'
              check (status in ('Active', 'Left company')),
  created_at  timestamptz not null default now()
);
create unique index contacts_email_key on contacts (lower(email));
create index contacts_company_id_idx on contacts (company_id);

create table deals (
  id                bigint generated always as identity primary key,
  name              text not null,
  company_id        bigint not null references companies (id) on delete restrict,
  owner_id          bigint references users (id) on delete set null,
  stage             text not null default 'Lead'
                    check (stage in ('Lead', 'Qualified', 'Proposal', 'Negotiation', 'Won', 'Lost')),
  amount            numeric(12, 2) check (amount >= 0),
  expected_close    date,
  stage_entered_at  timestamptz not null default now(),
  lost_reason       text,
  created_at        timestamptz not null default now(),
  check (stage <> 'Lost' or lost_reason is not null)
);
create index deals_company_id_idx on deals (company_id);
create index deals_owner_id_idx on deals (owner_id);

create table deal_contacts (
  deal_id     bigint not null references deals (id) on delete cascade,
  contact_id  bigint not null references contacts (id) on delete cascade,
  role        text check (role in ('Decision maker', 'Champion', 'Influencer', 'Signer', 'User')),
  primary key (deal_id, contact_id)
);
create index deal_contacts_contact_id_idx on deal_contacts (contact_id);

create table activities (
  id          bigint generated always as identity primary key,
  type        text not null check (type in ('Call', 'Email', 'Meeting', 'Task', 'Note')),
  subject     text not null,
  deal_id     bigint references deals (id) on delete cascade,
  contact_id  bigint references contacts (id) on delete cascade,
  owner_id    bigint references users (id) on delete set null,
  happens_at  timestamptz not null default now(),
  done        boolean not null default false,
  notes       text,
  check (deal_id is not null or contact_id is not null)
);
create index activities_deal_id_idx on activities (deal_id, happens_at);
create index activities_contact_id_idx on activities (contact_id, happens_at);

create table deal_stage_history (
  id          bigint generated always as identity primary key,
  deal_id     bigint not null references deals (id) on delete cascade,
  stage       text not null,
  entered_at  timestamptz not null
);
create index deal_stage_history_deal_idx on deal_stage_history (deal_id, entered_at);

-- Stamp the moment a deal enters a new stage.
create function deals_stamp_stage() returns trigger
language plpgsql as $$
begin
  if new.stage is distinct from old.stage then
    new.stage_entered_at := now();
  end if;
  return new;
end;
$$;

create trigger deals_stamp_stage
  before update of stage on deals
  for each row execute function deals_stamp_stage();

-- Log every stage a deal enters, the first one included.
create function deals_log_stage() returns trigger
language plpgsql as $$
begin
  if tg_op = 'UPDATE' then
    if new.stage is not distinct from old.stage then
      return null;
    end if;
  end if;
  insert into deal_stage_history (deal_id, stage, entered_at)
  values (new.id, new.stage, new.stage_entered_at);
  return null;
end;
$$;

create trigger deals_log_stage
  after insert or update of stage on deals
  for each row execute function deals_log_stage();

-- Computed fields, read through a view.
create view deal_overview as
select d.*,
       current_date - d.stage_entered_at::date as days_in_stage,
       current_date - d.created_at::date as deal_age_days,
       (select max(a.happens_at)
          from activities a
         where a.deal_id = d.id and a.done) as last_activity_at
from deals d;
Core CRM tables, stage history triggers and a view of computed fields
  • A check constraint, not an enum, for stage. A Postgres enum sorts in declaration order, which is handy, but existing values cannot be removed or reordered without dropping and recreating the type. A check constraint changes with one ALTER TABLE. If stages change often, a lookup table with a position column is more flexible still.
  • Indexes on foreign keys. Postgres does not create an index on the referencing column when you declare a foreign key, so the schema adds one for each column the app filters or joins on.
  • Two triggers. The BEFORE trigger stamps stage_entered_at when the stage changes, and the AFTER trigger writes the history row. A trigger declared with UPDATE OF stage fires whenever stage appears in the SET list, even when the value is unchanged, so the function compares old and new before logging.
  • Restrict, not cascade, on deals. Deleting a company that has deals fails instead of quietly removing pipeline history.

How to build this CRM in Emberblocks

Every table above maps to Emberblocks field types, which include text, email, phone, number, date, date-time, boolean, select, reference and multi-reference. You can create the tables in the built-in database, or connect a Postgres database that already has this schema and build on its tables directly; a table accepts edits once writes are enabled for it and it has a primary key. Because the stage triggers live in the database, they run whichever app writes the deal. Putting a front end on Postgres covers that route in detail.

The pipeline is a kanban block on Deals with Stage as the lane field. Dragging a card writes the new stage, and only for roles allowed to update that field. Point Card age at Stage entered on and each card shows its days in stage ("12d"), set a WIP limit on lanes that tend to overflow, and draw a progress bar from a 0 to 100 probability field.

On the built-in database, turn on activity tracking: it records every change with the old and new value, and an activity block limited to the Stage field shows a deal's stage changes on its record page. A drag on the board writes only the stage, so if you rely on Stage entered on there, move deals with buttons whose Set value action writes both fields.

Formulas follow the model. [Owner].[Email] = USEREMAIL() gives each rep a My deals view, [Company].[Industry] follows a reference, and COUNT and SUM roll up a child table, so a company can count its deals and total their value. On a deal's record page, a timeline of activities filtered with [Deal] = RECORDID() and a stat tile showing the max of their dates gives the last activity date. The expressions guide lists every function.

One limit to know: multi-reference fields render as chips and are picked from the target table, but formulas cannot read them yet. That is one more reason to model deal contacts as a join table with two reference fields, which COUNT can roll up and filters can narrow.

To weigh building your own CRM against buying one, see the no-code CRM builder page. Emberblocks is the wrong fit if you need inbox sync, email sequences or built-in calling; a dedicated sales CRM does those well. For the data side of moving off a spreadsheet first, turning a spreadsheet into an app walks through the cleanup.

Questions people ask

What tables should a CRM database have?
Users, Companies, Contacts, Deals and Activities cover a simple CRM. Add a Deal contacts join table when several people matter on one deal, and a Stage history table when you need conversion and cycle-time reports.
Should contacts be linked to deals with a join table?
Use a join table when the link has its own data, such as the contact's role on that deal. A multi-reference field is enough when you only need to know who is involved.
Should I use an enum or a check constraint for deal stages in Postgres?
Either works. An enum sorts in declaration order, but existing values cannot be removed or reordered without recreating the type. A check constraint is easier to change, and a lookup table with a position column is the most flexible.
How do I prevent duplicate contacts in a CRM?
Make email unique and compare it case-insensitively, for example with a unique index on lower(email) in Postgres. For companies, use the website domain as the unique key, since names get spelled many ways.
Can I build my own CRM without code?
Yes. The model is five tables and a handful of references, which a no-code builder handles. You need reference fields, a single select for stages, a kanban board for the pipeline and per-user filters for My deals.

Sources

  1. PostgreSQL documentation: Constraints (foreign keys and indexes)
  2. PostgreSQL documentation: Enumerated types
  3. PostgreSQL documentation: CREATE TRIGGER

Written by

Apostolos builds Emberblocks, a visual app builder for internal tools on your own data, from Thessaloniki, Greece. Spotted something wrong or out of date? Write to hello@emberblocks.com.

Keep reading

Ready to build on your own data?

Connect Postgres, Airtable or a spreadsheet export and publish a working app your team can use today. Builders are always free.