Delete operations require the same defensive programming as updates: verify the resource exists (404), verify the requester owns it (403), and perform the deletion. The choice between hard delete (permanently removing the row) and soft delete (setting deleted_at to preserve the record) depends on business requirements — soft delete is safer for user-generated content where accidental deletion is possible. Both patterns return 204 No Content on success (no body needed — the resource is gone). An admin-only restore endpoint makes soft delete practically useful.
Hard Delete (204 No Content)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_user
from app.models.post import Post, User
router = APIRouter()
@router.delete(
"/{post_id}",
status_code = status.HTTP_204_NO_CONTENT,
summary = "Delete a post permanently",
)
def delete_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
post = db.get(Post, post_id)
if not post:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Post not found")
if post.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your post")
db.delete(post) # cascade delete-orphan removes comments automatically
# Return None — FastAPI sends 204 No Content with no body
None (Python’s implicit return value) and the decorator has status_code=204. Do not return {"message": "Deleted"} from a 204 route — FastAPI will ignore the return value, but it causes confusion. Some APIs return 200 with a confirmation body for deletions; 204 is the REST convention and is cleaner.slug where deleted_at IS NULL (as covered in Chapter 17). This allows a new post to be created with the same slug as a previously-deleted post, while still preventing duplicate slugs among active posts. Without this, once a slug is used (even by a deleted post), it can never be reused because the standard UNIQUE constraint still applies to the soft-deleted row.deleted_at IS NULL filter to every read query — list, single resource, search, and any related resource queries. It is easy to filter the direct query but forget to filter in a joined subquery. Consider creating a PostgreSQL view that automatically applies the filter, or using SQLAlchemy’s event system to add the filter automatically to all queries on the model.Soft Delete
from datetime import datetime
from sqlalchemy.sql import func
@router.delete(
"/{post_id}",
status_code = status.HTTP_204_NO_CONTENT,
summary = "Soft-delete a post (recoverable)",
)
def soft_delete_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
post = db.get(Post, post_id)
if not post or post.deleted_at is not None:
raise HTTPException(404, "Post not found")
if post.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "Not your post")
post.deleted_at = func.now()
post.deleted_by_id = current_user.id
db.flush()
# Return None → 204 No Content
# ── Admin restore endpoint ────────────────────────────────────────────────────
@router.post(
"/{post_id}/restore",
response_model = PostResponse,
summary = "Restore a soft-deleted post (admin only)",
)
def restore_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
# Must query without the deleted_at IS NULL filter to find deleted posts
post = db.get(Post, post_id)
if not post:
raise HTTPException(404, "Post not found")
if post.deleted_at is None:
raise HTTPException(400, "Post is not deleted")
post.deleted_at = None
post.deleted_by_id = None
db.flush()
db.refresh(post)
return post
Bulk Delete (Admin)
from fastapi import APIRouter, Depends
from sqlalchemy import delete
from pydantic import BaseModel
class BulkDeleteRequest(BaseModel):
post_ids: list[int]
hard_delete: bool = False # default soft delete
@router.delete(
"/bulk",
status_code = 204,
summary = "Bulk delete posts (admin only)",
)
def bulk_delete_posts(
request: BulkDeleteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
if request.hard_delete:
db.execute(
delete(Post).where(Post.id.in_(request.post_ids))
)
else:
db.execute(
update(Post)
.where(Post.id.in_(request.post_ids))
.values(deleted_at=func.now(), deleted_by_id=current_user.id)
)
db.flush()
Common Mistakes
Mistake 1 — Returning a body from a 204 endpoint
❌ Wrong — HTTP spec says 204 must have no body:
@router.delete("/{id}", status_code=204)
def delete_post(id: int, ...):
...
return {"message": "Deleted"} # ignored by FastAPI, confusing to read
✅ Correct — return None (implicit) for 204:
@router.delete("/{id}", status_code=204)
def delete_post(id: int, ...):
... # no return statement → 204 No Content ✓
Mistake 2 — Soft delete without filtering in all queries
❌ Wrong — one query forgets the filter:
@router.get("/search")
def search_posts(q: str, db: Session = Depends(get_db)):
return db.scalars(select(Post).where(Post.title.ilike(f"%{q}%"))).all()
# No deleted_at IS NULL filter! Returns deleted posts!
✅ Correct — every query includes the filter:
return db.scalars(
select(Post).where(Post.title.ilike(f"%{q}%"), Post.deleted_at.is_(None))
).all() # ✓
Mistake 3 — Hard deleting when soft delete is needed
❌ Wrong — no recovery possible:
db.delete(post); db.flush() # post is gone forever!
✅ Consider — use soft delete for user-generated content to allow recovery.
Quick Reference
| Pattern | Code | Recoverable? |
|---|---|---|
| Hard delete | db.delete(post) |
No |
| Soft delete | post.deleted_at = func.now() |
Yes |
| Restore | post.deleted_at = None |
N/A |
| Bulk delete | db.execute(delete(Post).where(Post.id.in_(ids))) |
No |
| Status code | status_code=204 |
No body returned |
| Always filter | .where(Post.deleted_at.is_(None)) |
N/A |