CRM Database ER Diagram

A CRM revolves around companies, the contacts that work there, and the deals you negotiate with them. Every interaction — calls, emails, meetings — is an activity linked to a contact or a deal, which is what makes the pipeline history searchable later.

The interesting decision is on contacts.company_id: ON DELETE SET NULL. People outlive the companies they work for — when a company record is removed, its contacts stay, each with a null employer instead of vanishing with the account. Compare that with activities.contact_id, which cascades: deleting a contact (a privacy request, a duplicate) takes the interaction history with it, since an activity about nobody is noise. The same contact appears in both rules because the answer to what should survive genuinely differs per relationship.

deals carries two optional foreign keys, company_id and contact_id, because a deal often starts as a company-level opportunity before a named contact is identified — the schema tolerates that vagueness rather than forcing a placeholder row. owner_id is NOT NULL and points at users, the internal sales reps: every deal has exactly one accountable person. In the diagram this is a separate edge from users down to deals, distinct from the edges companies and contacts send, so pipeline position and ownership stay readable independently.

activities is the table that makes a CRM useful six months in. It references both a contact and a deal, either of which can be null, so one row records a call that touched a deal, a contact, or both — a small compromise compared to separate call, email and meeting tables, paid for by the type column. notes mirrors the same shape for longer text. stage is a VARCHAR defaulting to 'lead': the pipeline's stage order lives in application configuration, which is fine until someone wants per-team pipelines — at that point the column becomes a foreign key.

The first extension is nearly always deal_stage_history (deal_id, from_stage, to_stage, changed_at): with only a current stage column you can report on the pipeline as it is, but not on how long deals sat in each stage or where they stall. Configurable per-team pipelines come next — a pipelines table plus stages, with deals pointing at a stage id instead of free text. Teams that log email heavily split a dedicated emails table out of activities, with threading columns, keeping the generic log for calls and meetings.

The diagram

1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N companies PKid integer name varchar(150) website varchar(200) industry varchar(80) created_at timestamp contacts PKid integer FKcompany_id integer first_name varchar(60) last_name varchar(60) Uemail varchar(255) phone varchar(30) users PKid integer Uemail varchar(255) full_name varchar(120) deals PKid integer FKcompany_id integer FKcontact_id integer FKowner_id integer title varchar(150) amount decimal(12,2) stage varchar(30) expected_close date created_at timestamp activities PKid integer FKcontact_id integer FKdeal_id integer FKuser_id integer type varchar(20) summary varchar(255) occurred_at timestamp notes PKid integer FKcompany_id integer FKcontact_id integer FKuser_id integer body text created_at timestamp tasks PKid integer FKdeal_id integer FKassignee_id integer title varchar(200) due_date date done boolean

Tables (7)

companies

  • idintegerPK
  • namevarchar(150)
  • websitevarchar(200)
  • industryvarchar(80)
  • created_attimestamp

contacts

  • idintegerPK
  • company_idintegerFK
  • first_namevarchar(60)
  • last_namevarchar(60)
  • emailvarchar(255)U
  • phonevarchar(30)

users

  • idintegerPK
  • emailvarchar(255)U
  • full_namevarchar(120)

deals

  • idintegerPK
  • company_idintegerFK
  • contact_idintegerFK
  • owner_idintegerFK
  • titlevarchar(150)
  • amountdecimal(12,2)
  • stagevarchar(30)
  • expected_closedate
  • created_attimestamp

activities

  • idintegerPK
  • contact_idintegerFK
  • deal_idintegerFK
  • user_idintegerFK
  • typevarchar(20)
  • summaryvarchar(255)
  • occurred_attimestamp

notes

  • idintegerPK
  • company_idintegerFK
  • contact_idintegerFK
  • user_idintegerFK
  • bodytext
  • created_attimestamp

tasks

  • idintegerPK
  • deal_idintegerFK
  • assignee_idintegerFK
  • titlevarchar(200)
  • due_datedate
  • doneboolean

Relationships (12)

  • 1:Ncompanies.id→contacts.company_idON DELETE SET NULL
  • 1:Ncompanies.id→deals.company_id
  • 1:Ncontacts.id→deals.contact_id
  • 1:Nusers.id→deals.owner_id
  • 1:Ncontacts.id→activities.contact_idON DELETE CASCADE
  • 1:Ndeals.id→activities.deal_idON DELETE CASCADE
  • 1:Nusers.id→activities.user_id
  • 1:Ncompanies.id→notes.company_idON DELETE CASCADE
  • 1:Ncontacts.id→notes.contact_idON DELETE CASCADE
  • 1:Nusers.id→notes.user_id
  • 1:Ndeals.id→tasks.deal_idON DELETE CASCADE
  • 1:Nusers.id→tasks.assignee_id

The SQL

Copy this, paste it in the SQL tab, or just open it below.
SQL
CREATE TABLE companies (
  id SERIAL PRIMARY KEY,
  name VARCHAR(150) NOT NULL,
  website VARCHAR(200),
  industry VARCHAR(80),
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE contacts (
  id SERIAL PRIMARY KEY,
  company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
  first_name VARCHAR(60) NOT NULL,
  last_name VARCHAR(60) NOT NULL,
  email VARCHAR(255) UNIQUE,
  phone VARCHAR(30)
);

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  full_name VARCHAR(120) NOT NULL
);

CREATE TABLE deals (
  id SERIAL PRIMARY KEY,
  company_id INTEGER REFERENCES companies(id),
  contact_id INTEGER REFERENCES contacts(id),
  owner_id INTEGER NOT NULL REFERENCES users(id),
  title VARCHAR(150) NOT NULL,
  amount DECIMAL(12,2),
  stage VARCHAR(30) NOT NULL DEFAULT 'lead',
  expected_close DATE,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE activities (
  id SERIAL PRIMARY KEY,
  contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
  deal_id INTEGER REFERENCES deals(id) ON DELETE CASCADE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  type VARCHAR(20) NOT NULL,
  summary VARCHAR(255) NOT NULL,
  occurred_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE notes (
  id SERIAL PRIMARY KEY,
  company_id INTEGER REFERENCES companies(id) ON DELETE CASCADE,
  contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  body TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE tasks (
  id SERIAL PRIMARY KEY,
  deal_id INTEGER REFERENCES deals(id) ON DELETE CASCADE,
  assignee_id INTEGER NOT NULL REFERENCES users(id),
  title VARCHAR(200) NOT NULL,
  due_date DATE,
  done BOOLEAN NOT NULL DEFAULT false
);

Keep learning