Project Management ER Diagram

Project management tools are built on projects, the members allowed in them, and tasks that can be grouped into sprints and tagged with labels. Membership is a many-to-many with a role; labels attach to tasks through a second join table.

Tenancy is a three-level chain: organizations own projects, projects own everything else, and project_members controls who sees what. The membership join table carries role, so admin-of-project-A and member-of-project-B is two rows rather than a column on users. Labels are scoped to project_id — unlike a global tag list, each project defines its own vocabulary with its own colors, which keeps a red urgent label in one project from meaning anything in another. task_labels is the join that attaches them to tasks.

tasks.sprint_id is nullable, and that null is the backlog: a task belongs to a project from the start but only joins a sprint when planning assigns it. assignee_id is nullable for the same reason — unassigned work exists. Sprints are optional per project, since a maintenance project may never use them, while comments anchor to tasks so discussion follows the work item. story_points and due_date are both nullable because neither convention applies to every team; the schema stays permissive where practices vary.

Cascade rules follow ownership: deleting an organization wipes projects, members, sprints, tasks, labels and comments in one go, and deleting a task removes its label join rows and comments. The edges without rules are the ones that would destroy live data: tasks.sprint_id has no ON DELETE, so a sprint with tasks cannot be deleted until they are moved or unsprinted. users referenced by assignee_id and comments.author_id are equally protected — offboarding a teammate means unassigning and reassigning, not deleting the row.

The first extension is task_history (task_id, field, old_value, new_value, changed_by, changed_at): status transitions and reassignments are exactly what managers ask about later, and none of it survives in the current columns. Epics come next — either a parent_task_id self-reference on tasks or a separate epics table, depending on whether an epic is a large task or its own entity. A task_watchers join table, mirroring task_labels, covers notification subscriptions, which this schema currently has no place to express.

The diagram

1:N 1:1 1:1 1:N 1:N 1:N 1:N 1:N 1:N 1:N N:M organizations PKid integer name varchar(120) created_at timestamp users PKid integer Uemail varchar(255) full_name varchar(120) projects PKid integer FKorganization_id integer name varchar(120) description text created_at timestamp project_members PKproject_id integer PKuser_id integer role varchar(20) sprints PKid integer FKproject_id integer name varchar(80) starts_on date ends_on date tasks PKid integer FKproject_id integer FKsprint_id integer FKassignee_id integer title varchar(200) status varchar(20) story_points integer due_date date labels PKid integer FKproject_id integer label varchar(40) color varchar(9) task_labels PKtask_id integer PKlabel_id integer comments PKid integer FKtask_id integer FKauthor_id integer body text created_at timestamp

Tables (9)

organizations

  • idintegerPK
  • namevarchar(120)
  • created_attimestamp

users

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

projects

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

project_members

  • project_idintegerPK
  • user_idintegerPK
  • rolevarchar(20)

sprints

  • idintegerPK
  • project_idintegerFK
  • namevarchar(80)
  • starts_ondate
  • ends_ondate

tasks

  • idintegerPK
  • project_idintegerFK
  • sprint_idintegerFK
  • assignee_idintegerFK
  • titlevarchar(200)
  • statusvarchar(20)
  • story_pointsinteger
  • due_datedate

labels

  • idintegerPK
  • project_idintegerFK
  • labelvarchar(40)
  • colorvarchar(9)

task_labels

  • task_idintegerPK
  • label_idintegerPK

comments

  • idintegerPK
  • task_idintegerFK
  • author_idintegerFK
  • bodytext
  • created_attimestamp

Relationships (11)

  • 1:Norganizations.id→projects.organization_idON DELETE CASCADE
  • 1:1projects.id→project_members.project_idON DELETE CASCADE
  • 1:1users.id→project_members.user_idON DELETE CASCADE
  • 1:Nprojects.id→sprints.project_idON DELETE CASCADE
  • 1:Nprojects.id→tasks.project_idON DELETE CASCADE
  • 1:Nsprints.id→tasks.sprint_id
  • 1:Nusers.id→tasks.assignee_id
  • 1:Nprojects.id→labels.project_idON DELETE CASCADE
  • 1:Ntasks.id→comments.task_idON DELETE CASCADE
  • 1:Nusers.id→comments.author_id
  • N:Mtasks.id→labels.id

The SQL

Copy this, paste it in the SQL tab, or just open it below.
SQL
CREATE TABLE organizations (
  id SERIAL PRIMARY KEY,
  name VARCHAR(120) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT now()
);

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

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

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

CREATE TABLE sprints (
  id SERIAL PRIMARY KEY,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name VARCHAR(80) NOT NULL,
  starts_on DATE,
  ends_on DATE
);

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

CREATE TABLE labels (
  id SERIAL PRIMARY KEY,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  label VARCHAR(40) NOT NULL,
  color VARCHAR(9) DEFAULT '#10b981'
);

CREATE TABLE task_labels (
  task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
  PRIMARY KEY (task_id, label_id)
);

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