DEV Community

Davis Mark
Davis Mark

Posted on

Building a REST API with Flask: A Beginner's Guide

Building a REST API with Flask: A Beginner's Guide

If you have been writing Python scripts for a while, the natural next step is putting your code behind an API so other applications can use it. A REST API is the most common way to expose functionality over HTTP, and Flask is one of the simplest frameworks for getting one running in minutes. In this guide I will cover the core concepts, build a small but complete API step by step, and highlight the mistakes beginners make most often.

What Is a REST API?

REST stands for Representational State Transfer. It is not a protocol or a library; it is a set of design principles for building web services. When people say "REST API," they usually mean an HTTP service that treats resources as URLs and uses HTTP methods to perform operations on them.

The key ideas are:

  • Resources - Things like users, orders, or products, each identified by a URL
  • HTTP methods - GET, POST, PUT, DELETE, and PATCH map to read, create, replace, update, and delete operations
  • Statelessness - Each request carries everything the server needs; the server does not remember previous requests
  • JSON - The dominant data format for request and response bodies

Why Flask?

Flask is a micro-framework, which means it gives you routing and request handling without forcing a specific project structure. You can start with a single file and grow into a larger application when you need to. Compared to full frameworks like Django, Flask has a shorter learning curve, which makes it ideal for learning API design and for small internal services.

Setting Up the Project

Create a virtual environment and install Flask:

python3 -m venv venv
source venv/bin/activate
pip install flask
Enter fullscreen mode Exit fullscreen mode

Then create a file called app.py with a minimal server:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return {"message": "Hello, API!"}

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. Flask automatically converts a returned dictionary into a JSON response, which is one of the small conveniences that makes it so pleasant to work with.

Designing the Resource

A good API design starts with a clear resource model. For this guide we will build a simple task manager with a Task resource. Each task has:

  • An id - a unique integer
  • A title - the task description
  • A done flag - whether it is completed
  • A created_at timestamp - when it was added

The endpoints follow a predictable pattern:

HTTP Method Endpoint Purpose
GET /tasks List all tasks
GET /tasks/<id> Get a single task
POST /tasks Create a task
PUT /tasks/<id> Replace a task
DELETE /tasks/<id> Delete a task

This mapping is so consistent that clients can often guess the API structure without reading documentation. That predictability is a hallmark of good REST design.

Building the Endpoints

We will use an in-memory list to keep the example focused. In production you would swap this for a database, but the route logic stays the same.

from flask import Flask, request, jsonify
from datetime import datetime

app = Flask(__name__)

tasks = []
next_id = 1

def find_task(task_id):
    return next((t for t in tasks if t["id"] == task_id), None)

@app.route("/tasks", methods=["GET"])
def list_tasks():
    return jsonify(tasks)

@app.route("/tasks", methods=["POST"])
def create_task():
    data = request.get_json(silent=True) or {}
    title = data.get("title", "").strip()
    if not title:
        return jsonify({"error": "title is required"}), 400
    global next_id
    task = {
        "id": next_id,
        "title": title,
        "done": False,
        "created_at": datetime.utcnow().isoformat() + "Z"
    }
    tasks.append(task)
    next_id += 1
    return jsonify(task), 201

@app.route("/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
    task = find_task(task_id)
    if task is None:
        return jsonify({"error": "task not found"}), 404
    return jsonify(task)

@app.route("/tasks/<int:task_id>", methods=["PUT"])
def update_task(task_id):
    task = find_task(task_id)
    if task is None:
        return jsonify({"error": "task not found"}), 404
    data = request.get_json(silent=True) or {}
    if "title" in data:
        task["title"] = data["title"].strip()
    if "done" in data:
        task["done"] = bool(data["done"])
    return jsonify(task)

@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    global tasks
    task = find_task(task_id)
    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 few details are worth calling out because they are common sources of bugs:

  1. <int:task_id> - The converter ensures only integers reach the function; anything else gets a 404 automatically
  2. Status codes matter - Returning 201 for creation, 204 for deletion, and 400/404 for errors lets clients react correctly without parsing the body
  3. request.get_json(silent=True) - Returns None instead of raising when the body is not valid JSON, which simplifies error handling
  4. Timestamps in UTC with a Z suffix - Storing times in UTC avoids timezone bugs when clients in different regions consume the API

Testing with curl

You can test every endpoint from the terminal without any extra tools:

# Create a task
curl -X POST http://127.0.0.1:5000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Flask"}'

# List tasks
curl http://127.0.0.1:5000/tasks

# Update a task
curl -X PUT http://127.0.0.1:5000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"done": true}'

# Delete a task
curl -X DELETE http://127.0.0.1:5000/tasks/1
Enter fullscreen mode Exit fullscreen mode

Each command maps directly to the method table above, which makes curl the perfect companion for learning and debugging.

Handling Errors Consistently

Beginners often return errors in different shapes from different routes, which forces clients to write fragile parsing code. A better approach is to standardize the error format across the whole application:

from flask import Flask, request, jsonify

app = Flask(__name__)

class APIError(Exception):
    def __init__(self, message, status_code=400):
        self.message = message
        self.status_code = status_code

@app.errorhandler(APIError)
def handle_api_error(error):
    return jsonify({"error": error.message}), error.status_code

@app.errorhandler(404)
def handle_404(error):
    return jsonify({"error": "resource not found"}), 404

@app.errorhandler(405)
def handle_405(error):
    return jsonify({"error": "method not allowed"}), 405

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

Now any route can raise APIError("some message", 422) and the client always receives the same {"error": "..."} shape.

Validating Input

Never trust data that arrives over HTTP. At minimum, validate required fields and types before doing anything with them:

def validate_task_payload(data, partial=False):
    errors = []
    if not isinstance(data, dict):
        errors.append("body must be a JSON object")
        return errors
    if not partial or "title" in data:
        title = data.get("title")
        if not isinstance(title, str) or not title.strip():
            errors.append("title must be a non-empty string")
    if "done" in data and not isinstance(data["done"], bool):
        errors.append("done must be a boolean")
    return errors
Enter fullscreen mode Exit fullscreen mode

For larger projects, libraries like Marshmallow or Pydantic handle this declaratively, but a plain function is enough to keep a small API safe.

Adding CORS Support

If your API is consumed by a browser-based frontend running on a different origin, you will hit CORS errors. The simplest fix is the flask-cors extension:

pip install flask-cors
Enter fullscreen mode Exit fullscreen mode
from flask_cors import CORS

CORS(app)  # allow all origins, fine for development
Enter fullscreen mode Exit fullscreen mode

For production, restrict origins to your actual frontend domain instead of using *. CORS is a browser mechanism, not a security boundary, so keep real authentication separate from it.

What About Authentication?

A public API that anyone can call is rarely useful for long. The standard progression for Flask APIs is:

  1. API keys - Simple shared secrets sent in a header like X-API-Key, easy to implement for internal tools
  2. JWT tokens - Stateless tokens that carry claims, common for mobile and SPA backends
  3. OAuth2 - Delegated authorization for third-party applications, heavier but the industry standard

Start with API keys if you are building an internal service. Move to JWT when you need per-user permissions, and consider OAuth2 only when external developers will integrate with your platform.

Common Beginner Mistakes

Here are the mistakes I see most often when reviewing beginner Flask APIs:

  • Returning strings instead of JSON - Flask will send a string as plain text; always return dictionaries, lists, or jsonify(...)
  • Forgetting to set status codes - A successful POST that returns 200 instead of 201 is technically fine but loses information
  • Storing passwords in plain text - Hash them with werkzeug.security or a library like bcrypt, never store raw values
  • Exposing stack traces - Disable debug=True in production or you leak internal paths and library versions
  • Ignoring request size limits - A malicious client can upload a huge body; set MAX_CONTENT_LENGTH on the app
  • Not testing the error paths - Happy-path testing misses the exact code clients will hit first

Wrapping Up

A REST API built with Flask is a great first step into backend development. You now have a working service with resource-based endpoints, consistent error handling, input validation, and a clear path toward authentication. The full example is under two hundred lines, yet it demonstrates every concept that carries over to larger frameworks and real production systems.

Start small, test with curl, and add complexity only when a real requirement demands it.

Top comments (0)