HTTP Status Codes

HTTP status codes matter. Learn correct usage for Python web development with practical steps, edge cases, and what to study next.

Focus: use http status codes correctly

Sponsored

Ever built a Python web API and wondered why your frontend shows a confusing 500 Internal Server Error when a user submits invalid data? Or maybe you've returned a 404 when a resource was actually forbidden? Inconsistent or wrong HTTP status codes are a silent killer of API usability — they break client logic, make debugging a nightmare, and frustrate users. This lesson will teach you the exact rules for using HTTP status codes correctly, so your Python web applications communicate clearly and behave predictably under every circumstance.

The problem this lesson solves

Incorrect HTTP status codes are more than a style issue — they actively break API clients. A frontend that sees a 200 OK for a failed login might display “Welcome” instead of an error message. A monitoring system that treats every 4xx as a 5xx could page you at 3 AM. When your API returns 500 for a missing record, the client can't distinguish between “your server is broken” and “you asked for something that doesn't exist.”

Without a shared understanding of status code semantics, every integration becomes a debugging session. Your API consumers have to guess what your endpoint means when it returns 200 with an error body vs. 201 with an empty response. The pain is real: misused codes cause silent failures, broken retries, and brittle client-side logic. This lesson gives you a mental template to eliminate that guesswork for good.

Core concept / mental model

Think of HTTP status codes as a three-digit language your server speaks to every client — browsers, mobile apps, curl scripts, and other services. Each code has a precise meaning, and getting it right is like using the correct verb in a sentence. Just as you wouldn't say “I'm fine” when you're hurt, you shouldn't return 200 OK when your API failed.

Here's the simple classification, the five families of status codes:

  • 1xx (Informational): The request is received and processing continues. Rarely used in typical web apps.
  • 2xx (Success): The request was received, understood, and accepted. Your API did exactly what was asked.
  • 3xx (Redirection): The client must take additional action to complete the request, like following a redirect.
  • 4xx (Client Error): The request contains bad syntax or cannot be fulfilled. The fault is on the client's side.
  • 5xx (Server Error): The server failed to fulfill an apparently valid request. The fault is on your side.

Pro tip: A great way to build the mental model is to ask yourself “Whose fault is it?” every time you choose a status. If the client made a mistake — missing data, wrong URL, not authenticated — use a 4xx. If your server hit an exception or a database outage — use a 5xx.

The next level is remembering the most common codes in each family:

Code Name Typical meaning
200 OK Successful GET, PUT, DELETE (if no body returned)
201 Created New resource created (POST)
204 No Content Success, but no body to return
301 Moved Permanently Permanent redirect
302 Found Temporary redirect
400 Bad Request Malformed syntax or missing fields
401 Unauthorized Authentication missing or invalid
403 Forbidden Authenticated but not allowed
404 Not Found Resource doesn't exist
409 Conflict Resource state conflicts (e.g., duplicate)
422 Unprocessable Entity Validation errors (semantically invalid)
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Unhandled exception
503 Service Unavailable Server temporarily down/overloaded

This map is your starting point. As you build more, you'll internalize which code fits each scenario — and that's the core skill of this lesson.

How it works step by step

Choosing the right status code isn't random; it follows a logical process. Here's a step-by-step decision framework you can apply to any endpoint:

  1. Identify the request type: Is it a POST (create), GET (read), PUT/PATCH (update), DELETE (remove)?
  2. Check if the request is well-formed: Did the client send valid JSON? Are all required fields present? If not, return 400 Bad Request.
  3. Authenticate and authorize: Is the client logged in? If not, return 401. If logged in but lacks permission, return 403.
  4. Check resource existence: Does the URL point to an existing resource? If not, return 404.
  5. Validate business rules: Even if the data is syntactically valid, does it violate your business logic (e.g., duplicate email)? Return 409 or 422.
  6. Perform the action: If everything passes, execute the operation. Return the appropriate 2xx code.
  7. Handle unexpected exceptions: Wrap your code in a handler that returns 500 for unknown errors — but you should never leak stack traces.

Blockquote callout: For a POST that creates a resource, the correct success code is 201 Created, not 200 OK. It tells the client a new resource exists at the Location header. Many beginners default to 200 and break client expectations.

For example, compare two endpoints:

  • POST /api/users → 201 with the new user JSON (plus a Location header).
  • POST /api/users with a missing email field → 400 Bad Request.
  • POST /api/users with a duplicate email → 409 Conflict.
  • GET /api/users/999 where user 999 doesn't exist → 404 Not Found.

This systematic approach prevents lazy 500 responses and vague 200s. Your API becomes self-documenting, because the status code itself tells the client what happened.

Hands-on walkthrough

Let's put this into practice with a minimal but complete Python web service using Flask. First, install Flask if you don't have it:

pip install flask

Now create a file app.py with a RESTful API for managing tasks:

from flask import Flask, request, jsonify

app = Flask(__name__)

# In-memory storage
tasks = {}
next_id = 1

@app.route('/tasks', methods=['POST'])
def create_task():
    global next_id
    data = request.get_json(silent=True)
    if data is None:
        return jsonify({'error': 'Invalid JSON'}), 400
    if not data.get('title'):
        return jsonify({'error': 'Title is required'}), 400

    task_id = next_id
    next_id += 1
    tasks[task_id] = {'id': task_id, 'title': data['title'], 'done': False}
    return jsonify(tasks[task_id]), 201

@app.route('/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    task = tasks.get(task_id)
    if task is None:
        return jsonify({'error': 'Task not found'}), 404
    return jsonify(task), 200

@app.route('/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    task = tasks.get(task_id)
    if task is None:
        return jsonify({'error': 'Task not found'}), 404
    data = request.get_json(silent=True)
    if data is None:
        return jsonify({'error': 'Invalid JSON'}), 400
    task['title'] = data.get('title', task['title'])
    task['done'] = data.get('done', task['done'])
    return jsonify(task), 200

@app.route('/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    task = tasks.pop(task_id, None)
    if task is None:
        return jsonify({'error': 'Task not found'}), 404
    return '', 204

if __name__ == '__main__':
    app.run(debug=True)

Run it with python app.py, then test it using curl:

# Create a task — expect 201
curl -i -X POST http://localhost:5000/tasks -H "Content-Type: application/json" -d '{"title": "Build API"}'

# Output (abridged):
# HTTP/1.1 201 Created
# ...

# Get a non-existent task — expect 404
curl -i http://localhost:5000/tasks/99

# Output (abridged):
# HTTP/1.1 404 Not Found
# {"error": "Task not found"}

# Delete a task — expect 204
curl -i -X DELETE http://localhost:5000/tasks/1
# Output: HTTP/1.1 204 No Content (no body)

This example demonstrates the key mapping: 400 for bad input, 404 for missing resources, 201 for creation, 200 for reads, and 204 for delete without a body. Every status code you return is now meaningful to the client.

Compare options / when to choose what

When you're unsure between two similar codes, use the following comparisons:

Scenario Code A Code B Why choose which?
Missing authentication 401 Unauthorized 403 Forbidden Use 401 when the client hasn't provided credentials; use 403 when credentials are provided but lack permission.
Invalid data sent 400 Bad Request 422 Unprocessable Entity 400 for malformed JSON; 422 for syntactically valid but semantically invalid fields (e.g., negative age). Some teams use only 400.
Resource not found 404 Not Found 410 Gone 404 when the resource never existed or is currently unavailable; 410 when it was intentionally removed and will not return.
Duplicate resource on create 409 Conflict 400 Bad Request 409 is more semantic — the request is valid but conflicts with current state (duplicate email). Use 400 only for syntax errors.
Server overload 503 Service Unavailable 500 Internal Server Error 503 indicates a temporary condition (e.g., maintenance); 500 is for unexpected errors that are likely permanent bugs.

Pro tip: Some APIs deliberately return 404 instead of 403 on unauthorized requests to avoid leaking that a resource exists. This is a design choice, but be consistent across your entire API to keep clients simple.

There are also variations in how you can implement status code handling:

  • Use Flask's abort() helper for quick responses: abort(404, description="Task not found") — but it's less flexible for custom JSON bodies.
  • Use a custom error handler to globally format all error responses in a consistent shape: @app.errorhandler(404).
  • Use an API framework like FastAPI which comes with automatic validation error responses (422) and OpenAPI documentation that maps status codes to responses.

These are valid alternatives, but the core principle remains: choose the code that best communicates the outcome.

Troubleshooting & edge cases

A few situations trip up even experienced developers. Here are common mistakes and how to fix them:

  • Returning 200 for a failed creation: If your database write fails, you must return a 5xx (e.g., 500 or 503) or a 4xx if it's a client data issue. Never mask failure as success.
  • Using 404 for unauthorized access: It's a valid security pattern, but only if your API consistently hides existence. If you return 404 for one protected endpoint and 403 for another, clients get confusing responses.
  • Forgetting the Location header with 201: The spec says a 201 response should include a Location header pointing to the new resource. Some clients rely on this.
  • Returning a body with 204: A 204 No Content must have an empty body; sending a JSON body with 204 violates the spec and can break HTTP libraries.
  • Uncaught exceptions giving 500 with stack trace: In production, never send exception details to the client. Use a global error handler that logs and returns a generic 500 message.
  • Redirect loops or wrong 3xx codes: Make sure 301 vs 302 are used correctly; a 301 is cached by browsers and changes the request method on redirect, while 302 does not.

If your curl tests show unexpected status codes, start by adding a custom error handler to log the exception.

from werkzeug.exceptions import HTTPException

@app.errorhandler(Exception)
def handle_error(e):
    if isinstance(e, HTTPException):
        return jsonify({'error': e.description}), e.code
    app.logger.error('Exception occurred', exc_info=True)
    return jsonify({'error': 'Internal server error'}), 500

This handler ensures clean JSON errors and prevents stack traces leaking.

What you learned & what's next

You've mastered the core skill of using HTTP status codes correctly. You can now:

  • Explain the five families of status codes and their meaning.
  • Apply a step-by-step decision process to map any API action to the correct response code.
  • Build a complete Flask endpoint that returns 201, 204, 400, 404, and 500 appropriately.
  • Avoid common pitfalls like returning bodies on 204 or mixing up 401/403.

This knowledge is the foundation for building resilient APIs. Next in the path, you'll dive into error handling and validation — learning how to structure error responses consistently and validate incoming data with Pydantic or Flask-JWT. That will make your APIs even more robust and client-friendly.

Keep this status code cheat sheet handy, and always ask yourself: “Does this response tell the client exactly what happened?” If yes, you're on the right track.

Practice recap

Now that you know the rules, harden your skills: extend the task API to support authentication and return 401 for missing tokens, and 403 for unauthorized access to other users' tasks. Also, add a validation for task titles longer than 100 characters and return 422 for that. Test each endpoint with curl and observe the status codes.

Common mistakes

  • Returning 200 OK for a POST that creates a resource — should be 201 Created.
  • Sending a JSON body with a 204 No Content response — the body must be empty.
  • Using 500 for client errors like missing fields — use 400 or 422 instead.
  • Confusing 401 Unauthorized with 403 Forbidden — use 401 for missing credentials, 403 for lacking permission.

Variations

  1. Use Flask's abort() helper for simple error responses.
  2. Implement custom error handlers for consistent JSON error formatting.
  3. Use FastAPI which automatically generates 422 validation responses and OpenAPI docs.

Real-world use cases

  • A SaaS API returns 429 Too Many Requests when a user exceeds their rate limit, letting clients implement retry logic.
  • An e-commerce API returns 409 Conflict when a user tries to place an order with an outdated product price, prompting a re-fetch.
  • A mobile app backend returns 201 Created with a Location header for new user registrations, enabling automatic login flows.

Key takeaways

  • HTTP status codes are a universal language: 4xx for client errors, 5xx for server errors, 2xx for success.
  • Map each API action to the correct code: 201 for create, 200 for read/update, 204 for delete.
  • Always ask 'whose fault is it?' before choosing a status code.
  • Never leak stack traces in 500 responses — use a global error handler.
  • Respect HTTP semantics: 204 has no body, 201 should include Location header.
  • Consistency is key: choose a pattern (e.g., always 400 vs 422) and stick to it across your API.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.