Module 8: Final Project — TaskFlow API
Task CRUD + cursor pagination + soft delete
You've reached the heart of TaskFlow. You'll create the Task model with tenant_id (RLS applied automatically), deleted_at for soft delete, and implement the CRUD endpoints with cursor pagination in GET /tasks. You'll also configure the partial index on deleted_at IS NULL, which is the key optimization for soft delete queries.
By the end, TaskFlow has its central entity operational with the first two patterns applied (cursor + soft delete).
Task model
# app/models/task.py
from datetime import datetime, timezone
import uuid
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id"),
nullable=False,
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id"),
nullable=False,
)
title: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(50), default="pending")
# Soft delete
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
# Partial index: only live rows, optimizes GET /tasks
Index(
"idx_tasks_alive_created",
"tenant_id",
"created_at",
"id",
postgresql_where="deleted_at IS NULL",
),
)
Add to app/models/__init__.py:
from app.models.task import Task
__all__ = ["Tenant", "User", "Project", "Task"]
Migration with RLS
alembic revision --autogenerate -m "add tasks with soft delete"
Edit it to add RLS:
# alembic/versions/XXX_add_tasks.py
def upgrade() -> None:
op.create_table(
'tasks',
# ... auto-generated ...
)
op.create_index(
'idx_tasks_alive_created',
'tasks',
['tenant_id', 'created_at', 'id'],
postgresql_where=text('deleted_at IS NULL'),
)
# Enable RLS
op.execute("ALTER TABLE tasks ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY tenant_isolation_tasks ON tasks
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
WITH CHECK (tenant_id::text = current_setting('app.tenant_id', TRUE))
""")
def downgrade() -> None:
op.execute("DROP POLICY tenant_isolation_tasks ON tasks")
op.execute("ALTER TABLE tasks DISABLE ROW LEVEL SECURITY")
op.drop_index('idx_tasks_alive_created')
op.drop_table('tasks')
Basic CRUD endpoints
# app/routers/tasks.py
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Response, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from pydantic import BaseModel, Field
import json
from base64 import urlsafe_b64encode, urlsafe_b64decode
from app.deps import get_db_with_tenant, get_current_tenant_id
from app.models import Task
router = APIRouter()
class TaskCreate(BaseModel):
project_id: uuid.UUID
title: str = Field(min_length=1, max_length=200)
status: str = "pending"
class TaskResponse(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
title: str
status: str
created_at: datetime
updated_at: datetime
class TaskListResponse(BaseModel):
items: list[TaskResponse]
next_cursor: Optional[str]
def encode_cursor(created_at: datetime, task_id: uuid.UUID) -> str:
data = {"created_at": created_at.isoformat(), "id": str(task_id)}
return urlsafe_b64encode(json.dumps(data).encode()).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(urlsafe_b64decode(cursor.encode()))
@router.get("/tasks", response_model=TaskListResponse)
async def list_tasks(
cursor: Optional[str] = None,
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db_with_tenant),
):
"""Cursor pagination with a composite cursor (created_at DESC, id DESC).
Filters automatically by tenant_id (RLS) and deleted_at IS NULL (soft delete).
Uses the partial index idx_tasks_alive_created.
"""
query = (
select(Task)
.where(Task.deleted_at.is_(None))
.order_by(Task.created_at.desc(), Task.id.desc())
.limit(page_size + 1) # +1 to detect more pages
)
if cursor:
try:
decoded = decode_cursor(cursor)
cursor_created_at = datetime.fromisoformat(decoded["created_at"])
cursor_id = uuid.UUID(decoded["id"])
except (ValueError, KeyError):
raise HTTPException(400, "Invalid cursor")
# WHERE (created_at, id) < (cursor_created_at, cursor_id) — ORDER BY DESC
query = query.where(
(Task.created_at < cursor_created_at) |
((Task.created_at == cursor_created_at) & (Task.id < cursor_id))
)
result = await db.execute(query)
tasks = result.scalars().all()
has_more = len(tasks) > page_size
if has_more:
tasks = tasks[:page_size]
next_cursor = None
if has_more and tasks:
last = tasks[-1]
next_cursor = encode_cursor(last.created_at, last.id)
return TaskListResponse(
items=[TaskResponse(
id=t.id,
project_id=t.project_id,
title=t.title,
status=t.status,
created_at=t.created_at,
updated_at=t.updated_at,
) for t in tasks],
next_cursor=next_cursor,
)
@router.post("/tasks", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
data: TaskCreate,
tenant_id: str = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db_with_tenant),
):
task = Task(
tenant_id=uuid.UUID(tenant_id),
project_id=data.project_id,
title=data.title,
status=data.status,
)
db.add(task)
await db.commit()
await db.refresh(task)
return TaskResponse(
id=task.id, project_id=task.project_id, title=task.title,
status=task.status, created_at=task.created_at, updated_at=task.updated_at,
)
@router.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: uuid.UUID,
db: AsyncSession = Depends(get_db_with_tenant),
):
task = await db.scalar(
select(Task).where(
Task.id == task_id,
Task.deleted_at.is_(None), # Don't show soft-deleted
)
)
if not task:
raise HTTPException(404, "Task not found")
return TaskResponse(
id=task.id, project_id=task.project_id, title=task.title,
status=task.status, created_at=task.created_at, updated_at=task.updated_at,
)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: uuid.UUID,
db: AsyncSession = Depends(get_db_with_tenant),
):
"""Soft delete: sets deleted_at."""
task = await db.scalar(
select(Task).where(
Task.id == task_id,
Task.deleted_at.is_(None),
)
)
if not task:
raise HTTPException(404, "Task not found")
task.deleted_at = datetime.now(timezone.utc)
await db.commit()
Include it:
# app/main.py
from app.routers import tasks
app.include_router(tasks.router)
Why the partial index matters
Without a partial index:
CREATE INDEX idx_tasks_created ON tasks(tenant_id, created_at, id);
It covers all rows, including the soft-deleted ones. If your table has 30% soft-deleted, the index has 30% of entries you never use.
With a partial index:
CREATE INDEX idx_tasks_alive_created ON tasks(tenant_id, created_at, id)
WHERE deleted_at IS NULL;
It covers only live rows. Smaller, faster to scan.
Check that the planner uses it:
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE tenant_id = '...' AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT 20;
Limit
-> Index Scan using idx_tasks_alive_created on tasks
Index Cond: (tenant_id = '...')
Index Scan using idx_tasks_alive_created confirms it. Without the WHERE deleted_at IS NULL in the query, the planner can't use the partial index.
Anti-pattern: filter in WHERE but not in the INDEX
-- ❌ Index without WHERE
CREATE INDEX idx_tasks_created ON tasks(tenant_id, created_at, id);
-- Query
SELECT * FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20;
-- Plan: Index Scan + Filter (deleted_at IS NULL)
-- Has to scan soft-deleted rows and discard them
vs:
-- ✅ Index with WHERE
CREATE INDEX idx_tasks_alive ON tasks(tenant_id, created_at, id)
WHERE deleted_at IS NULL;
-- Query
SELECT * FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20;
-- Plan: Index Scan (no filter — every row in the index already qualifies)
Tests
# tests/test_tasks_crud.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_and_list_task(client: AsyncClient, tenant_a_token: str, project_a_id: str):
# Create
response = await client.post(
"/tasks",
json={"project_id": project_a_id, "title": "T1", "status": "pending"},
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
assert response.status_code == 201
task_id = response.json()["id"]
# List
response = await client.get(
"/tasks",
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) == 1
assert body["items"][0]["id"] == task_id
@pytest.mark.asyncio
async def test_soft_delete_doesnt_appear_in_list(client, tenant_a_token, project_a_id):
# Create
create = await client.post("/tasks", json={...}, headers={...})
task_id = create.json()["id"]
# Delete
delete = await client.delete(f"/tasks/{task_id}", headers={...})
assert delete.status_code == 204
# List — must not appear
list_resp = await client.get("/tasks", headers={...})
assert len(list_resp.json()["items"]) == 0
# Direct GET — 404
get_resp = await client.get(f"/tasks/{task_id}", headers={...})
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_cursor_pagination(client, tenant_a_token, project_a_id):
# Create 25 tasks
for i in range(25):
await client.post(
"/tasks",
json={"project_id": project_a_id, "title": f"T{i}", "status": "pending"},
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
# First page
page1 = await client.get(
"/tasks?page_size=10",
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
body1 = page1.json()
assert len(body1["items"]) == 10
assert body1["next_cursor"] is not None
# Second page
cursor = body1["next_cursor"]
page2 = await client.get(
f"/tasks?page_size=10&cursor={cursor}",
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
body2 = page2.json()
assert len(body2["items"]) == 10
# No overlap between pages
page1_ids = {t["id"] for t in body1["items"]}
page2_ids = {t["id"] for t in body2["items"]}
assert page1_ids.isdisjoint(page2_ids)
# Third page (final 5)
page3 = await client.get(
f"/tasks?page_size=10&cursor={body2['next_cursor']}",
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
body3 = page3.json()
assert len(body3["items"]) == 5
assert body3["next_cursor"] is None # No more pages
Listing with soft-deleted (variant)
Sometimes you need an endpoint that includes soft-deleted rows (admin, audit):
@router.get("/tasks/admin/all", response_model=TaskListResponse)
async def list_all_tasks_including_deleted(
include_deleted: bool = False,
cursor: Optional[str] = None,
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db_with_tenant),
):
"""Admin endpoint that can include deleted tasks."""
query = (
select(Task)
.order_by(Task.created_at.desc(), Task.id.desc())
.limit(page_size + 1)
)
if not include_deleted:
query = query.where(Task.deleted_at.is_(None))
# ... rest is the same
Without the deleted_at IS NULL filter, the planner won't use the partial index. For that specific endpoint, consider an additional index without the condition.
Pitfalls and common mistakes
1. Forgetting deleted_at IS NULL in queries.
If a query doesn't filter by deleted_at IS NULL, it returns deleted rows. And worse: it doesn't use the partial index → seq scan on large tables.
2. Hard delete on an already soft-deleted task.
With soft delete active, "delete" is an UPDATE (set deleted_at). If you want a real hard delete (purge), a separate admin endpoint:
@router.delete("/admin/tasks/{task_id}/purge")
async def purge_task(task_id: uuid.UUID, db = Depends(...)):
# Real hard delete
await db.execute(delete(Task).where(Task.id == task_id))
await db.commit()
3. Cursor without a tiebreaker.
If two tasks have the same created_at (rare but possible), a cursor with only created_at can skip one. The id tiebreaker prevents that.
4. Cursor decoded but not validated.
The client can send a cursor with a valid format but arbitrary data. try/except on decode + type validation.
5. UNIQUE constraint on email when there's soft delete.
If a user is deleted and another is created with the same email, it violates the unique constraint.
-- Solution: partial UNIQUE index
CREATE UNIQUE INDEX uq_users_email_alive ON users(email)
WHERE deleted_at IS NULL;
Allows duplicates between soft-deleted and live rows, keeps uniqueness among live rows.
6. __table_args__ without a tuple when there's a single element.
# ❌
__table_args__ = Index(...)
# ✅
__table_args__ = (Index(...),)
7. UPDATE of a soft-deleted row without protection.
PUT /tasks/{id} should return 404 if the task is soft-deleted, not update it. Always filter deleted_at IS NULL in UPDATE queries too.
Summary and next step
What you have now:
- The
Taskmodel withtenant_id,deleted_at, timestamps. - A migration with RLS enabled.
- A partial index on live rows — the key optimization.
- CRUD endpoints with cursor pagination and soft delete.
- Tests that verify RLS, cursor, soft delete.
Commit:
git add .
git commit -m "feat: tasks CRUD with cursor pagination and soft delete"
In the next capsule we add the two features that close out the Task model: audit log with PostgreSQL triggers + optimistic locking. You'll create an audit schema with a task_log table, a trigger that captures every change, add a version column with __mapper_args__, and a PUT /tasks/{id} endpoint with an If-Match header → 412 / StaleDataError → 409.
Resources
- PostgreSQL — Partial Indexes — official reference.
- Markus Winand — Pagination Done the PostgreSQL Way — cursor pagination.
- PostgreSQL — UUID type — UUIDs as primary keys.
- SQLAlchemy 2.0 —
__table_args__— reference. - Brandur — Soft delete patterns — deep dive.
Capsule 04 of 08 — Module 8 — SQL Patterns for Production APIs Guide