The previous chapter implemented Create and Read operations. Posts and users can be created with POST requests and retrieved with GET requests. Database relationships ensure post responses automatically include full author information, and users contain information about their posts. This chapter completes CRUD by adding Update and Delete functionality, implementing both full (PUT) and partial (PATCH) updates, configuring cascade deletion so deleting a user removes their posts, and testing everything through the interactive documentation.

Understanding PUT vs PATCH

When building REST APIs, two HTTP methods handle updates:

  • PUT — Full replacement: Send all fields for the resource. Think of it as replacing the entire record with a new version. Every field must be provided.
  • PATCH — Partial updates: Send only what has changed. To update just a post’s title, send only the title—everything else remains unchanged.

In practice, PATCH is usually more useful because clients shouldn’t be forced to send the entire resource when changing one or two fields. This chapter implements both to demonstrate their differences and appropriate use cases.

Creating a Schema for Partial Updates

For PATCH requests, all fields must be optional since clients might update only title, only content, or any combination. The existing schemas (PostBase, PostCreate, PostResponse) don’t fit this pattern—they require all fields.

Create PostUpdate in schemas.py:

class PostUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    content: str | None = Field(default=None, min_length=1)

Each field can be a string or None with a default of None, making them optional. Validation still applies when values are provided—if a title is sent, it must still be 1-100 characters.

Critical design decision: user_id is not included in PostUpdate. Typically, ownership shouldn’t change through partial update endpoints. If reassigning a post were necessary, use a PUT request or create a dedicated ownership transfer endpoint.

Import this schema in main.py:

from schemas import PostCreate, PostResponse, PostUpdate, UserCreate, UserResponse

Implementing PUT for Full Updates

Add the full update endpoint after the single post GET route:

@app.put("/api/posts/{post_id}", response_model=PostResponse)
def update_post_full(
    post_id: int,
    post_data: PostCreate,
    db: Annotated[Session, Depends(get_db)]
):
    # Find the post
    result = db.execute(
        select(models.Post).where(models.Post.id == post_id)
    )
    post = result.scalars().first()
    
    # Return 404 if post doesn't exist
    if not post:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Post not found"
        )
    
    # If changing user, verify new user exists
    if post_data.user_id != post.user_id:
        result = db.execute(
            select(models.User).where(models.User.id == post_data.user_id)
        )
        user = result.scalars().first()
        if not user:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="User not found"
            )
    
    # Update all fields
    post.title = post_data.title
    post.content = post_data.content
    post.user_id = post_data.user_id
    
    db.commit()
    db.refresh(post)
    return post

Uses PostCreate for validation because PUT requires all fields, and PostCreate already mandates title, content, and user_id.

Conditional logic flipped: Instead of returning the post if it exists, check if it doesn’t exist and immediately raise a 404. Don’t attempt to update non-existent posts.

User validation: If the new user_id differs from the current user_id, verify the new user exists. This provides a friendlier error than letting the database constraint fail.

Note on code reuse: The logic for checking if users or posts exist is repeated across routes. This signals these checks should be extracted into helper functions, but that refactoring is deferred for now.

All fields updated explicitly: With PUT, every field must be set. post.title, post.content, and post.user_id are all assigned from post_data.

Once authentication is added, only the post’s author will be allowed to update posts. The current permissive approach exists for testing.

Testing the PUT Endpoint

Start the development server and navigate to /docs. The documentation now shows “Update Post Full.”

Expand it and click “Try it out.” Update post ID 1:

{
  "title": "Fully Updated Post",
  "content": "All new content",
  "user_id": 1
}

Execute. The 200 response shows the updated post with the new title, content, and confirmed user.

Changing the user_id to 2 transfers ownership:

Implementing PATCH for Partial Updates

Copy the PUT endpoint as a starting point, then modify it:

@app.patch("/api/posts/{post_id}", response_model=PostResponse)
def update_post_partial(
    post_id: int,
    post_data: PostUpdate,
    db: Annotated[Session, Depends(get_db)]
):
    # Find the post
    result = db.execute(
        select(models.Post).where(models.Post.id == post_id)
    )
    post = result.scalars().first()
    
    # Return 404 if post doesn't exist
    if not post:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Post not found"
        )
    
    # Get only fields that were actually sent
    update_data = post_data.model_dump(exclude_unset=True)
    
    # Dynamically update provided fields
    for field, value in update_data.items():
        setattr(post, field, value)
    
    db.commit()
    db.refresh(post)
    return post

Critical differences from PUT.

Uses PostUpdate with optional fields instead of PostCreate.

No user validation block because PostUpdate doesn’t include user_id.

exclude_unset=True is the key to making PATCH work correctly. Without it, if a client sends {"title": "New Title"}, Pydantic would include all fields with their defaults. Since content defaults to None, it would be included as None in the update, wiping out the content. With exclude_unset=True, only what the client actually sent is included.

Dynamic field setting: Loop over the dictionary from model_dump() and use setattr() to dynamically set each provided field on the post object. If post_data contains {"title": "New Title"}, the loop sets post.title = "New Title" and nothing else.

Testing the PATCH Endpoint

Reload /docs. The documentation now shows “Update Post Partial.”

Get all posts first to see current state. Post ID 1 has “Fully Updated Post” as the title from the PUT test.

Click “Try it out” on the PATCH endpoint. Update post 1:

{
  "title": "Partially Updated Title"
}

Execute. The 200 response shows the title updated but content remained unchanged. This is far more flexible than PUT—clients don’t have to send every field just to change one thing.

Implementing DELETE for Posts

Use the GET endpoint as a starting point:

@app.delete("/api/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)  
def delete_post(post_id: int, db: Annotated[Session, Depends(get_db)]):  
    result = db.execute(select(models.Post).where(models.Post.id == post_id))  
    post = result.scalars().first()  
    if not post:  
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found")  
  
    db.delete(post)  
    db.commit()

Key implementation details:

No response_model because DELETE returns no body. Only status_code=status.HTTP_204_NO_CONTENT is specified.

204 No Content: The request succeeded but there’s no response body. This is the standard status for successful deletions.

Conditional flipped: Raise 404 if the post doesn’t exist; don’t try to delete non-existent resources.

No return statement needed—204 responses have no body.

When authentication is added later, ownership checks will ensure only the post’s author can delete it. Currently, anyone can delete any post for testing purposes.

Testing DELETE

Reload /docs. The documentation shows “Delete Post.” Execute a delete on post ID 1. The response shows 204 with no body.

Attempt to GET post ID 1—it returns 404 Not Found. The deletion succeeded.

HTTP Status Code Review

The series has used several HTTP status codes. Here’s a reference:

  • 200 OK — Successful GET, PUT, or PATCH requests
  • 201 Created — Successful POST for creating users or posts
  • 204 No Content — Successful deletion
  • 400 Bad Request — Duplicate usernames or emails when creating users
  • 404 Not Found — Resource doesn’t exist (user or post)
  • 422 Unprocessable Entity — Validation error (automatic from Pydantic, e.g., invalid email format)

These are standard REST API status codes. Using them correctly makes APIs significantly easier for client applications to work with.

Implementing User Updates and Deletes

Posts now have complete CRUD. Users still only have Create and Read. Add Update and Delete for users as well.

User Update Schema

Create UserUpdate in schemas.py:

class UserUpdate(BaseModel):
    username: str | None = Field(default=None, min_length=1, max_length=50)
    email: EmailStr | None = Field(default=None, max_length=120)
    image_file: str | None = Field(default=None, min_length=1, max_length=200)

Same pattern as PostUpdate—all fields optional with defaults of None, validations still apply when values are provided. This includes image_file allowing users to update their profile picture filename.

Important: Only the filename is stored, not the full path. The image_path property on the User model builds the full path for use in templates.

Import in main.py:

from schemas import PostCreate, PostResponse, PostUpdate, UserCreate, UserResponse, UserUpdate

User PATCH Endpoint

@app.patch("/api/users/{user_id}", response_model=UserResponse)
def update_user(
    user_id: int,
    user_data: UserUpdate,
    db: Annotated[Session, Depends(get_db)]
):
    # Find the user
    result = db.execute(
        select(models.User).where(models.User.id == user_id)
    )
    user = result.scalars().first()
    
    # Return 404 if user doesn't exist
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found"
        )
    
    # Check if new username is already taken
    if user_data.username and user_data.username != user.username:
        result = db.execute(
            select(models.User).where(models.User.username == user_data.username)
        )
        existing = result.scalars().first()
        if existing:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Username already exists"
            )
    
    # Check if new email is already taken
    if user_data.email and user_data.email != user.email:
        result = db.execute(
            select(models.User).where(models.User.email == user_data.email)
        )
        existing = result.scalars().first()
        if existing:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Email already exists"
            )
    
    # Update only provided fields (alternative to setattr approach)
    if user_data.username is not None:
        user.username = user_data.username
    if user_data.email is not None:
        user.email = user_data.email
    if user_data.image_file is not None:
        user.image_file = user_data.image_file
    
    db.commit()
    db.refresh(user)
    return user

Implementation details:

Validation for uniqueness: If a new username is requested, check if it differs from the current username. If it does, verify the new username doesn’t exist in the database. Same logic for email.

Manual field updates demonstrated: Instead of using setattr() like in the post PATCH, this manually checks each field. If user_data.username is not None, it was provided—set it. This approach works well for models with few fields. Some developers unfamiliar with setattr() prefer this explicit style.

Only PATCH, no PUT: Both PUT and PATCH were shown for posts to demonstrate their differences. For users, only PATCH is implemented. In practice, PATCH is usually what you want for both. It’s rare to need complete resource replacement rather than updating specific fields.

Testing User Updates

Reload /docs and find “Update User.” Try updating user 1:

{
  "username": "coreyupdated"
}

Execute. The 200 response shows coreyupdated as the username with everything else unchanged.

Configuring Cascade Deletion

Before implementing user deletion, consider: When a user is deleted, what happens to their posts?

Two options exist:

  1. Prevent deletion if the user has posts — safest approach
  2. Delete the user and cascade delete all their posts — common in real-world applications

Many services display warnings like “Are you absolutely sure? This will delete all of your data” when deleting accounts. This is cascade deletion. Some services (like AWS) require typing the account name to confirm. The application will use cascade deletion to match real-world patterns.

Setting Up Cascade in Models

Update the User model in models.py:

class User(Base):
    __tablename__ = "users"
    
    # ... fields ...
    
    posts: Mapped[list["Post"]] = relationship(
        back_populates="author",
        cascade="all, delete-orphan"
    )

Adding cascade="all, delete-orphan" tells SQLAlchemy: when a user is deleted, delete all their posts. The delete-orphan part also means if a post is somehow removed from the relationship without explicit deletion, it will be cleaned up.

This is a powerful feature—use with caution. In production, warn users before deleting accounts with clear language: “Deleting your account will permanently delete all your posts. Are you absolutely sure?”

User DELETE Endpoint

@app.delete("/api/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)  
def delete_user(user_id: int, db: Annotated[Session, Depends(get_db)]):  
    # Find the user  
    result = db.execute(select(models.User).where(models.User.id == user_id))  
    user = result.scalars().first()  
  
    # Return 404 if user doesn't exist  
    if not user:  
        raise HTTPException(  
            status_code=status.HTTP_404_NOT_FOUND,  
            detail="User not found"  
        )  
  
    db.delete(user)  
    db.commit()

Similar to post deletion: 204 status code, find the user, raise 404 if not found, delete and commit. The cascade configured in models.py automatically deletes all their posts. No return needed for 204 responses.

Testing Cascade Deletion

First, create multiple posts for a user. Check current posts with GET all posts—there’s one post by the current user and posts by Jane Doe (user ID 2).

Create another post by Jane Doe:

{
  "title": "Another Post",
  "content": "Another content block",
  "user_id": 2
}

Now user_id 2 has multiple posts.

Delete user 2 (Jane Doe). The 204 response confirms success.

Attempt to GET user 2—404 Not Found. The user is deleted.

Here’s the cascade in action: GET all posts. Only posts from the remaining user appear. All of Jane Doe’s posts were completely deleted when her account was deleted.

Updating Profile Pictures

Currently all users have the default profile picture from /static/profile_pics/default.jpg. But image_file was added to UserUpdate, so users can have custom profile pictures.

For now, manually add an image to the media directory. Real applications will have file upload functionality (added in a later tutorial), but for testing, copy a file into media/profile_pics/.

Update user 1 with a PATCH request:

{
  "image_file": "corey.png"
}

Execute. The 200 response shows image_file set to corey.png. The image_path automatically calculates it should be /media/profile_pics/corey.png instead of the static directory.

This is the image_path property working: Only the filename is stored in the database, but the property builds the full path dynamically. This was set up in the User model.

Reload the homepage. The profile picture now uses the uploaded image instead of the default. Profile picture updates work, but file upload functionality doesn’t exist yet—files must be manually placed in the media directory. A future chapter adds upload functionality.

Summary

This chapter completed CRUD operations for both users and posts. Full CRUD functionality now exists with proper validation, status codes, and error handling following REST principles. Database relationships work seamlessly, automatically including author information in post responses. Cascade deletion is configured properly—when a user is deleted, all their posts are removed.

The patterns learned here apply to virtually any resource in an API. Both PUT and PATCH were demonstrated for posts to show the difference between full and partial updates. For users, only PATCH was implemented since it’s typically preferred. DELETE operations return 204 No Content with no response body. Proper HTTP status codes (200, 201, 204, 400, 404, 422) are used throughout.

The next chapter introduces async/await to the application. Currently all database operations are synchronous. Converting them to async will improve performance when handling multiple requests. The chapter will cover when to use async versus sync in applications. Following that, code will be reorganized with API routers—the main.py file is becoming lengthy and needs to be split into a more professional code organization structure.