SaaS Database ER Diagram

A SaaS product almost always starts with the same skeleton: users, workspaces (teams), membership with roles, projects, tasks, and billing through subscriptions and invoices. Workspace membership is a classic many-to-many between users and workspaces, modeled here with an explicit role column on the join table.

This schema draws the billing boundary around the workspace, not the user. workspace_members is a join table between users and workspaces, and its composite primary key (workspace_id, user_id) does two things: it makes the pair unique, and it carries role as an attribute of the membership itself — being an admin is a property of you-in-that-workspace, not of you. workspaces.owner_id is a deliberate denormalization: the founder's identity is needed even before any membership row exists, and it survives role changes on the members list.

The billing side reads as a vertical chain. subscriptions.workspace_id is UNIQUE, so a workspace has exactly one subscription, and plan changes are updates to that single row rather than inserts. invoices hang below with a plain one-to-many — one subscription, many invoices over time. On the product side, projects and tasks form a deep one-to-many chain, and tasks.assignee_id is nullable: a task can sit in the backlog with nobody on it. comments attach to tasks rather than projects, which keeps the discussion anchored to work items.

Delete behavior follows the money. Projects cascade to tasks and tasks to comments, so removing a project is one statement. But subscriptions references workspaces with no rule: a workspace that has ever been billed cannot simply be dropped, which is intentional — subscription and invoice rows are financial records that outlive the product data. The same logic protects users: tasks.assignee_id and comments.author_id reference users without any ON DELETE rule, so deleting a user who authored or owns anything fails. Production SaaS products usually soft-delete accounts instead.

What is missing first is invitations: workspace_members can only reference users who already exist, so onboarding a teammate needs an invitations table (workspace, email, token, expiry, intended role) that converts into a membership row on acceptance. The second gap is per-workspace history — an audit_log table keyed by workspace records who changed what, which matters the moment workspaces are shared. And if plans carry seat or feature limits, a plans table referenced by subscriptions.plan (currently a free-text VARCHAR) takes hardcoded plan names out of application code.

The diagram

1:N 1:1 1:1 1:1 1:N 1:N 1:N 1:N 1:N 1:N users PKid integer Uemail varchar(255) full_name varchar(120) password_hash varchar(255) created_at timestamp workspaces PKid integer name varchar(120) FKowner_id integer created_at timestamp workspace_members PKworkspace_id integer PKuser_id integer role varchar(20) subscriptions PKid integer FKworkspace_id integer plan varchar(30) status varchar(20) current_period_end timestamp invoices PKid integer FKsubscription_id integer amount decimal(10,2) status varchar(20) issued_at timestamp projects PKid integer FKworkspace_id integer name varchar(120) description text created_at timestamp tasks PKid integer FKproject_id integer FKassignee_id integer title varchar(200) status varchar(20) due_date date created_at timestamp comments PKid integer FKtask_id integer FKauthor_id integer body text created_at timestamp

Tables (8)

users

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

workspaces

  • idintegerPK
  • namevarchar(120)
  • owner_idintegerFK
  • created_attimestamp

workspace_members

  • workspace_idintegerPK
  • user_idintegerPK
  • rolevarchar(20)

subscriptions

  • idintegerPK
  • workspace_idintegerFKU
  • planvarchar(30)
  • statusvarchar(20)
  • current_period_endtimestamp

invoices

  • idintegerPK
  • subscription_idintegerFK
  • amountdecimal(10,2)
  • statusvarchar(20)
  • issued_attimestamp

projects

  • idintegerPK
  • workspace_idintegerFK
  • namevarchar(120)
  • descriptiontext
  • created_attimestamp

tasks

  • idintegerPK
  • project_idintegerFK
  • assignee_idintegerFK
  • titlevarchar(200)
  • statusvarchar(20)
  • due_datedate
  • created_attimestamp

comments

  • idintegerPK
  • task_idintegerFK
  • author_idintegerFK
  • bodytext
  • created_attimestamp

Relationships (10)

  • 1:Nusers.id→workspaces.owner_id
  • 1:1workspaces.id→workspace_members.workspace_idON DELETE CASCADE
  • 1:1users.id→workspace_members.user_idON DELETE CASCADE
  • 1:1workspaces.id→subscriptions.workspace_id
  • 1:Nsubscriptions.id→invoices.subscription_id
  • 1:Nworkspaces.id→projects.workspace_idON DELETE CASCADE
  • 1:Nprojects.id→tasks.project_idON DELETE CASCADE
  • 1:Nusers.id→tasks.assignee_id
  • 1:Ntasks.id→comments.task_idON DELETE CASCADE
  • 1:Nusers.id→comments.author_id

The SQL

Copy this, paste it in the SQL tab, or just open it below.
SQL
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  full_name VARCHAR(120) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE workspaces (
  id SERIAL PRIMARY KEY,
  name VARCHAR(120) NOT NULL,
  owner_id INTEGER NOT NULL REFERENCES users(id),
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE workspace_members (
  workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role VARCHAR(20) NOT NULL DEFAULT 'member',
  PRIMARY KEY (workspace_id, user_id)
);

CREATE TABLE subscriptions (
  id SERIAL PRIMARY KEY,
  workspace_id INTEGER NOT NULL UNIQUE REFERENCES workspaces(id),
  plan VARCHAR(30) NOT NULL DEFAULT 'free',
  status VARCHAR(20) NOT NULL DEFAULT 'active',
  current_period_end TIMESTAMP
);

CREATE TABLE invoices (
  id SERIAL PRIMARY KEY,
  subscription_id INTEGER NOT NULL REFERENCES subscriptions(id),
  amount DECIMAL(10,2) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'open',
  issued_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE projects (
  id SERIAL PRIMARY KEY,
  workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  name VARCHAR(120) NOT NULL,
  description TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE tasks (
  id SERIAL PRIMARY KEY,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  assignee_id INTEGER REFERENCES users(id),
  title VARCHAR(200) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'todo',
  due_date DATE,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE comments (
  id SERIAL PRIMARY KEY,
  task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  author_id INTEGER NOT NULL REFERENCES users(id),
  body TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

Keep learning