Build a REST API with Flask and Python

Flask API connecting client requests to task records through CRUD operations

What You’ll Learn

In this lesson, you will build a small task-tracking service with Flask. You will create REST endpoints that let clients create, read, update, and delete tasks using HTTP requests.

  • How Flask routes map HTTP methods to Python functions
  • How to return JSON responses and appropriate HTTP status codes
  • How to validate request data
  • How to implement CRUD operations for task records
  • Why in-memory storage is useful for learning but limited in production
Ad

The Concept

A REST API exposes resources through URLs and uses HTTP methods to describe actions on those resources. In a task-tracking service, the resource is a task.

  • GET retrieves tasks.
  • POST creates a new task.
  • PATCH partially updates an existing task.
  • DELETE removes a task.

Flask connects each URL and HTTP method to a Python function. The function reads request data, performs an operation, and returns a response, commonly as JSON.

The example uses a Python list as temporary storage. This keeps the focus on HTTP and Flask. The data will disappear whenever the application restarts. For persistent task records, you can move this storage layer to a database such as SQLite. See SQLite databases in Python for a useful next step.

Basic Example

Install Flask in your active Python environment:

python -m pip install Flask

Create a file named app.py with the following task API:

from flask import Flask, jsonify, request

app = Flask(__name__)

tasks = [
    {
        "id": 1,
        "title": "Review pull request",
        "description": "Check the API error handling changes",
        "completed": False,
    }
]
next_task_id = 2


@app.get("/tasks")
def list_tasks():
    return jsonify(tasks)


@app.post("/tasks")
def create_task():
    global next_task_id

    data = request.get_json(silent=True) or {}
    title = data.get("title", "").strip()

    if not title:
        return jsonify({"error": "title is required"}), 400

    task = {
        "id": next_task_id,
        "title": title,
        "description": data.get("description", ""),
        "completed": False,
    }
    tasks.append(task)
    next_task_id += 1

    return jsonify(task), 201


@app.get("/tasks/<int:task_id>")
def get_task(task_id):
    task = next((item for item in tasks if item["id"] == task_id), None)

    if task is None:
        return jsonify({"error": "task not found"}), 404

    return jsonify(task)


@app.patch("/tasks/<int:task_id>")
def update_task(task_id):
    task = next((item for item in tasks if item["id"] == task_id), None)

    if task is None:
        return jsonify({"error": "task not found"}), 404

    data = request.get_json(silent=True) or {}

    if "title" in data:
        title = data["title"].strip()
        if not title:
            return jsonify({"error": "title cannot be empty"}), 400
        task["title"] = title

    if "description" in data:
        task["description"] = data["description"]

    if "completed" in data:
        if not isinstance(data["completed"], bool):
            return jsonify({"error": "completed must be a boolean"}), 400
        task["completed"] = data["completed"]

    return jsonify(task)


@app.delete("/tasks/<int:task_id>")
def delete_task(task_id):
    task = next((item for item in tasks if item["id"] == task_id), None)

    if task is None:
        return jsonify({"error": "task not found"}), 404

    tasks.remove(task)
    return "", 204


if __name__ == "__main__":
    app.run()

Start the development server with:

python app.py

A client can create a task by sending a POST request to /tasks with a JSON body such as:

{
    "title": "Write deployment notes",
    "description": "Document the steps for the staging release"
}

Expected Output

The server returns the newly created task with an HTTP status of 201 Created:

{
    "completed": false,
    "description": "Document the steps for the staging release",
    "id": 2,
    "title": "Write deployment notes"
}

How the Code Works

Architecture flow showing clients sending HTTP requests to Flask routes, which validate JSON data and perform CRUD operations on in-memory task records before returning JSON responses with HTTP status codes.
Clients call Flask task routes with HTTP methods; the API validates input, updates temporary task data, and returns JSON with meaningful status codes.

Flask(__name__) creates the application object. The route decorators register functions for specific URL paths and HTTP methods. For example, @app.post("/tasks") means that Flask calls create_task for a POST request to /tasks.

request.get_json(silent=True) attempts to read a JSON request body. The or {} fallback gives the endpoint an empty dictionary when the body is missing or invalid. The endpoint then checks that the required title is present before creating a task.

jsonify converts Python dictionaries and lists into JSON responses. Returning a tuple such as jsonify(task), 201 sets both the response body and the HTTP status code.

The PATCH endpoint updates only fields included by the client. This is different from a full replacement: a request containing only "completed": true leaves the title and description unchanged.

The next expression searches for the first task with the requested ID. If no task is found, the endpoint returns a 404 Not Found response. Returning a useful status code helps clients distinguish successful operations from validation and lookup errors.

The list-based lookup is sufficient for a small demonstration. A larger service would normally use a database query and a database-generated ID. The global counter is also not safe for multiple workers or concurrent requests.

Another Example

REST APIs often need more than basic CRUD. Clients may want to filter tasks or retrieve a summary for a dashboard. Flask exposes query-string values through request.args.

This separate example provides a read-only reporting API. It supports requests such as /tasks?status=open and returns counts through /tasks/summary.

from flask import Flask, jsonify, request

app = Flask(__name__)

tasks = [
    {"id": 1, "title": "Prepare sprint plan", "completed": True},
    {"id": 2, "title": "Test notification endpoint", "completed": False},
    {"id": 3, "title": "Update onboarding guide", "completed": False},
]


@app.get("/tasks")
def filter_tasks():
    status = request.args.get("status")

    if status not in (None, "open", "completed"):
        return jsonify({
            "error": "status must be open or completed"
        }), 400

    if status == "open":
        result = [task for task in tasks if not task["completed"]]
    elif status == "completed":
        result = [task for task in tasks if task["completed"]]
    else:
        result = tasks

    return jsonify({
        "count": len(result),
        "tasks": result,
    })


@app.get("/tasks/summary")
def task_summary():
    completed_count = sum(task["completed"] for task in tasks)

    return jsonify({
        "total": len(tasks),
        "completed": completed_count,
        "open": len(tasks) - completed_count,
    })


if __name__ == "__main__":
    app.run()

A request to /tasks?status=open returns only unfinished tasks. A request to /tasks/summary returns aggregate information without requiring the client to count records itself.

Common Mistakes

  • Forgetting the status code: A successful creation should normally return 201, while a missing resource should return 404. Always consider what the client needs to know about the result.
  • Assuming request JSON is always valid: Clients can send an empty body, malformed JSON, or the wrong data type. Validate required fields before modifying a task.
  • Confusing PATCH and PUT: PATCH changes selected fields. PUT generally replaces the complete representation. Use the method that matches the behavior your API promises.
  • Using in-memory data in production: The list is reset when the process restarts and is not shared reliably between multiple server workers. Use persistent storage for real task data.
  • Leaving debug behavior exposed: Flask’s development server is for local development. Deploy a production application with a suitable WSGI server and add authentication, authorization, and request logging as needed.

Try It Yourself

Run the first application and make requests to it using an API client. Test the following cases:

  • Fetch all tasks with GET /tasks.
  • Fetch task 1 with GET /tasks/1.
  • Update only the completion state with PATCH /tasks/1.
  • Request a task ID that does not exist and inspect the 404 response.
  • Send a new task without a title and inspect the validation error.

Then add a GET /tasks?completed=true feature that filters tasks using a Boolean query parameter. Decide how your endpoint should respond when the parameter has an invalid value.

Challenge

Extend the task service with a full replacement endpoint:

  • Add PUT /tasks/<task_id>.
  • Require both title and completed in the request body.
  • Replace the existing task’s title, description, and completion state.
  • Return 400 if a required field is missing or has the wrong type.
  • Return 404 when the task ID does not exist.
  • Return the updated task with status 200 when the replacement succeeds.

Solution

The following complete solution includes the requested PUT endpoint and keeps the existing list and single-task retrieval behavior:

from flask import Flask, jsonify, request

app = Flask(__name__)

tasks = [
    {
        "id": 1,
        "title": "Review pull request",
        "description": "Check the API error handling changes",
        "completed": False,
    }
]


@app.get("/tasks")
def list_tasks():
    return jsonify(tasks)


@app.get("/tasks/<int:task_id>")
def get_task(task_id):
    task = next((item for item in tasks if item["id"] == task_id), None)

    if task is None:
        return jsonify({"error": "task not found"}), 404

    return jsonify(task)


@app.put("/tasks/<int:task_id>")
def replace_task(task_id):
    task = next((item for item in tasks if item["id"] == task_id), None)

    if task is None:
        return jsonify({"error": "task not found"}), 404

    data = request.get_json(silent=True) or {}

    if "title" not in data or "completed" not in data:
        return jsonify({
            "error": "title and completed are required"
        }), 400

    if not isinstance(data["title"], str) or not data["title"].strip():
        return jsonify({"error": "title must be a non-empty string"}), 400

    if not isinstance(data["completed"], bool):
        return jsonify({"error": "completed must be a boolean"}), 400

    task["title"] = data["title"].strip()
    task["description"] = data.get("description", "")
    task["completed"] = data["completed"]

    return jsonify(task), 200


if __name__ == "__main__":
    app.run()

The solution checks for the task before reading replacement data, validates both required fields, and assigns every replaceable field. Because description is optional, it falls back to an empty string when omitted.

Key Takeaways

  • Flask routes connect URLs and HTTP methods to Python functions.
  • REST CRUD operations commonly use GET, POST, PATCH, PUT, and DELETE.
  • JSON responses and meaningful HTTP status codes make an API easier for clients to use.
  • Request data should be validated before it changes application state.
  • In-memory storage is useful for learning, but production APIs need persistent storage and additional security controls.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
Ad
Ad
Ad