29.5 Validation
Table of Contents
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:
- You may insert broken or incomplete data into the database.
- Your API may behave unpredictably.
- Attackers may exploit weak input checks.
With good validation:
- Your data stays consistent.
- Your API is easier to use and debug.
- Errors are caught early and reported clearly to clients.
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:
- Task
- User (already mostly handled in the authentication project later, but can exist here too)
- Project or List (optional, depending on your design)
Let us assume a simple Task model for this project.
Example schema for a task:
| Field | Type | Required | Example |
|---|---|---|---|
| id | integer | no (auto) | 1 |
| title | string | yes | "Buy groceries" |
| description | string / null | no | "Milk, eggs, bread" |
| status | string | yes | "todo" |
| priority | integer | no | 1 |
| due_date | datetime/null | no | "2026-09-01T10:00:00Z" |
| created_at | datetime | no (auto) | "2026-08-01T12:00:00Z" |
| updated_at | datetime | no (auto) | "2026-08-01T12:10:00Z" |
| owner_id | integer | yes | 42 |
For each field you must decide:
- Is it required when creating a task?
- Can it be changed when updating a task?
- What constraints does it have?
Example decisions:
titlemust be a non-empty string, maximum 200 characters.statuscan only be one of:"todo","in_progress","done".prioritymust be an integer between 1 and 5.due_datecannot be a date in the past.owner_idmust refer to an existing user (business rule, often checked in the service / database layer).
Types of Validation You Will Use
You will typically use three layers of validation in this project:
- Request body schema validation
Use Pydantic models (in FastAPI) to ensure correct types and basic constraints. - 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.”
- 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:
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] = NoneKey things happening here:
Field(..., ...)means the field is required.min_length,max_length,ge(greater or equal),le(less or equal) add constraints.Literal["todo", "in_progress", "done"]restricts the allowed values.
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:
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 taskSchema-level creation rules
Typical validation rules for task creation:
titleis required and cannot be empty.statustypically defaults to"todo"and may be optional.priorityis optional but must be in a specific range if provided.due_datecan be optional, but if provided it must not be in the past.
You can implement the “due date cannot be in the past” rule inside a Pydantic validator.
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 valueNow, if a client sends:
{
"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
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_taskHandling partial updates correctly
When you apply patch data, use .dict(exclude_unset=True) so that missing fields are not set to None by mistake.
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:
- You might not allow changing
statusfrom"done"back to"todo". - You might require that
due_datebe later thancreated_at. - You might not allow changing
owner_idat all.
These rules are usually enforced in your service layer, not in the Pydantic model, because they depend on existing data in the database.
Example:
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 taskValidating IDs and Path Parameters
Validation is not only for request bodies. Path parameters and query parameters also benefit from validation.
Validating `task_id`
from fastapi import Path
@router.get("/tasks/{task_id}")
async def get_task(
task_id: int = Path(..., ge=1)
):
# ...
...Here:
task_idmust be an integer.- It must be greater or equal to 1.
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:
user_id,project_id, etc. should usually be positive integers.- For UUID based IDs, use the
UUIDtype.
Example:
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:
| Rule | Where to enforce |
|---|---|
| A user cannot create more than 1000 active tasks | Service layer / database |
| Only the task owner can update or delete a task | Service layer |
| Status can only move in a certain order | Service layer |
| Due date cannot be before the creation date | Service layer |
Example: status transition validation
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:
titlecannot be empty (check or not-null with minimum length).statusmust be in an enumerated set.prioritybetween 1 and 5.owner_idmust reference an existing user (FOREIGN KEY).
Example table definition in SQL (PostgreSQL style):
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:
{
"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:
raise HTTPException(
status_code=400,
detail="Cannot change status from done to todo"
)Simple guidelines:
- Use
400 Bad Requestfor business rule violations. - Use
404 Not Foundwhen the resource does not exist. - Use
403 Forbiddenfor permission issues.
Example: Full Validation Flow for Creating a Task
Let us walk through what happens when a client calls POST /tasks with 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.
- JSON parsing
FastAPI reads the JSON and tries to map it toTaskCreate. - Schema validation
Pydantic checks: titleis a string and at least 1 character ✅statusis aLiteral["todo", "in_progress", "done"]❌ here you passed"finished"prioritybetween 1 and 5 ❌ you passed10due_datenot in the past ❌ date is in the past- Error response
FastAPI never calls your route function. Instead it returns a422response listing all validation errors.
Example response body (simplified):
{
"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
- Define Pydantic models for:
TaskCreate(required fields for creating)TaskUpdate(all fields optional for partial updates)TaskRead(response model, usually includesid, timestamps, and owner info)- Add constraints in models:
title: required, non-empty,max_lengthdescription: optional,max_lengthstatus: allowed values onlypriority: optional, numeric rangedue_date: custom validator to prevent past dates- Validate path and query parameters:
task_id: positive integer- pagination parameters: non-negative, with sensible maximum
- Enforce business rules in service layer:
- Ownership checks for update and delete
- Status transition rules
- Limits per user if needed
- Add database constraints:
NOT NULLfor required fieldsCHECKconstraints for ranges and statusFOREIGN KEYconstraints for owners and relations- Return clear error messages and HTTP status codes:
400for business rule violations403for permission errors404for missing resources- Default
422for 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
KAHIBARO