[FastAPI Series 5] Build a CRUD Flow with an In-Memory List

한국어 버전

Once you know how to define Pydantic models, you can practice CRUD with a tiny in-memory store. CRUD stands for Create, Read, Update, Delete—the four operations every data app must handle reliably. Instead of introducing a database right away, this post repeats the API loop with a Python list to cement the flow.

Key terms

  1. CRUD – the core create/read/update/delete operations you implement throughout this article.
  2. HTTP status 201/204 – "Created" and "Success with no content," respectively; they clarify what happened in each endpoint.
  3. HTTPException – FastAPI's helper for returning a specific status code and message immediately, such as 404.
  4. PATCH – an HTTP method used here to update only part of a record, like moving a task across statuses.

Practice card

  • Estimated time: 45 minutes
  • Prereqs: Part 4 Pydantic example, Python 3.12, Swagger UI or curl
  • Goal: implement list-backed CRUD endpoints and return distinct status codes

Save the code snippets into main.py, run uv run fastapi dev main.py (or uv run uvicorn main:app --reload), and open http://127.0.0.1:8000/docs to exercise every route.

What this post covers

  • Use a global list as a simple store
  • Write POST/GET/PUT/DELETE endpoints
  • Increment IDs and update tasks reliably

Starter code

from enum import Enum

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

class Status(str, Enum):
    todo = "todo"
    doing = "doing"
    done = "done"

app = FastAPI()

class Task(BaseModel):
    title: str
    done: bool = False
    status: Status = Status.todo

tasks: list[dict] = []
current_id = 0

tasks holds the data as dictionaries, and current_id increments to assign identifiers. This store lives only in memory: it resets on every restart and is not thread-safe, so treat it purely as a practice scaffold.

Create

@app.post("/tasks", status_code=201)
def create_task(task: Task):
    global current_id
    current_id += 1
    record = {"id": current_id, **task.model_dump()}
    tasks.append(record)
    return record
  • status_code=201 signals a new resource.
  • model_dump() turns the Pydantic model into a dictionary (use .dict() if you are still on Pydantic 1.x).
  • The global current_id pattern works for one-process experiments only; later you will rely on database-generated IDs instead.

Read

@app.get("/tasks")
def list_tasks():
    return tasks

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for t in tasks:
        if t["id"] == task_id:
            return t
    raise HTTPException(status_code=404, detail="Task not found")

Return the full list or a single task; missing IDs become 404s via HTTPException.

Update

@app.put("/tasks/{task_id}")
def update_task(task_id: int, task: Task):
    for idx, t in enumerate(tasks):
        if t["id"] == task_id:
            updated = {"id": task_id, **task.model_dump()}
            tasks[idx] = updated
            return updated
    raise HTTPException(status_code=404, detail="Task not found")

Return the updated record so the client sees the new state immediately.

PUT replaces the entire resource—clients must send every field. For partial tweaks, we will add a PATCH route shortly.

Delete

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    for idx, t in enumerate(tasks):
        if t["id"] == task_id:
            tasks.pop(idx)
            return
    raise HTTPException(status_code=404, detail="Task not found")

Use 204 when no response body is needed.

CRUD flow overview

Swagger UI / curlPOST /tasks (201)GET /tasksGET /tasks/{id}PUT /tasks/{id}DELETE /tasks/{id} (204)tasks list (in memory) JSON bodyappend recordentire listsingle matchreplace entryremove entryHTTP response

Regardless of whether you drive the flow from Swagger UI or curl, this loop repeats: create, inspect, update, delete, repeat. If your editor does not render the D2 diagram, remember the same order: client sends JSON to create/update, FastAPI mutates the in-memory list, and every response echoes the current list or single record.

Example: project schedule management

Extend the model with a status field (already present in Task) and use PATCH to change only that field.

class StatusUpdate(BaseModel):
    status: Status

@app.patch("/tasks/{task_id}")
def move_task(task_id: int, update: StatusUpdate):
    for task in tasks:
        if task["id"] == task_id:
            task["status"] = update.status
            return task
    raise HTTPException(status_code=404, detail="Task not found")

PATCH now accepts a minimal JSON body such as { "status": "doing" }, validates it through StatusUpdate, and leaves the rest of the record untouched.

Why list-based practice matters

  • Skip database setup and focus on API behavior.
  • Rehearse the full CRUD sequence repeatedly in Swagger UI.
  • Reuse the same signatures and validation rules when you later plug in persistence.
  • Accept that data disappears whenever the process restarts and that concurrent requests may race—perfect reminders that you need a real database for production.

Practice

  • Follow along: implement all /tasks CRUD routes and run POST → GET → PUT → DELETE in Swagger UI.
  • Follow along: implement all /tasks CRUD routes, run the server, open /docs, and execute POST → GET → PUT → DELETE in order so you can watch IDs increment.
  • Extend: keep 201 and 204 responses correct and add the PATCH route; send { "status": "doing" } to confirm partial updates.
  • Debug: call a missing ID and confirm you get a 404 with the expected message.
  • Done when: success and error paths both return the right status codes and you have a recorded test order.

Wrap-up

An in-memory list is enough to internalize CRUD mechanics. Next we will tighten response models and documentation so clients always receive consistent structures. When you eventually swap in a database, keep the same Task schema, add response_model=Task (or list variants) to each route, and let FastAPI keep enforcing those contracts for you.

💬 댓글

이 글에 대한 의견을 남겨주세요