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
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)
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
jsonifyensures the content type isapplication/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
GET /tasks — List All Tasks
@app.route('/tasks', methods=['GET'])
def get_tasks():
return jsonify(tasks)
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
Key points:
-
request.get_json()parses the incoming JSON body. It returnsNoneif the body isn't valid JSON. - We validate that
titleexists. If not, we return a 400 Bad Request with an error message. - We assign a new
idusing thenext_idcounter. - The
201 Createdstatus 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)
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)
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
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
Similarly, you might want to handle 500 errors gracefully:
@app.errorhandler(500)
def internal_error(error):
return jsonify({"error": "Internal server error"}), 500
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
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
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
Set configuration via environment variables rather than hardcoding. For example:
import os
app = Flask(__name__)
app.config['DEBUG'] = os.environ.get('FLASK_DEBUG', False)
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)