Lesson 3 - CRUD: Create and Read
Welcome to CRUD: Create and Read
You now have a database connected (Lesson 1) and clean table and schema models to work with (Lesson 2). It’s time to actually move data. The four operations every database app needs are Create, Read, Update, and Delete — together, CRUD. This lesson covers the first two: writing new rows and reading them back. By the end, your Task Manager will persist tasks to disk and serve them on request, surviving any restart.
The good news is that with the foundation in place, each operation is just a few lines. You’ll add three rows to a real SQLite file, fetch one by its id, and page through the full list — all through the session dependency you already built.
By the end of this lesson, you will be able to:
- Create a row by converting a create schema with
Task.model_validateand saving it withadd,commit, andrefresh - Read one row by id with
session.get, returning a 404 when it’s missing - Read many rows with a
selectquery, usingoffsetandlimitfor pagination - Wire all three as endpoints through the
get_sessiondependency and aresponse_model
Let’s write some data.
Setting Up the App
Every example below shares the same setup: the table and schema models from Lesson 2, the engine, and the session dependency from Lesson 1. Here is the full app we’ll build piece by piece, so you have the whole picture before we zoom in:
from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import SQLModel, Field, Session, create_engine, select
class TaskBase(SQLModel):
title: str
done: bool = False
class Task(TaskBase, table=True):
id: int | None = Field(default=None, primary_key=True)
class TaskCreate(TaskBase):
pass
engine = create_engine("sqlite:///tasks.db", connect_args={"check_same_thread": False})
def get_session():
with Session(engine) as session:
yield session
app = FastAPI()
SQLModel.metadata.create_all(engine)TaskCreate is what clients send (just title and done — no id); Task is the table that gains a database-assigned id. With this in place, the three endpoints follow.
Create: Writing a New Row
To create a task, the endpoint receives a TaskCreate, turns it into a Task table row, and saves it. The conversion is one line — Task.model_validate(data) — and saving is the three-step add, commit, refresh sequence:
@app.post("/tasks", response_model=Task, status_code=201)
def create_task(data: TaskCreate, session: Session = Depends(get_session)):
task = Task.model_validate(data)
session.add(task)
session.commit()
session.refresh(task)
return taskTask.model_validate(data) reads the fields off the incoming TaskCreate and builds a real Task instance — a row object the database understands. At this moment its id is still None, because the database hasn’t assigned one yet:
data = TaskCreate(title="Write the report")
task = Task.model_validate(data)
print("id before commit:", task.id)id before commit: NoneThen the three database calls do their distinct jobs, and afterward the id is filled in. POSTing three tasks gives us:
# POST /tasks {"title": "Write the report"}
# POST /tasks {"title": "Email the team", "done": true}
# POST /tasks {"title": "Plan the sprint"}POST 201 {'done': False, 'id': 1, 'title': 'Write the report'}
POST 201 {'done': True, 'id': 2, 'title': 'Email the team'}
POST 201 {'done': False, 'id': 3, 'title': 'Plan the sprint'}Each response carries a real id (1, 2, 3) that the database generated. The status_code=201 marks “created,” and response_model=Task shapes the response. That id is the value clients will use to read, update, or delete the task later.
What add, commit, and refresh each do
These three calls are easy to blur together, but they’re distinct steps:
session.add(task)stages the new row in the session — it’s pending, not yet written to the database file.session.commit()writes all pending changes to disk in a single transaction. This is when the database assigns the auto-incrementid.session.refresh(task)re-reads the row from the database into yourtaskobject, so fields the database filled in (likeid) are loaded and ready to return.
Skip refresh and your returned object may still show id=None, even though the row was saved.
Read One: Fetch by Id, or 404
Reading a single row by its primary key is the simplest query SQLModel offers: session.get(Task, task_id). It returns the matching Task, or None if no row has that id. A well-behaved API turns that None into a clear 404 Not Found rather than crashing:
@app.get("/tasks/{task_id}", response_model=Task)
def get_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")
return taskFetching an existing task returns it directly; asking for one that doesn’t exist returns a 404 with a helpful message:
# GET /tasks/2
# GET /tasks/99GET /tasks/2 200 {'done': True, 'id': 2, 'title': 'Email the team'}
GET /tasks/99 404 {'detail': 'Task not found'}session.get is purpose-built for lookups by primary key, so it’s both clearer and faster than writing a full query for this case. The if not task guard is the standard pattern you’ll repeat in the update and delete endpoints next lesson — always check before you trust the row exists.
Read Many: A Paginated List
To return all tasks, you build a query with select(Task) and run it through the session. But returning every row at once is a bad idea once a table grows large, so we add pagination: offset skips a number of rows and limit caps how many come back. We expose those as query parameters with sensible defaults:
@app.get("/tasks", response_model=list[Task])
def list_tasks(skip: int = 0, limit: int = 10, session: Session = Depends(get_session)):
return session.exec(select(Task).offset(skip).limit(limit)).all()session.exec(...) runs the query, and .all() collects the results into a list. With no parameters, you get the first page (up to 10 rows); the response_model=list[Task] tells FastAPI to serialize each row:
# GET /tasksGET /tasks 200 [{'done': False, 'id': 1, 'title': 'Write the report'}, {'done': True, 'id': 2, 'title': 'Email the team'}, {'done': False, 'id': 3, 'title': 'Plan the sprint'}]Add skip and limit to walk through pages. Asking to skip the first row and take just one returns exactly the second task:
# GET /tasks?skip=1&limit=1GET /tasks?skip=1&limit=1 200 [{'done': True, 'id': 2, 'title': 'Email the team'}]That’s the whole pagination pattern: offset is where the page starts, limit is how big the page is. A client wanting page 3 of 10-row pages would send skip=20&limit=10. Defaults keep simple requests simple while giving callers control when they need it.
Practice Exercises
Exercise 1: Trace the three save calls
A teammate writes create_task but forgets session.refresh(task) before returning. The row saves fine, but the response shows "id": null. Why?
Hint
The database assigns the id during commit, but your in-memory task object doesn’t automatically pick up that value. refresh re-reads the row from the database so the new id (and any other database-set fields) load into the object you return. Without it, the object still holds the pre-commit id=None.
Exercise 2: Why model_validate instead of saving the schema directly?
Why does the create endpoint convert data into a Task with Task.model_validate(data) instead of just calling session.add(data) on the incoming TaskCreate?
Hint
TaskCreate is a plain schema, not a table — it has no table=True and the database knows nothing about it. Task.model_validate(data) copies the validated fields into a real Task row object, which is what session.add can save. This also enforces the separation from Lesson 2: clients send TaskCreate (no id), and the table assigns the id itself.
Exercise 3: Build a “page 2” request
Pages are 5 tasks each. Which skip and limit query parameters return the second page (tasks 6 through 10)?
Hint
limit=5 sets the page size; to get the second page you skip the first page’s 5 rows, so skip=5. The full request is GET /tasks?skip=5&limit=5. In general, for page N with size S, skip = (N - 1) * S and limit = S.
Summary
You’ve written the first half of CRUD against a real database. Create converts the incoming TaskCreate into a Task row with Task.model_validate, then saves it with the three-step add (stage), commit (write and assign the id), and refresh (reload the saved row) sequence. Read one uses session.get(Task, task_id) and raises a 404 when the row is missing. Read many runs select(Task) with offset and limit for clean pagination. All three plug into the get_session dependency and use response_model to shape their output — and every row now lives on disk.
Key Concepts
model_validate— converts a create schema into a savable table row object.add/commit/refresh— stage the row, write it (assigning the id), then reload it.session.get(Model, id)— fetch one row by primary key, orNoneif absent.- 404 guard — raise
HTTPException(status_code=404, ...)when a lookup returnsNone. offset/limit— skip and cap rows in aselectto paginate a list.
Why This Matters
Create and read are the operations you’ll write most often in any API — almost every feature boils down to “save this” and “show me that.” The add/commit/refresh rhythm and the get-then-404 guard are patterns you’ll reuse in every project, and pagination is what keeps list endpoints fast and safe as data grows. Master these and the update and delete operations next lesson are small variations on the same moves.
Next Steps
Continue to Lesson 4 - CRUD: Update and Delete
Complete CRUD: update existing rows with a partial-update schema and delete rows by id, each with a 404 guard.
Back to Module Overview
Return to the Databases with SQLModel module overview
Continue Building Your Skills
Your Task Manager can now create and read tasks that persist to a real database. Next you’ll round out CRUD by changing rows that already exist and removing the ones you no longer need — completing the full lifecycle of your data and finishing the database-backed API you set out to build.