Students API — Full CRUD: POST, GET, PUT and DELETE on the Database

Lesson goals
  • Add POST, PUT and DELETE that are connected to the functions in db.py
  • Perform business validation in a shared function, and return 400
  • Translate the contract of db.py into codes — 201, 204, 404 and 409
  • Run the full server and read every response correctly

What will we learn?

We complete the API according to the mapping table:

  • POST /students → INSERT → 201.
  • PUT /students/{id} → UPDATE → 200 / 404 / 409.
  • DELETE /students/{id} → DELETE → 204 / 404.
  • An input model (Pydantic) and shared business validation (400).

Why do we need this?

This is the short project that sums up Server + Database + CRUD, as the syllabus defines. At the end of the lesson there is a real API: an app, a Frontend or another service can create, read, update and delete students, and the data is saved in the database.

Explanation from the basics

The input model

class StudentIn(BaseModel):
    name: str
    age: int
    email: str

StudentIn describes what the Client sends in POST and in PUT. There is no id in it, because the id is given by the database (POST) or by the Path (PUT). The name ...In is a common convention: “the model of what comes in”.

FastAPI validates the structure and the types (a missing field, text instead of a number → 422, automatically).

Business validation — 400

The rules (age 16–120, a name of at least 2 characters, an email with @) are ours. Check them in one function, which POST and PUT call (DRY):

def validate_student(student):
    if student.age < 16 or student.age > 120:
        raise HTTPException(status_code=400, detail="age must be between 16 and 120")

Three layers of checks, each with its own role:

LayerChecksCode
Pydantic (FastAPI)Structure and types422
validate_student (main.py)Business rules400
The database’s constraints (db.py)Uniqueness, required409 (through a ValueError)

204 No Content

On a successful DELETE there is nothing to return: the student was deleted. status_code=204 says “I succeeded, without a body”. The function simply returns nothing (None), and FastAPI sends an empty response.

Translating a ValueError

try:
    return db.create_student(...)
except ValueError as error:
    raise HTTPException(status_code=409, detail=str(error))

str(error) = the message that db.py wrote (“a student with email … already exists”). The message passes through two layers: from the database (IntegrityError), through db.py (ValueError), to the Client (409 + detail).

Syntax

@app.post("/students", status_code=201)
def create_student(student: StudentIn):            # body
    ...

@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):   # path + body together
    ...

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    ...                                            # return nothing on success

In PUT there is both a Path Parameter and a Body. FastAPI tells them apart: student_id in the path, and student (a model) in the Body.

First code example

The complete main.py:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import db

app = FastAPI(title="Students API")
db.init_db()

class StudentIn(BaseModel):
    name: str
    age: int
    email: str

def validate_student(student):
    if len(student.name.strip()) < 2:
        raise HTTPException(status_code=400, detail="name must have at least 2 characters")
    if student.age < 16 or student.age > 120:
        raise HTTPException(status_code=400, detail="age must be between 16 and 120")
    if "@" not in student.email:
        raise HTTPException(status_code=400, detail="invalid email")

@app.get("/students")
def list_students():
    return db.get_all_students()

@app.get("/students/{student_id}")
def get_student(student_id: int):
    student = db.get_student(student_id)
    if student is None:
        raise HTTPException(status_code=404, detail="student not found")
    return student

@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    validate_student(student)
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))

@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
    validate_student(student)
    try:
        updated = db.update_student(student_id, student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
    if updated is None:
        raise HTTPException(status_code=404, detail="student not found")
    return updated

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    if not db.delete_student(student_id):
        raise HTTPException(status_code=404, detail="student not found")

Running: uvicorn main:app --reload, from the project folder, with the venv active.

Breaking down the code line by line

PartExplanation
validate_student(student)One validation for two Endpoints. raise HTTPException(400) stops the request before the database is touched.
POSTValidation → db.create_student → returns the student from the database (with an id) and 201. A ValueError (the email exists) → 409.
PUTValidation → db.update_student. Two different failures: a ValueError → 409, and None → 404.
updated = ... inside the try, the check for None outside itThe try wraps only what may throw a ValueError (a small try).
DELETEdb.delete_student returns False → 404. Success → no return → 204 without a body.
if not db.delete_student(...)A call and a check in one line. The delete happens in any case, and not turns False into “failed”.

In total: a file of about 60 lines, which contains a full API. Every Route is short, because all the heavy work (SQL, constraints) is in db.py, and all the conversions (JSON, types) are in FastAPI.

Another example

All the server’s real responses — a sequence of requests on an empty database (tested with the code above):

#RequestBodyCodeResponse
1POST /students{"name": "Dana", "age": 24, "email": "dana@mail.com"}201{"id": 1, "name": "Dana", "age": 24, "email": "dana@mail.com"}
2POST /students{"name": "Avi", "age": 31, "email": "avi@mail.com"}201{"id": 2, ...}
3POST /students{"name": "Dana2", "age": 20, "email": "dana@mail.com"}409{"detail": "a student with email dana@mail.com already exists"}
4POST /students{"name": "Kid", "age": 7, "email": "kid@mail.com"}400{"detail": "age must be between 16 and 120"}
5POST /students{"name": "Noa"}422{"detail": [{"type": "missing", "loc": ["body", "age"], ...}, {... "email" ...}]}
6POST /students{"name": "Noa", "age": "abc", "email": "n@m.com"}422{"detail": [{"type": "int_parsing", "loc": ["body", "age"], ...}]}
7GET /students—200[{"id": 1, ...Dana}, {"id": 2, ...Avi}]
8GET /students/99—404{"detail": "student not found"}
9GET /students/abc—422{"detail": [{"type": "int_parsing", "loc": ["path", "student_id"], ...}]}
10PUT /students/2{"name": "Avi Levi", "age": 32, "email": "avi@mail.com"}200{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}
11PUT /students/99{"name": "Ghost", "age": 30, "email": "g@mail.com"}404{"detail": "student not found"}
12DELETE /students/1—204(empty)
13DELETE /students/1—404{"detail": "student not found"}
14GET /students—200[{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}]

And the server’s log (in uvicorn’s Terminal) shows a line for every request, for example:

INFO:     127.0.0.1:49197 - "POST /students HTTP/1.1" 201 Created
INFO:     127.0.0.1:49198 - "GET /students/999 HTTP/1.1" 404 Not Found

Reading the table correctly: each of the rows has a reason in a different layer:

  • 422 (rows 5, 6, 9) — FastAPI and Pydantic. Our code did not run.
  • 400 (row 4) — validate_student.
  • 409 (row 3) — the database (UNIQUE), through db.py.
  • 404 (rows 8, 11, 13) — db.py returned None/False.

Common mistake

Mistake 1: forgetting status_code=201 in POST, or 204 in DELETE. Everything “works”, but with 200. The Client (and automated tests) expect the right codes.

Mistake 2: returning content from a DELETE with 204.

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    db.delete_student(student_id)
    return {"message": "deleted"}

According to the HTTP standard, a 204 must not have a body. Different versions of FastAPI handle this differently (an error, or a body that is thrown away). Either 204 without a return, or 200 with a message. Not both.

Mistake 3: forgetting the check for None in PUT. return db.update_student(...) returns 200 with null for a student that does not exist.

Mistake 4: except Exception in a Route.

try:
    return db.create_student(...)
except Exception:
    raise HTTPException(status_code=409, detail="conflict")

A real bug (for example a missing table) will be returned as “409 Conflict”, and mislead everyone. Catch only ValueError, and everything else will arrive as a 500, so that we see it and fix it.

Mistake 5: duplicated and different validation. An age check in POST “over 16”, and in PUT “over 18”. One function (validate_student).

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

Exercise 1: POST — creating

Add to main.py a model StudentIn (name, age, email) and a POST /students that returns 201. A duplicate email (a ValueError from db.create_student) → 409.

Show solution

main.py

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import db

app = FastAPI(title="Students API")
db.init_db()


class StudentIn(BaseModel):
    name: str
    age: int
    email: str


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
  • A successful creation → 201 with the student (including the id).
  • The same email again → 409 with "a student with email ... already exists".
  • A body without email → 422, even before the function runs.

Exercise 2: Validation rules

Write validate_student(student) that throws an HTTPException with 400 if: the name is shorter than 2 characters, the age is outside 16 to 120, or there is no @ in the email. Run it in POST before saving.

Show solution

main.py (in addition)

MIN_NAME_LENGTH = 2
MIN_AGE = 16
MAX_AGE = 120


def validate_student(student):
    if len(student.name.strip()) < MIN_NAME_LENGTH:
        raise HTTPException(status_code=400, detail="name too short")
    if student.age < MIN_AGE or student.age > MAX_AGE:
        raise HTTPException(status_code=400, detail="age must be 16-120")
    if "@" not in student.email:
        raise HTTPException(status_code=400, detail="invalid email")


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    validate_student(student)
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))

Our checks run before the database is touched: invalid input does not reach the data layer at all.

Exercise 3: PUT — updating

Add PUT /students/{student_id}: validation (400), a student that does not exist (404), an email taken by another student (409), and otherwise 200 with the updated student.

Show solution

main.py (in addition)

@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
    validate_student(student)
    try:
        updated = db.update_student(student_id, student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
    if updated is None:
        raise HTTPException(status_code=404, detail="student not found")
    return updated

Every result of db.update_student is translated into a different code: a dictionary → 200, None → 404, a ValueError → 409.

Exercise 4: DELETE — 204

Add DELETE /students/{student_id} that returns 204 when the delete succeeded, and 404 when there is no such student. Why don’t you return a body with 204?

Show solution

main.py (in addition)

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    if not db.delete_student(student_id):
        raise HTTPException(status_code=404, detail="student not found")

204 No Content says exactly “I succeeded, and there is nothing to return”. That is why the function returns nothing. A second delete of the same id → 404.

Exercise 5: Who decides on which code?

For each request — which code will come back, and who returns it: Pydantic, our code (validate_student), or the translation from db?

1. POST /students   {"name": "Noa", "age": "abc", "email": "noa@mail.com"}
2. POST /students   {"name": "Noa", "age": 10, "email": "noa@mail.com"}
3. POST /students   {"name": "Noa", "age": 22, "email": "dana@mail.com"}   (taken)
4. PUT  /students/99  {"name": "Noa", "age": 22, "email": "noa@mail.com"}
5. DELETE /students/1   (exists)
Show solution
RequestCodeWho
1422Pydantic — the age is not a number
2400validate_student — a business rule
3409Translating a ValueError from db — a duplicate in the database
4404db.update_student returned None
5204Success without a body

Summary

  • POST → db.create_student → 201 + the student. A ValueError → 409.
  • PUT (Path + Body) → db.update_student → 200. None → 404. A ValueError → 409.
  • DELETE → db.delete_student → 204 without a body. False → 404.
  • Three layers of checks: 422 (Pydantic, structure), 400 (validate_student, rules), 409 (the database, uniqueness).
  • Validation in one function for POST and PUT. Catch only ValueError, and everything else stays a 500.
  • A main.py of about 60 lines = a full API, because every layer does its own job.

Official documentation