KAHIBARO
Discord Login Register

29.5 Validation

Why Validation Matters in a Task Management API

In a Task Management API, almost every endpoint accepts data from clients: creating tasks, updating them, assigning users, setting due dates, and more. Validation is the process of checking that this incoming data is correct, safe, and complete before your application uses it.

Without validation:

With good validation:

In this chapter you will focus on validation that is specific to your Task Management API project, not general validation theory.


Typical Data to Validate in a Task App

Most Task Management APIs use data structures similar to:

Let us assume a simple Task model for this project.

Example schema for a task:

FieldTypeRequiredExample
idintegerno (auto)1
titlestringyes"Buy groceries"
descriptionstring / nullno"Milk, eggs, bread"
statusstringyes"todo"
priorityintegerno1
due_datedatetime/nullno"2026-09-01T10:00:00Z"
created_atdatetimeno (auto)"2026-08-01T12:00:00Z"
updated_atdatetimeno (auto)"2026-08-01T12:10:00Z"
owner_idintegeryes42

For each field you must decide:

Example decisions:

Types of Validation You Will Use

You will typically use three layers of validation in this project:

  1. Request body schema validation
    Use Pydantic models (in FastAPI) to ensure correct types and basic constraints.
  2. Business rule validation
    Custom checks that depend on your application logic, such as:
    • “A completed task cannot have a due date in the past changed to a future date.”
    • “Only the owner can update a task.”
  3. Database-level validation
    Constraints in the database to ensure that even if your API bug lets bad data pass, the database rejects it.

You will focus mainly on the first two in this project chapter, but be aware of the third.


Using Pydantic Models for Request Validation

In FastAPI, Pydantic models are the main tool for validating request bodies.

Example Pydantic models for tasks:

python
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, Field
class TaskBase(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    status: Literal["todo", "in_progress", "done"] = "todo"
    priority: Optional[int] = Field(None, ge=1, le=5)
    due_date: Optional[datetime] = None
class TaskCreate(TaskBase):
    # For creation, all fields from TaskBase are allowed.
    # You could also override or add required fields here.
    pass
class TaskUpdate(BaseModel):
    # All fields optional for partial updates
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    status: Optional[Literal["todo", "in_progress", "done"]] = None
    priority: Optional[int] = Field(None, ge=1, le=5)
    due_date: Optional[datetime] = None

Key things happening here:

Always define separate models for creation and update if your rules differ. Never assume that the same model fits both POST /tasks and PATCH /tasks/{id}.


Validating Task Creation

For creating tasks, you usually require the minimum necessary fields and apply stricter rules.

Example FastAPI endpoint:

python
from fastapi import APIRouter, Depends, status
from .schemas import TaskCreate, TaskRead
from .dependencies import get_current_user
from .services import task_service
router = APIRouter()
@router.post("/tasks", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create_task(
    task_in: TaskCreate,
    current_user = Depends(get_current_user),
):
    task = await task_service.create_task(task_in, owner_id=current_user.id)
    return task

Schema-level creation rules

Typical validation rules for task creation:

You can implement the “due date cannot be in the past” rule inside a Pydantic validator.

python
from pydantic import validator
from datetime import datetime, timezone
class TaskCreate(TaskBase):
    @validator("due_date")
    def due_date_cannot_be_past(cls, value):
        if value is None:
            return value
        now = datetime.now(timezone.utc)
        if value < now:
            raise ValueError("due_date cannot be in the past")
        return value

Now, if a client sends:

json
{
  "title": "Finish report",
  "due_date": "2020-01-01T10:00:00Z"
}

FastAPI automatically returns a 422 Unprocessable Entity response with details of the validation error, without your route function running.


Validating Task Updates

For updates, clients often send only fields they want to change. That is why the update model has all fields optional.

Example `PATCH /tasks/{id}` endpoint

python
from fastapi import HTTPException
@router.patch("/tasks/{task_id}", response_model=TaskRead)
async def update_task(
    task_id: int,
    task_in: TaskUpdate,
    current_user = Depends(get_current_user),
):
    task = await task_service.get_task(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")
    # Business rule: only the owner can update
    if task.owner_id != current_user.id:
        raise HTTPException(status_code=403, detail="Not allowed to update this task")
    updated_task = await task_service.update_task(task, task_in)
    return updated_task

Handling partial updates correctly

When you apply patch data, use .dict(exclude_unset=True) so that missing fields are not set to None by mistake.

python
async def update_task(task, task_in: TaskUpdate):
    update_data = task_in.dict(exclude_unset=True)
    for field, value in update_data.items():
        setattr(task, field, value)
    # Save task in database here
    return task

For partial updates, always use exclude_unset=True or equivalent logic. Otherwise you may overwrite existing values with null for fields that the client did not intend to change.

Update-specific rules

Some rules only make sense on update:

These rules are usually enforced in your service layer, not in the Pydantic model, because they depend on existing data in the database.

Example:

python
from fastapi import HTTPException
async def update_task(task, task_in: TaskUpdate):
    data = task_in.dict(exclude_unset=True)
    new_status = data.get("status")
    if task.status == "done" and new_status and new_status != "done":
        raise HTTPException(
            status_code=400,
            detail="Cannot move a completed task back to todo or in_progress",
        )
    # apply allowed changes
    for field, value in data.items():
        setattr(task, field, value)
    # save and return
    return task

Validating IDs and Path Parameters

Validation is not only for request bodies. Path parameters and query parameters also benefit from validation.

Validating `task_id`

python
from fastapi import Path
@router.get("/tasks/{task_id}")
async def get_task(
    task_id: int = Path(..., ge=1)
):
    # ...
    ...

Here:

If the client calls /tasks/0 or /tasks/-5, FastAPI will return a validation error.

Consistent validation of identifiers

Use similar constraints for other identifiers:

Example:

python
from uuid import UUID
@router.get("/tasks/{task_id}")
async def get_task(task_id: UUID):
    ...

Business Rule Validation Inside Services

Schema validation checks the format and simple constraints of input, but it does not know your business rules. The service layer is a good place to enforce rules such as ownership, status transitions, or limits.

Typical business constraints for a task system:

RuleWhere to enforce
A user cannot create more than 1000 active tasksService layer / database
Only the task owner can update or delete a taskService layer
Status can only move in a certain orderService layer
Due date cannot be before the creation dateService layer

Example: status transition validation

python
ALLOWED_TRANSITIONS = {
    "todo": {"in_progress", "done"},
    "in_progress": {"todo", "done"},
    "done": set(),  # done is final
}
def validate_status_transition(old_status: str, new_status: str):
    allowed = ALLOWED_TRANSITIONS.get(old_status, set())
    if new_status not in allowed:
        raise HTTPException(
            status_code=400,
            detail=f"Cannot change status from {old_status} to {new_status}",
        )

Then call validate_status_transition inside your update function when status is being changed.

Business rules usually depend on existing data, so they cannot live entirely inside Pydantic models. Keep them in your service / domain layer so that they can see the full context.


Preventing Invalid Data at the Database Level

In addition to API validation, add constraints in your database. This avoids data corruption if there is a bug or if someone writes directly to the database.

Common database constraints for tasks:

Example table definition in SQL (PostgreSQL style):

sql
CREATE TABLE tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    status VARCHAR(20) NOT NULL DEFAULT 'todo',
    priority INTEGER,
    due_date TIMESTAMPTZ,
    owner_id INTEGER NOT NULL REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CHECK (LENGTH(title) > 0),
    CHECK (priority IS NULL OR (priority >= 1 AND priority <= 5)),
    CHECK (status IN ('todo', 'in_progress', 'done'))
);

If your API forgets to validate one of these rules, the database will still block invalid records.


Returning Helpful Validation Errors

When validation fails, clients need clear information. FastAPI returns a structured error format by default, but when you raise your own HTTPException you must choose helpful messages.

Example validation error structure

FastAPI typical 422 error body:

json
{
  "detail": [
    {
      "loc": ["body", "title"],
      "msg": "ensure this value has at least 1 characters",
      "type": "value_error.any_str.min_length",
      "ctx": {"limit_value": 1}
    }
  ]
}

For your own business rule errors, return a clear detail message:

python
raise HTTPException(
    status_code=400,
    detail="Cannot change status from done to todo"
)

Simple guidelines:

Example: Full Validation Flow for Creating a Task

Let us walk through what happens when a client calls POST /tasks with JSON:

json
{
  "title": "Plan weekend trip",
  "status": "finished",
  "priority": 10,
  "due_date": "2020-01-01T10:00:00Z"
}

Assume your models and validators are set up as earlier.

  1. JSON parsing
    FastAPI reads the JSON and tries to map it to TaskCreate.
  2. Schema validation
    Pydantic checks:
    • title is a string and at least 1 character ✅
    • status is a Literal["todo", "in_progress", "done"] ❌ here you passed "finished"
    • priority between 1 and 5 ❌ you passed 10
    • due_date not in the past ❌ date is in the past
  3. Error response
    FastAPI never calls your route function. Instead it returns a 422 response listing all validation errors.

Example response body (simplified):

json
{
  "detail": [
    {
      "loc": ["body", "status"],
      "msg": "unexpected value; permitted: 'todo', 'in_progress', 'done'",
      "type": "type_error.literal"
    },
    {
      "loc": ["body", "priority"],
      "msg": "ensure this value is less than or equal to 5",
      "type": "value_error.number.not_le",
      "ctx": {"limit_value": 5}
    },
    {
      "loc": ["body", "due_date"],
      "msg": "due_date cannot be in the past",
      "type": "value_error"
    }
  ]
}

The client can now fix all problems and send a new request.


Practical Checklist for Validation in This Project

When implementing validation in your Task Management API, walk through this checklist:

Validation Checklist for the Task API

  1. Define Pydantic models for:
    • TaskCreate (required fields for creating)
    • TaskUpdate (all fields optional for partial updates)
    • TaskRead (response model, usually includes id, timestamps, and owner info)
  2. Add constraints in models:
    • title: required, non-empty, max_length
    • description: optional, max_length
    • status: allowed values only
    • priority: optional, numeric range
    • due_date: custom validator to prevent past dates
  3. Validate path and query parameters:
    • task_id: positive integer
    • pagination parameters: non-negative, with sensible maximum
  4. Enforce business rules in service layer:
    • Ownership checks for update and delete
    • Status transition rules
    • Limits per user if needed
  5. Add database constraints:
    • NOT NULL for required fields
    • CHECK constraints for ranges and status
    • FOREIGN KEY constraints for owners and relations
  6. Return clear error messages and HTTP status codes:
    • 400 for business rule violations
    • 403 for permission errors
    • 404 for missing resources
    • Default 422 for schema validation errors

If you implement these steps consistently, your Task Management API will be much more robust, predictable, and safe to use.

Views: 7

Comments

Please login to add a comment.

Don't have an account? Register now!