2. Creating resources with POST

POST is the verb for "create this." Sign-up forms, new posts, new orders, anything that brings a resource into existence happens over POST. This page builds the defensive POST pattern: validate input before sending, branch on every status code POST returns, and surface failures in a structured way the caller can act on. By the end you'll have a runnable create_user.py, a side-by-side of JSON vs form-encoded bodies, and a read on the production trap that turns a timed-out POST into a duplicate order.

Because POST modifies server state, the stakes are higher than GET. A bad POST doesn't just return the wrong data, it creates the wrong data. Professional POST code always validates input before sending and checks status codes after, so problems are caught at the boundary rather than discovered later when a customer complains.

Your first POST request

Here's the bare-bones version so the mechanics are clear. Save this as first_post.py and run it:

first_post.py
import requests

# Data to create
new_user = {
    "username": "alice_coder",
    "email": "alice@example.com",
    "age": 25
}

# Send POST request with JSON data
response = requests.post(
    "https://httpbin.org/post",
    json=new_user,  # Automatically sets Content-Type: application/json
    timeout=10
)

print(f"Status: {response.status_code}")
print(f"Response: {response.json()}")
Output (example)
Status: 200
Response: {'args': {}, 'data': '{"username": "alice_coder", "email": "alice@example.com", "age": 25}', ...}

Run it and httpbin echoes your data back, confirming everything made it through. A few things worth noticing: passing json=new_user serialises the dictionary automatically and sets the Content-Type: application/json header for you. The data travels in the request body, not the URL, so it never shows up in a browser history bar. And httpbin just echoes, so you can see exactly what the server received.

That version works, but it's missing every defensive pattern from Chapter 4. No status check, no validation, no clue what to do when the network misbehaves. Let's look at what can go wrong before we write the professional version.

When POST requests fail

POST has failure modes that GET doesn't. Knowing them in advance is the difference between code that handles problems cleanly and code that hides them.

  • Validation failures (400 Bad Request). The server rejects your data because required fields are missing, formats are invalid, or values don't meet constraints. The resource was never created.
  • Duplicate resources (409 Conflict). You're trying to create something that already exists, like a username that's taken. The server prevents duplicate creation.
  • Authorisation failures (401/403). You lack permission to create. This happens with missing API keys or insufficient access levels.
  • Network timeouts. The request takes too long or the connection drops. The resource might or might not have been created, you can't tell without checking.

That last one, the timeout, is the nastiest of the four. Say your POST takes 30 seconds, then times out. Did the server create the resource before the connection died, or did the timeout fire during transmission? Without proper status checking, you might retry and create a duplicate, or give up when the resource is actually already there. The defensive pattern below solves this for you.

Reading POST status codes

Before the pattern, the status codes the pattern branches on. POST returns different codes than GET because it creates resources, and reading the code correctly tells you whether creation succeeded and, when it didn't, what went wrong:

POST response status codes
Status code Meaning What to do
200 OK Request processed successfully Resource created or processed; check response for details
201 Created New resource created successfully Check Location header for new resource URL
400 Bad Request Invalid data format or missing fields Fix data validation errors; check API docs for requirements
401 Unauthorized Missing or invalid authentication Check API key or login credentials
409 Conflict Resource already exists Use PUT to update or choose different identifier
422 Unprocessable Data syntax correct but semantically invalid Check business rule violations (e.g., invalid date range)
500 Server Error Server problem Not your fault; retry after delay or contact support

Both 200 OK and 201 Created mean success, but they don't mean the same thing. 201 means a new resource was created, and the response often includes a Location header pointing to it. 200 means the request succeeded but might not have created anything new, it could mean the request was processed, queued, or the resource already existed. Professional code checks for both 200 and 201 as success, then reads the body or Location header to understand exactly what happened.

The professional POST pattern

Now the full version with every failure mode handled explicitly. Save this as create_user.py and run it:

create_user.py
import requests

def create_user(username, email, age):
    """
    Create a new user with professional error handling.
    
    Returns:
        tuple: (success: bool, user_data: dict or None, message: str)
    """
    
    # Validate input before sending
    if not username or not email:
        return (False, None, "Username and email are required")
    
    if not isinstance(age, int) or age < 0:
        return (False, None, "Age must be a positive number")
    
    user_data = {
        "username": username,
        "email": email,
        "age": age
    }
    
    try:

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

            # Success - resource created
            
            # 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 and validate response data
            try:
                response_data = response.json()
                return (True, response_data, "User created successfully")
            except ValueError:
                return (False, None, "Server returned invalid JSON")
        
        elif response.status_code == 400:

            # Bad request - invalid data
            return (False, None, "Invalid user data - check format and required fields")
        
        elif response.status_code == 409:

            # Conflict - username already exists
            return (False, None, f"Username '{username}' is already taken")
        
        elif response.status_code == 401 or response.status_code == 403:

            # Authentication/authorisation failure
            return (False, None, "Not authorised to create users")
        
        else:

            # Unexpected status code
            return (False, None, f"Request 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
success, user_data, message = create_user("alice_coder", "alice@example.com", 25)

if success:
    print(f"✅ {message}")
    print(f"Created user: {user_data.get('json', {}).get('username')}")
else:
    print(f"❌ {message}")
Output
✅ User created successfully
Created user: alice_coder

That pattern is longer than the bare-bones version, and every line earns its place. Input validation catches bad data before a request is made, saving bandwidth and surfacing problems instantly. Status code checking distinguishes different failure types so the caller can react appropriately. Content-Type validation means you never try to .json() an HTML error page. Network exception handling separates transport problems from application errors. And the structured return, a (success, data, message) tuple, gives the caller a consistent shape to work with whatever happened.

Verbose? Yes. But it prevents the category of bugs that plague applications without defensive handling, the ones where users see cryptic stack traces and you see no useful signal in the logs.

Sending JSON vs form data

APIs accept POST data in different formats. Modern APIs prefer JSON, but many still use traditional form encoding. The two differ in what they can carry and how the server parses them, and knowing both means you can work with any API you meet. Save this as post_json.py:

post_json.py
import requests

# JSON format - best for complex data structures
user_data = {
    "username": "alice",
    "profile": {
        "email": "alice@example.com",
        "preferences": {
            "theme": "dark",
            "notifications": True
        }
    },
    "tags": ["python", "apis", "coding"]
}

response = requests.post(
    "https://httpbin.org/post",
    json=user_data,  # Handles nested structures naturally
    timeout=10
)

print(f"Status: {response.status_code}")

And the form-encoded version. Save this as post_form.py:

post_form.py
import requests

# Form data - simple key-value pairs only
form_data = {
    "name": "Alice Smith",
    "email": "alice@example.com",
    "subject": "API Question",
    "message": "How do I learn more about POST requests?"
}

response = requests.post(
    "https://httpbin.org/post",
    data=form_data,  # Sends as application/x-www-form-urlencoded
    timeout=10
)

print(f"Status: {response.status_code}")
JSON vs form-encoded POST bodies
Format When to use Advantages Limitations
JSON
json=data
Modern APIs, complex data Supports nesting, arrays, multiple types Slightly more bandwidth
Form Data
data=data
Simple forms, legacy APIs Widely supported, simple Flat key-value pairs only

Which one to use comes down to what the API docs show. If the examples have curly braces and nested objects, use json=data. If they show simple key=value pairs, use data=data. When in doubt, JSON is the safer bet for anything modern.

In production: the duplicate order problem

Here's a failure pattern that shows up in e-commerce apps again and again. When users tap "Place Order," the POST sometimes times out on slow networks. Frustrated users tap again. The result: duplicate orders, double charges, angry support calls.

The bug isn't on the server. It's in the client's retry logic, which blindly re-sends POST requests without checking whether the first one succeeded. Because POST isn't idempotent, every retry creates a new order.

The fix combines three patterns: generate a unique order ID client-side before sending, show a spinner and disable the button to prevent double-taps, and before any retry, check whether an order with that ID already exists. Do all three and duplicate orders all but disappear. The defensive patterns in this section prevent exactly this category of bug, and it's why validation, status handling, and clear user feedback aren't paranoia; they're insurance against real losses.

POST covers the "C" in CRUD. Next up is PUT, the verb for "replace the whole thing."