How to Build Your First REST API
RELEASES Dec. 8, 2025, 5:30 a.m.

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 return 200 OK when 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

Share this article