How to Build Your First REST API
Welcome to your first dive into building a REST API! In this guide we’ll walk through the core concepts, set up a simple Python‑Flask service, and explore how to make it production‑ready. By the end you’ll have a fully functional API you can query from a browser, curl, or any front‑end framework. Let’s demystify the process and get your code running in minutes.
Understanding REST Basics
REST (Representational State Transfer) is an architectural style that leverages standard HTTP verbs—GET, POST, PUT, PATCH, DELETE—to manipulate resources identified by URLs. Each resource is typically represented as JSON, making it easy for clients written in JavaScript, Python, or any language to consume. The key principles are statelessness, a uniform interface, and a clear separation between client and server.
Statelessness means the server never stores client context between requests; every request must contain all the information needed to process it. This leads to better scalability because you can spin up multiple instances behind a load balancer without worrying about session affinity. Keep this in mind when designing endpoints: avoid hidden dependencies on server‑side state.
Why Choose Flask for Your First API?
Flask is lightweight, has an intuitive routing system, and integrates seamlessly with extensions like Flask‑RESTful or Flask‑SQLAlchemy. It doesn’t force a particular project structure, so you can start with a single file and evolve into a modular layout as your API grows. Plus, the Python ecosystem offers excellent testing tools (pytest) and deployment options (Gunicorn, Docker).
Setting Up the Development Environment
First, ensure you have Python 3.9+ installed. Then create a virtual environment to isolate dependencies:
python -m venv venv
source venv/bin/activate # On Windows use `venv\Scripts\activate`
pip install flask
Optionally, install flask-cors if you plan to serve requests from a browser front‑end:
pip install flask-cors
Once the environment is ready, create a file named app.py. This will host the core of our API.
Creating Your First Endpoint
Let’s start with a simple “Hello, World!” endpoint that returns JSON. This will illustrate the request‑response cycle without any database involvement.
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/api/v1/greeting', methods=['GET'])
def greeting():
"""Return a friendly greeting."""
return jsonify({
'message': 'Hello, world!',
'status': 'success'
})
if __name__ == '__main__':
app.run(debug=True)
Run the script with python app.py and visit http://127.0.0.1:5000/api/v1/greeting. You should see a JSON payload similar to the one defined in the function. Notice how jsonify automatically sets the Content‑Type: application/json header.
Pro tip: Keep debug=True only during development. In production, switch to a WSGI server like Gunicorn to handle concurrency and avoid exposing detailed tracebacks.
Designing a Real‑World Resource: To‑Do Items
Most APIs revolve around CRUD (Create, Read, Update, Delete) operations on a resource. We’ll model a simple to‑do list where each item has an id, title, description, and completed flag. For brevity, we’ll store data in an in‑memory list; later we’ll discuss swapping it out for a database.
First, define a global list to act as our mock datastore and a helper to generate unique IDs:
todos = []
next_id = 1
def generate_id():
global next_id
current = next_id
next_id += 1
return current
Now add endpoints for each CRUD operation. We’ll group them under the /api/v1/todos namespace.
Creating a To‑Do (POST)
@app.route('/api/v1/todos', methods=['POST'])
def create_todo():
"""Create a new to‑do item."""
data = request.get_json()
if not data or 'title' not in data:
return jsonify({'error': 'Title is required'}), 400
todo = {
'id': generate_id(),
'title': data['title'],
'description': data.get('description', ''),
'completed': False
}
todos.append(todo)
return jsonify(todo), 201
This endpoint validates the incoming JSON, ensures a title exists, and returns the created object with a 201 Created status.
Reading To‑Dos (GET)
@app.route('/api/v1/todos', methods=['GET'])
def list_todos():
"""Return the full list of to‑do items."""
return jsonify(todos)
@app.route('/api/v1/todos/<int:todo_id>', methods=['GET'])
def get_todo(todo_id):
"""Fetch a single to‑do by its ID."""
todo = next((t for t in todos if t['id'] == todo_id), None)
if not todo:
return jsonify({'error': 'Not found'}), 404
return jsonify(todo)
The collection endpoint returns an array, while the item‑specific endpoint returns a single object or a 404 if the ID does not exist.
Updating a To‑Do (PUT/PATCH)
@app.route('/api/v1/todos/<int:todo_id>', methods=['PUT'])
def replace_todo(todo_id):
"""Replace the entire to‑do item."""
data = request.get_json()
if not data or 'title' not in data:
return jsonify({'error': 'Title is required'}), 400
todo = next((t for t in todos if t['id'] == todo_id), None)
if not todo:
return jsonify({'error': 'Not found'}), 404
todo.update({
'title': data['title'],
'description': data.get('description', ''),
'completed': data.get('completed', False)
})
return jsonify(todo)
For partial updates you could add a PATCH endpoint that only modifies supplied fields. Keeping both verbs gives clients flexibility.
Deleting a To‑Do (DELETE)
@app.route('/api/v1/todos/<int:todo_id>', methods=['DELETE'])
def delete_todo(todo_id):
"""Remove a to‑do item."""
global todos
todo = next((t for t in todos if t['id'] == todo_id), None)
if not todo:
return jsonify({'error': 'Not found'}), 404
todos = [t for t in todos if t['id'] != todo_id]
return '', 204
The 204 No Content response signals a successful deletion without returning a body.
Pro tip: Always return appropriate HTTP status codes. They convey intent to clients and simplify error handling on the front end.
Testing Your API with curl and Postman
Before writing automated tests, manually verify each endpoint. Here’s a quick curl checklist:
- Create:
curl -X POST -H "Content-Type: application/json" -d '{"title":"Buy milk"}' http://127.0.0.1:5000/api/v1/todos - List:
curl http://127.0.0.1:5000/api/v1/todos - Retrieve:
curl http://127.0.0.1:5000/api/v1/todos/1 - Update:
curl -X PUT -H "Content-Type: application/json" -d '{"title":"Buy almond milk","completed":true}' http://127.0.0.1:5000/api/v1/todos/1 - Delete:
curl -X DELETE http://127.0.0.1:5000/api/v1/todos/1
Postman offers a visual interface for the same calls, letting you save collections, set environment variables, and generate documentation automatically. When you’re comfortable with manual testing, move on to automated unit tests with pytest and Flask‑testing.
Adding Validation and Error Handling
Real‑world APIs must guard against malformed payloads, missing fields, and unexpected data types. The marshmallow library provides schema validation that integrates nicely with Flask.
pip install marshmallow
Define a schema for our to‑do items:
from marshmallow import Schema, fields, validate, ValidationError
class TodoSchema(Schema):
id = fields.Int(dump_only=True)
title = fields.Str(required=True, validate=validate.Length(min=1))
description = fields.Str()
completed = fields.Bool(default=False)
todo_schema = TodoSchema()
todos_schema = TodoSchema(many=True)
Now incorporate the schema into the create endpoint:
@app.route('/api/v1/todos', methods=['POST'])
def create_todo():
try:
data = todo_schema.load(request.get_json())
except ValidationError as err:
return jsonify(err.messages), 400
data['id'] = generate_id()
todos.append(data)
return todo_schema.jsonify(data), 201
When validation fails, Flask returns a clear 400 Bad Request with a dictionary of field errors. This pattern scales nicely as your models become more complex.
Pro tip: Centralize error handling with Flask’s @app.errorhandler decorator. It lets you format all error responses consistently, reducing duplication across endpoints.
Persisting Data with SQLite
Our in‑memory list disappears when the server restarts. To make the API durable, swap the list for a lightweight SQLite database using SQLAlchemy. Install the required packages:
pip install flask-sqlalchemy
Update app.py to configure the database and define a model:
from flask_sqlalchemy import SQLAlchemy
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class TodoModel(db.Model):
__tablename__ = 'todos'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120), nullable=False)
description = db.Column(db.String(250))
completed = db.Column(db.Boolean, default=False)
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'description': self.description,
'completed': self.completed
}
Initialize the database once (e.g., from a Python shell):
>>> from app import db
>>> db.create_all()
Rewrite the CRUD endpoints to interact with TodoModel instead of the list. For brevity, here’s the updated list_todos function:
@app.route('/api/v1/todos', methods=['GET'])
def list_todos():
todos = TodoModel.query.all()
return jsonify([t.to_dict() for t in todos])
Similar adjustments apply to create, retrieve, update, and delete operations. The transition is straightforward, and you now have persistent storage without leaving the Flask ecosystem.
Versioning and Documentation
Notice the /api/v1/ prefix we used in every route. Versioning protects clients from breaking changes when you evolve the API. When you’re ready to release v2, you can create a new blueprint, keep v1 alive for legacy consumers, and gradually migrate.
Good documentation is as important as code. Tools like Flasgger or Swagger UI can generate interactive docs directly from your route docstrings. Install and set up Flasgger:
pip install flasgger
Then add a simple Swagger config:
from flasgger import Swagger
swagger = Swagger(app)
@app.route('/api/v1/todos', methods=['GET'])
def list_todos():
"""List all to‑do items
---
responses:
200:
description: A list of to‑do objects
schema:
type: array
items:
$ref: '#/definitions/Todo'
"""
# existing implementation
Visiting /apidocs will now display a UI where developers can try out the API live. This reduces onboarding friction and encourages proper usage.
Deploying to Production
When you move beyond local development, you’ll need a production‑grade WSGI server. Gunicorn is the de‑facto standard for Flask apps:
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 app:app
The -w 4 flag spawns four worker processes, leveraging multiple CPU cores. Pair Gunicorn with Nginx as a reverse proxy to handle TLS termination, static asset serving, and request buffering.
Containerization further simplifies deployment. A minimal Dockerfile looks like this:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
Build and run the image with docker build -t todo-api . && docker run -p 8000:8000 todo-api. Your API is now isolated, reproducible, and ready for cloud platforms like AWS ECS, Google Cloud Run, or Azure App Service.
Pro tip: Enable health‑check endpoints (e.g.,/healthz) that return200 OKwhen the database connection is alive. Orchestrators use these probes to restart unhealthy containers automatically.
Testing with pytest
Automated tests protect you from regressions as the API evolves. Flask provides a test client that simulates requests without a real server.
# tests/test_api.py
import json
import pytest
from app import app, db, TodoModel
@pytest.fixture
def client():
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
with app.test_client() as client:
with app.app_context():
db.create_all()
yield client
with app.app_context():
db.drop_all()
def test_create_todo(client):
response = client.post('/api/v1/todos',
data=json.dumps({'title': 'Write tests'}),
content_type='application/json')
assert response.status_code == 201
data = response.get_json()
assert data['title'] == 'Write tests'
assert data['completed'] is False
Run pytest -q to execute the suite. Expand the test file to cover read, update, and delete scenarios, ensuring 100% endpoint coverage before shipping.
Security Considerations
Even a simple API should enforce basic security. Implement