Browse Templates/Project Management

Project Management

A comprehensive project management database schema for building tools like Asana, Linear, or Jira. Features workspace-based multi-tenancy, project organization with customizable statuses, hierarchical task management with priority and due dates, threaded task comments for collaboration, and team member roles. Supports agile workflows with sprint-ready task tracking and team workload management.

PostgreSQL5 tablesSaaS
project-managementtasksagileteamscollaborationworkspace
Created with ERD Studio

About this schema

Everything is scoped to a workspace, and the scope is enforced one level up rather than repeated everywhere. members and projects carry workspace_id; tasks and task_comments do not, because they reach it through their project. That keeps the boundary in one place, at the cost of a join whenever you need to prove a task belongs to the workspace someone is signed in to.

People are members, not users. There is no separate account table here — members holds the email, the name and the role directly, scoped to one workspace. The same person working in two workspaces is two rows, which means a role is always workspace-local and there is no global permission to accidentally leak across tenants.

Subtasks are the same table pointing at itself. tasks.parent_task_id makes a checklist item and a top-level task structurally identical, so a subtask can be assigned, dated and prioritised like anything else — and a task can be promoted out of its parent by clearing one column instead of being moved between tables.

Key relationships

workspaces → projects → tasks
Three levels of containment. A task's workspace is derived through its project, so moving a project moves everything under it.
tasks → tasks (parent_task_id)
Self-reference for subtasks. A null parent means top-level; promoting a subtask is clearing the column, not a data migration.
tasks → members (assignee_id)
Nullable, because an unassigned task is a normal state in a backlog — not an error to be filled in with a placeholder member.
projects → members (lead_member_id)
One named owner per project, pointing at a member of the same workspace. It is a separate concept from assignment on tasks.
task_comments → tasks / members
The comment names both the task it is on and the member who wrote it, so a thread survives the author changing their display name.

Design decisions

Subtasks reuse tasks instead of getting their own table

A separate subtasks table would be simpler to read and would immediately be wrong: teams nest two or three levels, and a subtask that turns out to be real work has to become a task. The self-reference makes both free. What it costs is that depth is unbounded — the application has to stop runaway nesting, because the schema will happily allow a chain a hundred long.

labels is a column, not a join table

Labels here are free text on the task rather than rows in a labels table. For a tool where labels are per-task tags and are read far more often than they are aggregated, that avoids a join on the most common query. The trade is real: renaming a label everywhere becomes a scan and update, and there is nothing stopping two spellings of the same tag.

sort_order on tasks, not an ordering table

Manual ordering inside a column or list needs a per-task number, and putting it on the row keeps a board render to one sorted read. The known cost is reordering — dropping a task between two others needs a value between them, so the numbers are spaced and periodically renumbered rather than being consecutive integers.

members replaces a global users table

Modelling a platform-wide account and a membership row is the correct shape for a product with cross-workspace identity. This schema deliberately does not: one row per person per workspace means role and avatar are always local, and a query can never accidentally return someone from another tenant. If single sign-on across workspaces is later required, this is the decision that has to be revisited.

Tables in this template

workspacesWorkspaces
5 cols
membersMembers
7 cols
projectsProjects
9 cols
tasksTasks
13 cols
task_commentsTask Comments
7 cols

Frequently asked

Why do tasks not carry workspace_id directly?
Because it would be a second copy of a fact the project already states, and copies drift — moving a project would leave its tasks claiming the old workspace. The join is the price of having one source of truth.
How deep can subtasks nest?
The schema puts no limit on it, which is why the application must. Most tools stop at two or three levels; without that check a cycle or a very long chain is possible, since parent_task_id is just another task id.
Can the same person be in two workspaces?
Yes, as two members rows with the same email. That is the intended shape here — it keeps roles workspace-local, but it does mean there is no single row representing that person across the product.
Where do attachments live?
task_comments carries an attachments column, so files arrive with a comment rather than being attached to the task directly. If files need their own lifecycle, that is the point to split them into a table of their own.
Why is assignee_id nullable?
Because most tasks are created before anyone picks them up. Forcing an assignee would mean inventing an unassigned member row, and every report would then have to filter that fake person out.