3. Replacing resources with PUT

PUT is the verb for "replace this with that." Where POST creates something new ("add a user"), PUT replaces an existing resource wholesale ("replace user #123 with this new version"). That single distinction drives everything else about how you use it, including one famous footgun we'll get to shortly.

PUT shines when you're sending complete, updated state for a resource, a whole profile, a whole document, a whole configuration. If you only want to change a few fields without resending the rest, you want PATCH instead. PATCH isn't covered in this book, but once you understand PUT's request shape here, PATCH follows the same mechanics with a smaller payload. For now, PUT is the right tool whenever you have the full new version in hand.

When to use PUT

A quick mental check: do you have the complete new state of the resource, or just the parts that changed? If you have all of it, PUT fits. If you only have the delta, you want PATCH.

  • Complete profile updates. Replacing an entire user profile with updated name, bio, preferences, and settings all at once.
  • Document replacement. Replacing the entire content of a document, configuration file, or data record with a new version.
  • Configuration changes. Updating application settings or preferences where you specify the complete new configuration state.
  • File updates. Replacing a whole file with a new version, such as a new profile picture.
The "partial update" trap

A common beginner mistake is treating PUT like a partial update. PUT is a complete replacement. Imagine a profile with name, email, and age. If you only want to change the email and you PUT a body containing only the new email, the server replaces the entire resource with what you sent, and the name and age vanish because you didn't include them. The signature is telling: your "update" silently deletes fields you never touched, and the bug often only surfaces later when someone notices missing data. If you want to change just one field without resending the whole object, you want PATCH, not PUT.

One more thing before the code: PUT is idempotent. Calling it twice with the same data leaves the resource in the same state as calling it once. That's a meaningful difference from POST, where a retry might create a duplicate. When a PUT times out on a flaky network, you can retry without worrying about accidentally doubling up.

Your first PUT request

Here's the bare-bones version to make the mechanics concrete. Save this as first_put.py and run it:

first_put.py
import requests

# Complete updated user profile
updated_profile = {
    "user_id": 123,
    "username": "alice_developer",  # Changed
    "email": "alice.dev@example.com",  # Updated
    "age": 26,  # Birthday happened
    "bio": "Python developer passionate about APIs",  # New field
    "interests": ["python", "apis", "machine learning"]  # Expanded
}

# Send PUT request to replace the resource
response = requests.put(
    "https://httpbin.org/put",
    json=updated_profile,
    timeout=10
)

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

A few things to notice in that output. PUT sent the entire updated resource, every field, not just the ones that changed. Sending the same PUT again would produce the same result (that's idempotency in action). And the server needed to know which resource you were replacing, which is why user_id: 123 is part of the request.

The basic version works, but it's missing error handling for the scenarios unique to updates. Let's line those up before writing the professional version.

PUT-specific failure modes

PUT fails differently than POST because it operates on existing resources. The common failure modes:

  • Resource not found (404). You're trying to update something that doesn't exist. This is the most PUT-specific error, you can't replace what isn't there.
  • Concurrent modification (409). Someone else modified the resource after you retrieved it. Your update would overwrite their changes. APIs detect this with ETags or version numbers.
  • Permission denied (403). You can read the resource but not modify it. Common in collaborative systems with read-only roles.
  • Validation failures (400). Your update data is invalid. Unlike POST (where validation fails before anything is created), PUT validation failures mean you're trying to replace valid data with bad data.

The 409 one deserves a concrete picture. Alice loads user profile #123 at 2:00 PM. Bob updates the same profile at 2:05. Alice makes her changes and sends a PUT at 2:10. Without version checking, her PUT silently overwrites Bob's work. Professional APIs prevent that with versioning or ETags; professional clients handle the 409 response when the version doesn't match.

The professional PUT pattern

Now the full version with every PUT-specific failure mode handled. The skeleton is deliberately the same one you built for POST on the previous page: validate input, make the request with a timeout, check the status code, extract the JSON. You are not learning a new pattern here, and the repetition is the point; you are learning what changes inside it. Read the status-code branches and skim everything else: 204 joins 200 as a success (many APIs return No Content for updates), 404 appears because you cannot replace what does not exist, and 409 changes meaning from "duplicate" to "someone else edited this first". Save this as update_user_profile.py:

update_user_profile.py
import requests

def update_user_profile(user_id, profile_data):
    """
    Update complete user profile with professional error handling.
    
    Args:
        user_id: ID of user to update
        profile_data: Complete profile data dictionary
        
    Returns:
        tuple: (success: bool, updated_data: dict or None, message: str)
    """
    
    # Validate input before sending
    if not user_id:
        return (False, None, "User ID is required")
    
    if not profile_data:
        return (False, None, "Profile data cannot be empty")
    
    # Ensure user_id is in the data
    profile_data["user_id"] = user_id
    
    try:

        # STEP 1: Make the request with timeout
        response = requests.put(
            "https://httpbin.org/put",
            json=profile_data,
            timeout=10
        )
        
        # STEP 2: Check status code
        if response.status_code == 200:

            # Success with response data
            
            # STEP 3: Validate content type
            content_type = response.headers.get("Content-Type", "")
            if "application/json" not in content_type:
                return (False, None, f"Expected JSON but received {content_type}")
            
            # STEP 4: Extract response data
            try:
                response_data = response.json()
                return (True, response_data, "Profile updated successfully")
            except ValueError:
                return (False, None, "Server returned invalid JSON")
        
        elif response.status_code == 204:

            # Success with no response content (common for PUT)
            return (True, None, "Profile updated successfully")
        
        elif response.status_code == 404:

            # Resource doesn't exist
            return (False, None, f"User {user_id} not found - cannot update")
        
        elif response.status_code == 409:

            # Conflict - concurrent modification
            return (False, None, "Profile was modified by someone else - please reload and try again")
        
        elif response.status_code == 403:

            # Permission denied
            return (False, None, "You don't have permission to update this profile")
        
        elif response.status_code == 400:

            # Validation failure
            return (False, None, "Invalid profile data - check required fields and formats")
        
        else:

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

# Test the function
profile_update = {
    "username": "alice_developer",
    "email": "alice.dev@example.com",
    "age": 26,
    "bio": "Python developer passionate about APIs"
}

success, data, message = update_user_profile(123, profile_update)

if success:
    print(f"✅ {message}")
else:
    print(f"❌ {message}")
Output
✅ Profile updated successfully

Each branch in that function earns its place. Input validation stops empty requests before they hit the network. The 404 branch distinguishes "resource doesn't exist" from other failures so the user sees a clear message. The 409 branch handles concurrent modification with an actionable instruction ("reload and try again") rather than a cryptic error. Both 200 and 204 are treated as success, because many APIs return 204 No Content for updates that don't need to echo the resource back. And the structured (success, data, message) return keeps the caller's life simple.

PUT vs POST side by side

Because both methods send a body, new developers confuse them. Here's the cleanest way to hold the two apart, first as a table, then as a runnable comparison, then as a quick analogy.

POST vs PUT at a glance
Characteristic POST PUT
Purpose Create new resource Replace existing resource
Requires ID No (server assigns ID) Yes (you specify which resource)
Idempotent No (multiple calls create multiple resources) Yes (multiple calls same result)
Success Codes 201 Created, 200 OK 200 OK, 204 No Content
Common Errors 409 Conflict (duplicate), 400 Bad Request 404 Not Found, 409 Conflict (concurrent)
Safe to Retry No (might create duplicates) Yes (produces same result)

Now the same contrast in code. Save this as post_vs_put.py:

post_vs_put.py
import requests

# Use POST when creating new resources

# Example: User registration, creating new blog posts

response = requests.post(
    "https://api.example.com/users",  # Collection endpoint
    json={"username": "alice", "email": "alice@example.com"},
    timeout=10
)

# Server assigns new user ID, returns 201 Created

# Use PUT when updating existing resources
# Example: Updating user profile, modifying blog post

response = requests.put(
    "https://api.example.com/users/123",  # Specific resource endpoint
    json={"username": "alice_updated", "email": "alice.new@example.com"},
    timeout=10
)

# Updates user 123, returns 200 OK or 204 No Content

Notice the URL pattern. POST goes to the collection endpoint (/users), and the server decides where the new resource lives. PUT goes to a specific resource endpoint (/users/123), and you tell the server which resource you're replacing. If a URL has an ID at the end, it's probably a PUT target. If it's just a collection name, it's probably a POST target.

An analogy that helps this stick: the API is a parking lot. POST is the valet, you hand over your car and they decide the spot. PUT is you driving directly to spot #123, pulling out whatever's there, and parking your car in its place.

In production: the concurrent edit race

Imagine a customer support platform where two reps sometimes work on the same ticket at once. Rep A adds a note like "Refund approved, $150." Rep B updates the status to "Closed." When Rep B's PUT lands, it overwrites the entire ticket resource, and Rep A's refund note vanishes. The customer never gets their refund because the information was silently lost.

This is the concurrent modification problem, and it's common in collaborative systems. The second PUT overwrites changes made after the first user loaded the data, and neither rep sees an error. The data just vanishes.

Production systems solve this with optimistic locking: each resource carries a version number or timestamp, PUT requests include the version they're updating, and the server returns 409 Conflict when the versions don't match. The user sees "Someone else modified this ticket, please refresh and try again" instead of silent data loss. That's why the professional PUT pattern earlier checks for 409. It's not defensive paranoia; it's the thing keeping real customer data intact.

Idempotency in practice: the double-charge problem

There's one more idempotency wrinkle worth seeing in code, because PUT being idempotent at the protocol level doesn't always mean the operation is. Idempotency decides whether automatic retries are safe, and when a PUT triggers a side effect the server can't undo (a charge, an email, a webhook), the protocol-level guarantee stops applying. The next two examples make the difference concrete. First, a GET that's safe to retry at any time. Save this as safe_get_retry.py and notice that nothing on the server changes no matter how many times you run it:

safe_get_retry.py
import requests

def get_customer_order(order_id):
    response = requests.get(
        f"https://api.shop.com/orders/{order_id}",
        timeout=5
    )
    response.raise_for_status()
    return response.json()

# Safe to call repeatedly — nothing changes on the server
order = get_customer_order(42)
print(order)

Now the counter-example: a PUT that looks safe to retry but isn't, because it triggers a side effect (a card charge) that the server can't undo. Save this as put_double_charge.py and read the except block carefully:

put_double_charge.py
import requests

def update_order_and_charge(order_id, new_items):
    try:
        response = requests.put(
            f"https://api.shop.com/orders/{order_id}",
            json={
                "items": new_items,
                "charge_card": True   # triggers payment
            },
            timeout=5
        )
        response.raise_for_status()
        return response.json()

    except requests.Timeout:
        # The request timed out — but did it go through?
        # The server may have:
        #   1. Never received it (safe to retry)
        #   2. Processed it AND charged the card (retry = double charge!)
        print("Timed out. Retrying...")
        return update_order_and_charge(order_id, new_items)  # DANGEROUS

update_order_and_charge(42, ["shoes", "belt"])
Why this fails in production

The recursive call inside the except requests.Timeout block is the problem. When a timeout fires, you have no way of knowing whether the server processed the request before the connection dropped. If it did, calling the function again charges the customer a second time. PUT is theoretically idempotent, but only if the entire operation, including any side effects like payments, is idempotent too. The professional fix is an idempotency key: a unique token sent with every request so the server can recognise a duplicate and return the original result instead of processing it again.

PUT covers the "U" in CRUD. Next is DELETE, the verb where being idempotent doesn't soften the consequences, because once a resource is gone, no amount of retrying brings it back.