29.3 Database Design
Table of Contents
Understanding the Database for a Task Management API
In this chapter you will design the database for a simple Task Management API. You do not need to be a SQL expert yet. The goal here is to understand what data you need, how it relates, and how to model it so that later chapters (CRUD, validation, etc.) are straightforward.
We will work with a relational database such as PostgreSQL, but the ideas apply to other systems too.
Requirements That Affect the Database
Before you design tables, you must understand what your application needs to store.
For a basic Task Management API, typical requirements are:
- Users can register and log in.
- Each user manages their own tasks.
- A task has:
- A title and optional description.
- A status, for example: todo, in_progress, done.
- A priority, for example: low, medium, high.
- Due date (optional).
- Timestamps for when it was created and last updated.
- Users can organize tasks into projects or categories (optional but common).
- Tasks can have tags like "work", "personal", "urgent".
- You may want soft deletes, so you do not lose data permanently.
Even if the project requirements chapter does not mention all of these explicitly, it is useful to design for at least:
- Users
- Tasks
- Projects or lists (optional but instructive)
- Tags (nice for showing many to many relations)
You can always start smaller, then extend.
Choosing Main Entities
An entity is a type of thing that your application stores, such as a User or a Task.
For this project, reasonable entities are:
UserTaskProject(orList/Boardetc.)Tag- Join tables for relationships (for example,
task_tags)
We will focus on a clean, extensible design rather than an absolutely minimal one, because you are learning patterns used in real systems.
Designing the `users` Table
The users table stores basic account data.
A simple structure:
| Column | Type | Description |
|---|---|---|
| id | bigint (PK) | Unique identifier for the user |
| varchar, unique | Login and contact email | |
| password_hash | varchar | Hashed password (never store raw) |
| full_name | varchar, nullable | Optional display name |
| is_active | boolean | Whether the account is active |
| created_at | timestamp | When the user was created |
| updated_at | timestamp | Last update time |
Even though the main “Authentication” chapters come later, you need password_hash now so that you can link tasks to users.
Important rule: Never store plain text passwords in the database. Always store a secure password hash.
Typical primary key pattern:
idas a numeric, auto-increment or sequence-based primary key.
Example simplified SQL:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Designing the `projects` Table
A project groups tasks. Each project belongs to one user.
| Column | Type | Description |
|---|---|---|
| id | bigint (PK) | Unique project ID |
| owner_id | bigint (FK) | References users.id |
| name | varchar | Project name |
| description | text, nullable | Optional description |
| is_archived | boolean | If the project is archived |
| created_at | timestamp | Created time |
| updated_at | timestamp | Last update time |
The foreign key owner_id connects a project to its owner.
Example SQL:
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
description TEXT,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);This structure lets you later implement endpoints like:
GET /projectsto list a user’s projects.GET /projects/{project_id}/tasksto list tasks in a project.
Designing the `tasks` Table
Tasks are the core of the application. Each task belongs to exactly 1 user, and optionally to a project.
Choosing columns
Typical columns:
| Column | Type | Description |
|---|---|---|
| id | bigint (PK) | Unique task ID |
| owner_id | bigint (FK) | Owner user, references users.id |
| project_id | bigint (FK, nullable) | References projects.id, can be null |
| title | varchar | Short summary of the task |
| description | text, nullable | Details |
| status | varchar | Task state, for example todo |
| priority | smallint or varchar | Priority level |
| due_date | date, nullable | When the task is due |
| completed_at | timestamp, nullable | When it was finished |
| is_deleted | boolean | For soft delete |
| created_at | timestamp | Created time |
| updated_at | timestamp | Last updated time |
You could also use an enum type for status and priority, but for a first project, a simple text or smallint works fine.
Design tip: Always include created_at and updated_at on important tables. They help debugging, analytics, and audits.
Example SQL:
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
project_id BIGINT REFERENCES projects(id),
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'todo',
priority SMALLINT NOT NULL DEFAULT 0,
due_date DATE,
completed_at TIMESTAMPTZ,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);A simple priority scheme could be:
| priority value | Meaning |
|---|---|
| 0 | low |
| 1 | medium |
| 2 | high |
You can map numbers to labels in your application code.
Designing the `tags` and `task_tags` Tables
Tags are a good example of a many to many relationship. One task can have many tags, and one tag can belong to many tasks.
`tags` table
| Column | Type | Description |
|---|---|---|
| id | bigint (PK) | Unique tag ID |
| owner_id | bigint (FK) | Tag owner, references users.id |
| name | varchar | Example: "work", "urgent" |
| created_at | timestamp | Created time |
| updated_at | timestamp | Last updated |
You might want each user to have their own namespace of tags, so two users can both have a "work" tag without conflict.
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_id, name)
);
The UNIQUE (owner_id, name) constraint prevents a single user from creating the same tag twice.
`task_tags` join table
This table connects tasks and tags.
| Column | Type | Description |
|---|---|---|
| task_id | bigint (FK) | References tasks.id |
| tag_id | bigint (FK) | References tags.id |
You normally use a composite primary key:
CREATE TABLE task_tags (
task_id BIGINT NOT NULL REFERENCES tasks(id),
tag_id BIGINT NOT NULL REFERENCES tags(id),
PRIMARY KEY (task_id, tag_id)
);This ensures that the same tag is not linked twice to the same task.
Relationships Summary
So far you have these relationships:
| From table | Column | To table | Relationship type |
|---|---|---|---|
| projects | owner_id | users | Many projects per user |
| tasks | owner_id | users | Many tasks per user |
| tasks | project_id | projects | Many tasks per project |
| tags | owner_id | users | Many tags per user |
| task_tags | task_id | tasks | Many tags per task |
| task_tags | tag_id | tags | Many tasks per tag |
In more familiar names:
User1 to manyProjectUser1 to manyTaskProject1 to manyTaskUser1 to manyTagTaskmany to manyTag
This structure is enough for a realistic Task Management API.
Example: How API Endpoints Map to the Schema
Here are some typical endpoints and how they use the database design.
Create a task
Endpoint:
POST /tasks
Content-Type: application/json
{
"title": "Buy groceries",
"description": "Milk, eggs, bread",
"project_id": 3,
"priority": 1,
"due_date": "2026-09-01",
"tags": ["personal", "errand"]
}What happens in the database:
- Insert into
tasks:
INSERT INTO tasks (owner_id, project_id, title, description, priority, due_date)
VALUES (:user_id, 3, 'Buy groceries', 'Milk, eggs, bread', 1, '2026-09-01');- For each tag name:
- Check if that tag exists for the user in
tags. If not, insert it. - Insert into
task_tagswith the new task id and tag id.
List tasks in a project
Endpoint:
GET /projects/3/tasksQuery:
SELECT *
FROM tasks
WHERE project_id = 3
AND owner_id = :user_id
AND is_deleted = FALSE
ORDER BY created_at DESC;Mark a task as completed
Endpoint:
PATCH /tasks/42
Content-Type: application/json
{
"status": "done"
}Update:
UPDATE tasks
SET status = 'done',
completed_at = NOW(),
updated_at = NOW()
WHERE id = 42
AND owner_id = :user_id
AND is_deleted = FALSE;Note how the schema makes these operations easy to express.
Soft Deletes vs Hard Deletes
Often you do not want to remove tasks permanently. Instead you mark them as deleted.
We already added is_deleted to tasks. The idea:
- To "delete" a task, set
is_deleted = TRUE. - Normal queries should filter
WHERE is_deleted = FALSE.
Example soft delete:
UPDATE tasks
SET is_deleted = TRUE,
updated_at = NOW()
WHERE id = 42
AND owner_id = :user_id;
If you truly need to remove all traces, you can later run a background job to hard delete tasks where is_deleted = TRUE and older than some time.
Rule: Never forget to filter out soft deleted rows in normal queries, or deleted data will reappear in your API responses.
Indexes for Performance
Indexes help queries run faster, especially when you filter or sort by specific columns.
For this project, common useful indexes are:
- Tasks by owner and project:
CREATE INDEX idx_tasks_owner_project
ON tasks (owner_id, project_id);- Tasks by owner and status:
CREATE INDEX idx_tasks_owner_status
ON tasks (owner_id, status);- Tags by owner and name (we already have them as a unique constraint):
CREATE UNIQUE INDEX idx_tags_owner_name
ON tags (owner_id, name);You can add more indexes later when you measure performance, but these are a good starting point.
Normalization Basics in This Design
Without going deep into the dedicated "Normalization" chapter, you can already see some normalization ideas here:
- You avoid repeating project data in each task. Instead you reference a
project_id. - You avoid repeating tag names for each task, you have a separate
tagstable and a join table.
This keeps data consistent, and changes in one place, for example renaming a tag, are reflected everywhere.
Example Final Schema Overview
Here is a simplified overview of the core tables:
-- Users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Projects
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
description TEXT,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tasks
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
project_id BIGINT REFERENCES projects(id),
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'todo',
priority SMALLINT NOT NULL DEFAULT 0,
due_date DATE,
completed_at TIMESTAMPTZ,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tags
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_id, name)
);
-- Many to many: tasks <-> tags
CREATE TABLE task_tags (
task_id BIGINT NOT NULL REFERENCES tasks(id),
tag_id BIGINT NOT NULL REFERENCES tags(id),
PRIMARY KEY (task_id, tag_id)
);This design is:
- Simple enough for a first project.
- Rich enough to cover realistic CRUD operations, filtering, and relationships.
- A good base for the later chapters on CRUD, validation, testing, and documentation.
In the next chapters, you will use this schema to implement operations like creating tasks, listing tasks by project, and managing tags through your API.
Views: 6
KAHIBARO