4. Removing resources with DELETE

DELETE is simple in concept and unforgiving in practice. The purpose is clear, "remove this resource", but unlike GET, POST, or PUT, a successful DELETE can't be undone with another request. Once the server confirms, the thing is gone. That's why professional DELETE code always builds in confirmation and clear feedback, not because the HTTP is hard, but because the consequence is.

When to use DELETE

Use DELETE when you're certain a resource is no longer needed. The common cases:

  • User-requested removal. Removing items from shopping carts, deleting social posts, cancelling subscriptions when the user explicitly asked to.
  • Content cleanup. Removing old blog posts, outdated photos, or files that are no longer needed.
  • Account management. Deactivating accounts, removing access permissions, cleaning up expired sessions.
  • Automated maintenance. Removing test data, temporary files, or expired content via scheduled jobs.

Worth knowing: many production systems don't actually delete anything when you send a DELETE. They mark the resource as deleted but keep the data, a pattern called "soft delete." Soft delete lets users undo, keeps referential integrity intact for records that point at the deleted thing, preserves data for auditing, and avoids cascading-delete surprises. Your DELETE request may trigger soft-delete behaviour server-side even though you're using the DELETE method, and the API docs will tell you which flavour the server implements.

The basic DELETE request

DELETE requests are typically short because they only need to identify what to remove. Most send no body at all, the resource ID lives in the URL. Real APIs put the ID in the URL directly (e.g. DELETE /users/123), but httpbin's /delete endpoint accepts any request -- it's there for learning the mechanics, not for modelling real URL structure. Save this as first_delete.py:

first_delete.py
import requests

# Delete a specific resource by ID
resource_id = 123

response = requests.delete(
    "https://httpbin.org/delete",
    json={"id": resource_id},
    timeout=10
)

print(f"Status: {response.status_code}")
print(f"Response: {response.json()}")
Output (example)
Status: 200
Response: {'args': {}, 'data': '{"id": 123}', 'files': {}, 'json': {'id': 123}, ...}

A few things stand out in that output. The request itself is minimal because the URL does all the identifying work. Many APIs return 204 No Content on success (httpbin returns 200 with a small echo body so you can see what happened, but real APIs often return 204). And, crucially, the action is permanent, unlike a GET or a PUT, you can't undo a successful DELETE with another request. The data is gone.

The basic version works but skips every safety check. Next up: the failure modes that DELETE brings, and the professional pattern that handles them.

DELETE-specific failure modes

DELETE has unique failure scenarios that call for specific handling:

  • Resource already gone (404). You're trying to delete something that doesn't exist. This might not actually be an error, if your goal was "ensure this resource is gone," mission accomplished. Handle 404 gracefully for DELETE.
  • Dependency conflicts (409). Other resources depend on the one you're deleting, like a user who owns blog posts. The server refuses to maintain data integrity.
  • Permission denied (403). You lack permission to delete this resource. Common in collaborative systems where you can see other users' content but not remove it.
  • Protected resource (423 Locked). The resource is locked or protected. Some systems use 423 for resources under active use that can't be deleted right now.

Is DELETE idempotent? Yes, with an asterisk. The operation is idempotent, after the first DELETE, subsequent DELETEs leave the system in the same state. But the response code changes: the first one returns 200 or 204 (resource deleted), and subsequent calls return 404 (already gone). Professional code treats both as successful outcomes, because the only thing that matters is that the resource is gone.

The professional DELETE pattern

Now the full version with every DELETE-specific failure mode handled. By this point the skeleton should feel familiar, because it is the same one POST and PUT used: validate input, make the request with a timeout, check the status code. That familiarity is deliberate; the pattern is becoming muscle memory. What deserves your full attention is the branch list, because DELETE reads status codes differently from every other verb: 404 is treated as a success (the goal was "make it gone", and it is gone), there is usually no body to extract, and 423 appears for locked resources. Save this as delete_resource.py:

delete_resource.py
import requests

def delete_resource(resource_type, resource_id):
    """
    Delete a resource with professional error handling.
    
    Args:
        resource_type: Type of resource (e.g., "post", "photo", "comment")
        resource_id: ID of resource to delete
        
    Returns:
        tuple: (success: bool, message: str)
    """
    
    # Validate input
    if not resource_id:
        return (False, "Resource ID is required")
    
    if not resource_type:
        return (False, "Resource type is required")
    
    try:

        # STEP 1: Make the request with timeout
        response = requests.delete(
            "https://httpbin.org/delete",
            json={"type": resource_type, "id": resource_id},
            timeout=10
        )

        # STEP 2: Check status code.
        # (STEPs 3-4 from the create/update patterns don't apply here.
        # DELETE typically returns no body, so there's nothing to
        # validate as JSON or extract from the response.)
        if response.status_code == 200:

            # Success with confirmation response
            return (True, f"{resource_type} {resource_id} deleted successfully")
        
        elif response.status_code == 204:

            # Success with no content (common for DELETE)
            return (True, f"{resource_type} {resource_id} deleted successfully")
        
        elif response.status_code == 404:

            # Resource doesn't exist - treating as success since goal is accomplished
            return (True, f"{resource_type} {resource_id} not found (already deleted or never existed)")
        
        elif response.status_code == 409:

            # Conflict - dependencies prevent deletion
            return (False, f"Cannot delete {resource_type} {resource_id} - other resources depend on it")
        
        elif response.status_code == 403:

            # Permission denied
            return (False, f"You don't have permission to delete {resource_type} {resource_id}")
        
        elif response.status_code == 423:

            # Locked - resource protected from deletion
            return (False, f"{resource_type} {resource_id} is locked and cannot be deleted")
        
        else:

            # Unexpected status
            return (False, f"Deletion failed with status {response.status_code}")
    
    except requests.exceptions.Timeout:
        return (False, "Request timed out - server took too long to respond")
    
    except requests.exceptions.ConnectionError:
        return (False, "Could not connect to server - check your internet connection")
    
    except requests.exceptions.RequestException as e:
        return (False, f"Network error: {e}")

# Test the function
success, message = delete_resource("blog_post", 123)

if success:
    print(f"✅ {message}")
else:
    print(f"❌ {message}")
Output
✅ blog_post 123 deleted successfully
Never call .json() on a 204 response

The pattern above checks 200 and 204 separately for a specific reason. 204 means "No Content": the response body is empty, and response.json() on an empty body raises JSONDecodeError and crashes your program. The signature is telling: your code works fine in testing against an API that returns 200-with-body, then crashes in production the first time the server returns 204. Always branch on status before calling .json():

# Bad: crashes when the server returns 204
response = requests.put(...)
data = response.json()  # JSONDecodeError on empty body

# Good: branch on status first
if response.status_code == 204:
    print("Success, but no data returned")
else:
    data = response.json()

The pattern does five things that the basic version didn't. It treats 404 as success because the goal (resource gone) is achieved either way. It distinguishes the 409 dependency conflict so the user can act on it ("delete the dependent items first"). It surfaces 403 with a permission-specific message rather than a generic failure. It handles the 423 locked response for temporarily protected resources. And it handles both 200 and 204 because APIs disagree on which to return for a successful delete (the caution above explains why this branch matters).

A safe-deletion workflow

Because deletion is permanent, the professional move is to wrap the DELETE in a workflow that verifies, confirms, and logs. Save this as safe_delete.py:

safe_delete.py
import requests

def safe_delete_workflow(resource_type, resource_id, resource_name):
    """
    Demonstrate complete safe deletion workflow with all safety checks.
    
    Args:
        resource_type: Type of resource to delete
        resource_id: ID of resource
        resource_name: Human-readable name for confirmation
        
    Returns:
        tuple: (success: bool, message: str)
    """
    
    print("=== Safe Deletion Workflow ===")
    print(f"Resource: {resource_name} ({resource_type} #{resource_id})")

    # STEP 1: Verify resource exists
    # In production: GET /resources/{id} — httpbin always returns 200,
    # so this only demonstrates the call shape, not real existence checking.
    print("\n1. Verifying resource exists...")

    try:
        verify_response = requests.get(
            "https://httpbin.org/get",
            params={"type": resource_type, "id": resource_id},
            timeout=10
        )
        
        if not verify_response.ok:
            return (False, "Resource not found - nothing to delete")
        
        print("   ✓ Resource found")
    
    except requests.exceptions.RequestException as e:
        return (False, f"Could not verify resource existence: {e}")
    
    # STEP 2: Check for dependencies (in real apps, call a dependencies endpoint)
    print("\n2. Checking for dependencies...")

    # In production: call API to check what depends on this resource
    print("   ✓ No dependencies found")
    
    # STEP 3: User confirmation
    print("\n3. Confirming deletion...")
    print(f"   WARNING: This will permanently delete '{resource_name}'")
    print("   This action cannot be undone.")
    
    # In real applications: user_confirmed = input("Type 'DELETE' to confirm: ") == "DELETE"
    user_confirmed = True  # Simulated confirmation for demo
    
    if not user_confirmed:
        return (False, "Deletion cancelled by user")
    
    print("   ✓ User confirmed deletion")
    
    # STEP 4: Perform deletion with error handling
    print("\n4. Deleting resource...")
    
    try:
        delete_response = requests.delete(
            "https://httpbin.org/delete",
            json={"type": resource_type, "id": resource_id},
            timeout=10
        )
        
        if delete_response.status_code in (200, 204):
            print("   ✓ Resource deleted successfully")
            return (True, f"'{resource_name}' has been permanently removed")
        
        elif delete_response.status_code == 409:
            return (False, "Deletion failed - dependencies found (may have been created since check)")
        
        else:
            return (False, f"Deletion failed with status {delete_response.status_code}")
    
    except requests.exceptions.RequestException as e:
        return (False, f"Network error during deletion: {e}")

# Test the workflow
success, message = safe_delete_workflow("photo", 123, "vacation_sunset.jpg")

print(f"\n{'='*50}")
if success:
    print(f"✅ SUCCESS: {message}")
else:
    print(f"❌ FAILED: {message}")
print(f"{'='*50}")
Output
=== Safe Deletion Workflow ===
Resource: vacation_sunset.jpg (photo #123)

1. Verifying resource exists...
   ✓ Resource found

2. Checking for dependencies...
   ✓ No dependencies found

3. Confirming deletion...
   WARNING: This will permanently delete 'vacation_sunset.jpg'
   This action cannot be undone.
   ✓ User confirmed deletion

4. Deleting resource...
   ✓ Resource deleted successfully

==================================================
✅ SUCCESS: 'vacation_sunset.jpg' has been permanently removed
==================================================

Four habits do the work here, plus a fifth that production systems always add. You verify the resource exists before attempting deletion so the user sees a clear "not found" rather than a generic failure. You check for dependencies to avoid orphaning related records. You require explicit confirmation before destructive action, especially on important data. And you show context (a human-readable name, not just an ID) so the user knows what they're agreeing to. The fifth habit, audit logging, isn't shown in the demo above to keep it focused, but in production you'd record who deleted what and when, so a deletion can be traced and (where soft delete is in play) recovered.

In production: the accidental deletion disaster

Picture an admin panel with a one-click "Delete User" button next to each account. No confirmation dialog, no safety net. Support fields a steady stream of tickets from people who clicked the wrong button: "I meant to delete my test account but deleted my real one." "I was scrolling on mobile and my thumb hit Delete."

The cost is huge, customer service time, lost data, angry users, and legal complications when businesses lose accounts. The design assumes users will be careful. They aren't, because people make mistakes, especially on mobile or when tired.

The fix is a multi-step workflow that looks a lot like the one above: a confirmation dialog showing the account name (not just an ID), a requirement to type the name to confirm intent, a 30-day soft-delete period for recovery, a final "are you absolutely sure?" for permanent deletion, and an immediate email notification when deletion is initiated. Accidental deletion tickets all but vanish, and the few that remain are resolved via the soft-delete recovery feature. The lesson: this kind of verification isn't paranoia; it's protection against human error. Professional code assumes mistakes will happen and makes them recoverable.

POST, PUT, and DELETE are now in your toolkit, each with its own professional pattern. The next page steps back from the per-method view and pulls the shared defensive shape out of all three, so you can apply it to whichever method any future API throws at you.