Lesson 4 - CRUD: Update and Delete

Welcome to CRUD: Update and Delete

In the last lesson your Task Manager learned to create and read rows. That’s the first half of CRUD; this lesson finishes the set. You’ll let clients update a task — marking it done, fixing a typo in the title — without forcing them to resend every field. You’ll let them delete a task and return the precise HTTP status the web expects for “done, nothing to send back.” And once a single table feels comfortable, you’ll get a first look at how two tables connect, so a task can belong to a project.

The shape of each endpoint is the same one you already know: fetch the row, handle the missing case with a 404, change something, commit. The new ideas are small and worth getting exactly right.

By the end of this lesson, you will be able to:

  • Apply a partial update with PATCH using model_dump(exclude_unset=True)
  • Return 204 No Content from a delete endpoint and handle missing rows with a 404
  • Explain what a foreign key is and add one to link two tables
  • Recognize how Relationship() lets you navigate between linked rows

You’ll build on the Task table and session dependency from earlier in the module. Let’s finish CRUD.


Partial Updates with PATCH

A client rarely wants to replace an entire task. Usually they want to change one thing — flip done to true, or correct the title — and leave everything else exactly as it was. That’s what PATCH is for. The challenge is telling the difference between “set this field to a new value” and “I didn’t mention this field, leave it alone.”

Start with a dedicated input model where every field is optional. This lets a client send any subset of the fields:

from sqlmodel import SQLModel

class TaskUpdate(SQLModel):
    title: str | None = None
    done: bool | None = None

Now the endpoint. Fetch the row by id (404 if it isn’t there), then read only the fields the client actually sent with model_dump(exclude_unset=True), copy each one onto the row with setattr, and commit:

from fastapi import Depends, HTTPException
from sqlmodel import Session

@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, data: TaskUpdate, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    updates = data.model_dump(exclude_unset=True)
    for key, value in updates.items():
        setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task

The key line is data.model_dump(exclude_unset=True). It produces a dictionary containing only the fields the client included in the request body — not the optional fields that fell back to their defaults. Here is the difference, side by side, for a request that sent just done:

partial = TaskUpdate(done=True)
print("full      :", partial.model_dump())
print("exclude   :", partial.model_dump(exclude_unset=True))
full      : {'title': None, 'done': True}
exclude   : {'done': True}

The plain model_dump() includes title: None — which, if you looped over it, would wipe out the existing title. With exclude_unset=True, title simply isn’t in the dictionary, so the loop never touches it. That’s a true partial update.

With tasks 1, 2, and 3 already in the database, sending PATCH /tasks/1 with body {"done": true} returns:

status: 200
{'done': True, 'id': 1, 'title': 'Write the report'}

Only done changed. The title is untouched, exactly as the client intended.

Why exclude_unset is essential here

Because every field in TaskUpdate is optional with a default of None, a plain model_dump() always returns a value for every field. If a client patches only done, a plain dump still includes title: None — and setattr(task, "title", None) would erase the existing title. exclude_unset=True keeps only the keys the client explicitly sent, so unmentioned fields keep their current values. It’s the difference between “change one thing” and “blank out everything you didn’t mention.”


Deleting a Task

Deleting follows the same fetch-then-act rhythm, and it introduces an HTTP status worth knowing: 204 No Content. A successful delete has nothing meaningful to return — the resource is gone — so the correct response is an empty body with a 204. You declare that on the decorator with status_code=204:

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return None

session.delete(task) stages the row for removal; session.commit() makes it permanent. You return None because a 204 must carry no body. Calling DELETE /tasks/3 produces an empty response with the right status:

status: 204
body repr: ''

And the row is genuinely gone — a follow-up GET /tasks no longer lists it:

status: 200
ids: [1, 2]

Both PATCH and DELETE start by fetching the row, which means both get the same safety net for free: if the id doesn’t exist, session.get returns None and you raise a clean 404 instead of crashing. Trying either operation on a missing id returns:

status: 404 {'detail': 'Task not found'}

That 404 is what keeps your API predictable. A client always learns whether its delete or update actually applied, instead of receiving a vague server error.


A First Look at Relationships

So far every task has stood alone. Real apps connect data: a task belongs to a project, an order belongs to a customer, a comment belongs to a post. The database tool for this is a foreign key — a column in one table that stores the id of a row in another table.

Let’s give tasks a home. Add a second table, Project, and a project_id column on Task that points at it. The foreign_key="project.id" argument tells SQLModel that this column references the id column of the project table:

from sqlmodel import SQLModel, Field, Relationship

class Project(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    tasks: list["Task"] = Relationship(back_populates="project")

class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    done: bool = False
    project_id: int | None = Field(default=None, foreign_key="project.id")
    project: Project | None = Relationship(back_populates="tasks")

The project_id field is the column that actually lives in the database. The Relationship() lines are extra: they don’t add columns, they give you convenient navigation in Python — task.project to reach the parent project from a task, and project.tasks to list all tasks belonging to a project. The matching back_populates names keep the two sides in sync.

Create both tables, insert a project, then a task that references it by project_id:

with Session(engine) as session:
    website = Project(name="Website Relaunch")
    session.add(website)
    session.commit()
    session.refresh(website)
    print("project id:", website.id, "name:", website.name)

    task = Task(title="Draft the homepage copy", project_id=website.id)
    session.add(task)
    session.commit()
    session.refresh(task)
    print("task:", {"id": task.id, "title": task.title, "project_id": task.project_id})
project id: 1 name: Website Relaunch
task: {'id': 1, 'title': 'Draft the homepage copy', 'project_id': 1}

The task stored project_id: 1, linking it to the project. Reading it back in a fresh session, you can both read the foreign-key column directly and use Relationship() to hop between the linked rows without writing any join yourself:

with Session(engine) as session:
    task = session.get(Task, 1)
    print("read task.project_id:", task.project_id)
    print("navigate task.project.name:", task.project.name)
    proj = session.get(Project, 1)
    print("navigate project.tasks titles:", [t.title for t in proj.tasks])
read task.project_id: 1
navigate task.project.name: Website Relaunch
navigate project.tasks titles: ['Draft the homepage copy']

That’s the whole idea of a relationship: one stored number (project_id) connects two tables, and Relationship() turns that link into ordinary attribute access. This is just an introduction — there’s more to explore around shaping the responses and loading related rows efficiently — but you now have the core building block of every connected data model.


Practice Exercises

Exercise 1: Predict the partial update

A task currently reads {"id": 5, "title": "Buy milk", "done": false}. A client sends PATCH /tasks/5 with body {"title": "Buy oat milk"}. What will the row look like afterward, and why doesn’t done change?

Hint

The body only includes title, so model_dump(exclude_unset=True) returns {'title': 'Buy oat milk'}. The loop calls setattr only for title, leaving done at its current value false. The result is {"id": 5, "title": "Buy oat milk", "done": false}.

Exercise 2: Why 204 instead of 200?

After a successful delete, why return 204 No Content with an empty body rather than a 200 with a JSON message like {"deleted": true}?

Hint

204 is the HTTP status that means “success, and there is intentionally no body to send.” The resource is gone, so there’s nothing meaningful to return. Clients can rely on the status code alone, which is cleaner than parsing a custom message. (A 200 with a body is also valid in practice — but 204 is the most precise signal.)

Exercise 3: Read the foreign key

Given project_id: int | None = Field(default=None, foreign_key="project.id") on Task, what does the foreign_key="project.id" part declare, and what does default=None allow?

Hint

foreign_key="project.id" says this column stores the id of a row in the project table — the link between the two tables. default=None makes the link optional, so a task can exist without belonging to any project yet (an “unassigned” task).


Summary

You’ve completed CRUD. Update uses PATCH with an all-optional input model and model_dump(exclude_unset=True), so only the fields a client actually sends get written — a real partial update that never blanks out unmentioned fields. Delete fetches the row, removes it with session.delete and commit, and returns 204 No Content with an empty body. Both operations fetch first, which gives you a clean 404 whenever the id doesn’t exist. Finally, you met relationships: a foreign_key column links two tables by storing another row’s id, and Relationship() lets you navigate between the linked rows in plain Python.

Key Concepts

  • PATCH — the method for partial updates; only the sent fields change.
  • exclude_unset=True — dumps only the fields the client explicitly sent, protecting the rest.
  • setattr — copies each sent field onto the fetched row before committing.
  • 204 No Content — the correct status for a successful delete with no body.
  • Foreign key — a column holding another table’s id to link two tables.
  • Relationship() — navigation between linked rows (e.g. task.project, project.tasks).

Why This Matters

Update and delete complete the basic vocabulary every API speaks. Partial updates with exclude_unset are the standard way professionals build PATCH endpoints — without it, “change one field” quietly destroys the others, a classic and frustrating bug. Returning the right status codes (204, 404) makes your API honest and easy for clients to trust. And relationships are the foundation of every non-trivial data model: once tables can reference each other, you can represent the real connections in your domain, not just isolated lists.


Next Steps

Continue to Lesson 5 - Guided Project: Persistent Tasks API

Bring the whole module together: build a complete, database-backed Task Manager API with full CRUD from scratch.

Back to Module Overview

Return to the Databases with SQLModel module overview


Continue Building Your Skills

Your Task Manager can now create, read, update, and delete rows in a real database, with the right status codes at every step — and you’ve seen how tables connect through foreign keys. In the next lesson you’ll put all of it together in a guided project, building a complete, persistent Tasks API from an empty file to a working, database-backed service.