29.4. CRUD Operations
Table of Contents
Understanding CRUD in the Task Management API
In this chapter you will implement the four basic operations of almost every backend application: Create, Read, Update, Delete (CRUD) for tasks in your Task Management API.
We will assume you already have:
- A FastAPI project skeleton
- A database and
taskstable or ORM model - Basic routing knowledge
Here we connect those pieces into real, usable endpoints.
CRUD stands for:
- Create
- Read
- Update
- Delete
Every resource-based API will implement these in some form.
For examples, we will use:
- FastAPI
- SQLAlchemy ORM
- Pydantic models
You can adapt to your own stack if it differs.
Typical Task fields we will use:
| Field | Type | Description |
|---|---|---|
id | int | Unique task identifier (primary key) |
title | string | Short task title |
description | string/null | Optional detailed description |
is_done | bool | Completion flag |
created_at | datetime | When the task was created |
updated_at | datetime | Last update time |
Designing Request and Response Models
Before implementing CRUD operations, define Pydantic models for input and output. These models make your API clear and consistent.
A common pattern uses three models:
TaskCreatefor creating tasksTaskUpdatefor updating tasksTaskRead(orTask/TaskOut) for returning tasks to clients
Example:
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TaskBase(BaseModel):
title: str = Field(..., max_length=200)
description: Optional[str] = None
is_done: bool = False
class TaskCreate(TaskBase):
# All fields required for creation already in TaskBase
pass
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
is_done: Optional[bool] = None
class TaskRead(TaskBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = TrueSome points:
TaskBasecollects common fields.TaskCreateusually requires some fields, such astitle.TaskUpdateusesOptional[...]for partial updates.TaskReadincludesid, timestamps, andorm_mode = Trueso it can read from ORM objects.
Mapping CRUD to HTTP Methods and URLs
A simple, REST style design for tasks:
| Operation | HTTP Method | URL pattern | Description |
|---|---|---|---|
| Create | POST | /tasks | Create a new task |
| Read (list) | GET | /tasks | List all tasks |
| Read (one) | GET | /tasks/{task_id} | Get a single task by id |
| Update | PUT or PATCH | /tasks/{task_id} | Update an existing task |
| Delete | DELETE | /tasks/{task_id} | Delete a task |
Important design rule
- Use collection URL (
/tasks) for actions on many resources like create and list. - Use item URL (
/tasks/{task_id}) for actions on a single resource like get, update, delete.
Next we will implement each of these operations.
Create: Adding a New Task
The Create operation usually uses POST /tasks.
The client sends a JSON body with data for the new task, and the server returns the created task, often with HTTP status 201 Created.
Example request body:
{
"title": "Finish course chapter",
"description": "Write CRUD section for Task API",
"is_done": false
}Example SQLAlchemy model
We will assume something like this:
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from .database import Base # Your SQLAlchemy Base
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
description = Column(String, nullable=True)
is_done = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)POST /tasks endpoint
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from . import models, schemas
from .database import get_db
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post(
"",
response_model=schemas.TaskRead,
status_code=status.HTTP_201_CREATED,
)
def create_task(task_in: schemas.TaskCreate, db: Session = Depends(get_db)):
task = models.Task(
title=task_in.title,
description=task_in.description,
is_done=task_in.is_done,
)
db.add(task)
db.commit()
db.refresh(task) # Load generated id and timestamps from DB
return taskWhat happens:
- FastAPI parses the JSON body into
TaskCreate. - You create a
TaskORM object with that data. - You add and commit it to the database.
db.refreshloads DB-generated values such asidand timestamps.- The task is returned as
TaskReaddue toresponse_model=schemas.TaskRead.
Common mistakes to avoid:
- Forgetting to commit, the task is never saved.
- Returning the ORM object without
orm_mode = Truein your response model. - Returning
201 Createdbut not actually creating anything.
Suggested behavior:
- Validate
titlelength. - Do not allow the client to set
id,created_at, orupdated_at. Those belong to the server.
Read: Listing and Fetching Tasks
The Read operations are:
GET /tasksfor a list of tasks.GET /tasks/{task_id}for details of one task.
GET /tasks list all tasks
Basic version:
from typing import List
from fastapi import Query
@router.get(
"",
response_model=List[schemas.TaskRead],
)
def list_tasks(
db: Session = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
):
tasks = db.query(models.Task).offset(skip).limit(limit).all()
return tasksNotes:
skipandlimitare simple pagination parameters.ge=0means greater or equal to 0,le=1000means less or equal to 1000.- This returns a JSON array of tasks.
Example response:
[
{
"id": 1,
"title": "Finish course chapter",
"description": "Write CRUD section for Task API",
"is_done": false,
"created_at": "2026-08-28T12:00:00Z",
"updated_at": "2026-08-28T12:00:00Z"
},
{
"id": 2,
"title": "Review pull request",
"description": null,
"is_done": true,
"created_at": "2026-08-28T14:00:00Z",
"updated_at": "2026-08-28T14:15:00Z"
}
]You can later extend this with filtering, searching, or sorting.
GET /tasks/{task_id} get a single task
from fastapi import HTTPException
@router.get(
"/{task_id}",
response_model=schemas.TaskRead,
)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return taskImportant points:
- If the task does not exist, you should return
404 Not Found. - You do not expose database errors directly. You send a clear message like
"Task not found".
Important behavior rule for Read operations
- When a resource with a given id does not exist, return HTTP 404.
Do not silently returnnullor an empty object.
Update: Modifying Existing Tasks
For updates you typically use:
PUT /tasks/{task_id}for full updatesPATCH /tasks/{task_id}for partial updates
In many simple APIs you can implement only one of them, usually something closer to partial update using PATCH. Here we focus on a partial update with TaskUpdate.
Deciding between PUT and PATCH
- PUT means the client sends the whole new representation. Fields not sent are usually reset to default.
- PATCH means partial update. Only fields provided are changed.
To avoid confusion, we will implement:
PATCH /tasks/{task_id}withTaskUpdate.
PATCH /tasks/{task_id}
from fastapi import Body
@router.patch(
"/{task_id}",
response_model=schemas.TaskRead,
)
def update_task(
task_id: int,
task_in: schemas.TaskUpdate = Body(...),
db: Session = Depends(get_db),
):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
# Only update fields that were provided
update_data = task_in.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
db.commit()
db.refresh(task)
return taskExplanation:
exclude_unset=Trueonly keeps fields that the client actually sent.- We loop over those fields and set them on the ORM object.
- We commit the changes and return the updated task.
Example patch request:
PATCH /tasks/1
Content-Type: application/json
{
"is_done": true
}Example response:
{
"id": 1,
"title": "Finish course chapter",
"description": "Write CRUD section for Task API",
"is_done": true,
"created_at": "2026-08-28T12:00:00Z",
"updated_at": "2026-08-28T15:00:00Z"
}Optional: PUT for full update
If you want to support PUT:
- Require all fields in the input.
- Replace resource with new data.
Simple version using TaskCreate as body:
@router.put(
"/{task_id}",
response_model=schemas.TaskRead,
)
def replace_task(
task_id: int,
task_in: schemas.TaskCreate,
db: Session = Depends(get_db),
):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
task.title = task_in.title
task.description = task_in.description
task.is_done = task_in.is_done
db.commit()
db.refresh(task)
return taskUpdate behavior rules
- If the resource does not exist, return 404 Not Found.
- Do not let the client change server-managed fields such as
idorcreated_at. - Use
PATCHfor partial updates, and be consistent about it.
Delete: Removing Tasks
The Delete operation uses DELETE /tasks/{task_id}.
The server removes the task and usually returns:
204 No Content, with an empty body, or200 OKwith some confirmation data.
We will use 204 No Content, which is common and simple.
DELETE /tasks/{task_id}
@router.delete(
"/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
db.delete(task)
db.commit()
# No return body for 204Behavior summary:
- On success:
204 No Contentand an empty body. - If the task does not exist:
404 Not Found.
Alternative: soft deletes
In some applications you do not physically delete records, you mark them as deleted with a flag like is_deleted. For this project you likely do a real delete, but know soft delete is a common alternative.
Example soft delete code:
def delete_task_soft(task_id: int, db: Session):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
task.is_deleted = True
db.commit()Typical CRUD Workflow in the Task API
To see CRUD in action, imagine the following sequence for a single task.
- Create a task
POST /tasks
Content-Type: application/json
{
"title": "Write tests",
"description": "Add unit tests for Task API",
"is_done": false
}Response:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": 10,
"title": "Write tests",
"description": "Add unit tests for Task API",
"is_done": false,
"created_at": "2026-08-28T16:00:00Z",
"updated_at": "2026-08-28T16:00:00Z"
}- Read the task
GET /tasks/10Response:
HTTP/1.1 200 OK
{
"id": 10,
"title": "Write tests",
"description": "Add unit tests for Task API",
"is_done": false,
"created_at": "2026-08-28T16:00:00Z",
"updated_at": "2026-08-28T16:00:00Z"
}- Update the task to mark it as done
PATCH /tasks/10
Content-Type: application/json
{
"is_done": true
}Response:
HTTP/1.1 200 OK
{
"id": 10,
"title": "Write tests",
"description": "Add unit tests for Task API",
"is_done": true,
"created_at": "2026-08-28T16:00:00Z",
"updated_at": "2026-08-28T17:15:00Z"
}- Delete the task
DELETE /tasks/10Response:
HTTP/1.1 204 No Content- Try to read it again, and get
404:
GET /tasks/10Response:
HTTP/1.1 404 Not Found
{
"detail": "Task not found"
}Error Handling in CRUD Operations
Every CRUD endpoint should handle typical error cases:
| Operation | Common error situation | Status code |
|---|---|---|
| Create | Invalid input data | 400 / 422 |
| Read | Task not found | 404 |
| Update | Task not found | 404 |
| Update | Invalid field types or values | 400 / 422 |
| Delete | Task not found | 404 |
FastAPI uses 422 Unprocessable Entity by default when the request body cannot be validated against the Pydantic model.
Example of raising your own error:
if len(task_in.title.strip()) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Title cannot be empty",
)Key error handling rule
- For invalid input data, return a 4xx error and a clear message that helps the client fix the request.
Putting It All Together
A minimal, complete tasks router might look like this:
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List
from . import models, schemas
from .database import get_db
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("", response_model=schemas.TaskRead, status_code=status.HTTP_201_CREATED)
def create_task(task_in: schemas.TaskCreate, db: Session = Depends(get_db)):
task = models.Task(
title=task_in.title,
description=task_in.description,
is_done=task_in.is_done,
)
db.add(task)
db.commit()
db.refresh(task)
return task
@router.get("", response_model=List[schemas.TaskRead])
def list_tasks(
db: Session = Depends(get_db),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
):
tasks = db.query(models.Task).offset(skip).limit(limit).all()
return tasks
@router.get("/{task_id}", response_model=schemas.TaskRead)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/{task_id}", response_model=schemas.TaskRead)
def update_task(
task_id: int,
task_in: schemas.TaskUpdate,
db: Session = Depends(get_db),
):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_in.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
db.commit()
db.refresh(task)
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(models.Task).filter(models.Task.id == task_id).first()
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()Once you have these CRUD operations, you have a complete, usable Task Management API. In later chapters you will add validation layers, testing, authentication, and documentation on top of this foundation.
Views: 5
KAHIBARO