DEV Community

Cover image for Building a REST API with Flask: A Practical Tutorial
Ganesh Bora
Ganesh Bora

Posted on

Building a REST API with Flask: A Practical Tutorial

Building a REST API with Flask: A Practical Tutorial

Flask has a reputation for being small, and that's exactly why it's a great choice for building APIs. There's no magic, no heavy framework imposing its structure on you. You get a request, you return a response, and everything in between is plain Python. In this tutorial, we'll build a working task manager API with full CRUD (Create, Read, Update, Delete) operations. By the end, you'll have a solid foundation for adding databases, authentication, and more.

Why Flask for APIs?

Flask is a micro-framework. That means the core is minimal — routing, request/response handling, and a development server. Everything else (databases, forms, authentication) comes from extensions you add only when you need them. For an API, this keeps your codebase small and readable. You can see every endpoint at a glance, and you're never fighting the framework.

We'll build a simple task manager. Tasks will live in memory for now, which keeps the focus on API design rather than database setup. In a real project you'd swap the in-memory store for a database, but the route logic stays the same.

Setup and a First Route

Start by creating a virtual environment and installing Flask:

python -m venv venv
source venv/bin/activate   # on Windows: venv\Scripts\activate
pip install flask
Enter fullscreen mode Exit fullscreen mode

Create a file named app.py with the following:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/')
def index():
    return jsonify({"message": "Task API is running"})

if __name__ == '__main__':
    app.run(debug=True)
Enter fullscreen mode Exit fullscreen mode

Run it with python app.py and visit http://127.0.0.1:5000/. You'll see a JSON response. Let's break down what's happening:

  • Flask(__name__) creates the application instance.
  • The @app.route('/') decorator tells Flask which URL triggers the function below it.
  • The function returns a response. Using jsonify ensures the content type is application/json.

That's the core pattern: define a function, decorate it with a route, return a response.

Building the CRUD Endpoints

Now we'll add the real endpoints. We'll store tasks in a simple Python list. Each task is a dictionary with an id, a title, and a done flag.

from flask import Flask, jsonify, request

app = Flask(__name__)

tasks = []
next_id = 1
Enter fullscreen mode Exit fullscreen mode

GET /tasks — List All Tasks

@app.route('/tasks', methods=['GET'])
def get_tasks():
    return jsonify(tasks)
Enter fullscreen mode Exit fullscreen mode

Simple enough. But we'll want to return a proper status code and maybe a structured response later. For now, this works.

POST /tasks — Create a Task

@app.route('/tasks', methods=['POST'])
def create_task():
    data = request.get_json()
    if not data or 'title' not in data:
        return jsonify({"error": "Title is required"}), 400
    global next_id
    task = {
        "id": next_id,
        "title": data['title'],
        "done": data.get('done', False)
    }
    tasks.append(task)
    next_id += 1
    return jsonify(task), 201
Enter fullscreen mode Exit fullscreen mode

Key points:

  • request.get_json() parses the incoming JSON body. It returns None if the body isn't valid JSON.
  • We validate that title exists. If not, we return a 400 Bad Request with an error message.
  • We assign a new id using the next_id counter.
  • The 201 Created status code signals that a resource was created.

GET /tasks/ — Retrieve a Single Task

@app.route('/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    task = next((t for t in tasks if t['id'] == task_id), None)
    if task is None:
        return jsonify({"error": "Task not found"}), 404
    return jsonify(task)
Enter fullscreen mode Exit fullscreen mode

Here we use <int:task_id> to tell Flask that this segment of the URL should be converted to an integer. We then search the list for a matching id. If we don't find one, we return 404.

PUT /tasks/ — Update a Task

@app.route('/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    task = next((t for t in tasks if t['id'] == task_id), None)
    if task is None:
        return jsonify({"error": "Task not found"}), 404
    data = request.get_json()
    if not data:
        return jsonify({"error": "Request body must be JSON"}), 400
    task['title'] = data.get('title', task['title'])
    task['done'] = data.get('done', task['done'])
    return jsonify(task)
Enter fullscreen mode Exit fullscreen mode

We allow partial updates: only the fields provided in the request body are changed. This is a common pattern for PUT, though some prefer PATCH for partial updates. For simplicity, we'll stick with PUT.

DELETE /tasks/ — Delete a Task

@app.route('/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    global tasks
    task = next((t for t in tasks if t['id'] == task_id), None)
    if task is None:
        return jsonify({"error": "Task not found"}), 404
    tasks = [t for t in tasks if t['id'] != task_id]
    return '', 204
Enter fullscreen mode Exit fullscreen mode

A 204 No Content response is standard for a successful DELETE. Note that we reassign tasks to a new list without the deleted item. In a real app you'd use a database and a proper delete operation.

Error Handling and Validation

Right now, if a client hits a route that doesn't exist, Flask returns an HTML 404 page. For an API, we want JSON errors. We can add a global error handler:

@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found"}), 404
Enter fullscreen mode Exit fullscreen mode

Similarly, you might want to handle 500 errors gracefully:

@app.errorhandler(500)
def internal_error(error):
    return jsonify({"error": "Internal server error"}), 500
Enter fullscreen mode Exit fullscreen mode

Validation is already handled inline in the POST and PUT handlers. As your API grows, you'll want to centralize validation — perhaps with a library like Marshmallow — but for now, the inline checks keep things clear.

Testing and Running in Production

Testing with pytest

Flask's test client lets you simulate requests without running a server. Install pytest:

pip install pytest
Enter fullscreen mode Exit fullscreen mode

Create a test file test_app.py:

import pytest
from app import app, tasks

@pytest.fixture
def client():
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client

def test_create_task(client):
    response = client.post('/tasks', json={'title': 'Buy milk'})
    assert response.status_code == 201
    data = response.get_json()
    assert data['title'] == 'Buy milk'

def test_get_tasks(client):
    client.post('/tasks', json={'title': 'Walk dog'})
    response = client.get('/tasks')
    assert response.status_code == 200
    assert len(response.get_json()) == 1
Enter fullscreen mode Exit fullscreen mode

Run with pytest. The test client makes it easy to verify your endpoints without spinning up a server.

Running in Production

The built-in development server is not for production. Use a WSGI server like Gunicorn:

pip install gunicorn
gunicorn app:app
Enter fullscreen mode Exit fullscreen mode

Set configuration via environment variables rather than hardcoding. For example:

import os
app = Flask(__name__)
app.config['DEBUG'] = os.environ.get('FLASK_DEBUG', False)
Enter fullscreen mode Exit fullscreen mode

Next Steps

You now have a working REST API with Flask. The in-memory store is fine for learning, but real applications need persistence. Here's where to go next:

  • Database: Use Flask-SQLAlchemy to store tasks in SQLite or PostgreSQL.
  • Blueprints: Organize routes into modules as your app grows.
  • Authentication: Add JWT-based auth with Flask-JWT-Extended.
  • Serialization: Use Marshmallow to validate and serialize request/response data.

Flask's simplicity means you can add these one at a time, without rewriting your existing code. That's the beauty of a micro-framework: it grows with you, not against you.


Written by Ganesh Bora · ganesh@example.com

Top comments (0)