Intermediate

If you have ever wanted to build a backend that serves data to a mobile app, a frontend framework like React, or even another Python script, you need a REST API. REST APIs are the backbone of modern software — they let different systems communicate over HTTP using a standardized set of operations. Whether you are building a to-do app, a dashboard, or a microservice, the ability to create an API is one of the most practical skills a Python developer can have.

Flask is one of the best frameworks for building REST APIs in Python. It is lightweight, flexible, and stays out of your way — you can go from zero to a working API in under 20 lines of code. Flask does not force you into any particular project structure or ORM, which makes it perfect for learning and for small-to-medium projects. You will need Python 3.8 or later, and installing Flask is a single pip install flask command.

In this article we will build a REST API from scratch using Flask. We will start with a quick working example, then cover what REST means and how HTTP methods map to CRUD operations. From there we will build routes for creating, reading, updating, and deleting resources, add proper error handling, learn how to test our API with curl and Python’s requests library, and finish with a complete real-life project — a Bookmark Manager API that stores bookmarks in a JSON file with tagging and search. By the end, you will be ready to build any API you need.

Building a REST API With Flask: Quick Example

Here is the shortest working Flask API. It defines two endpoints — one that returns a welcome message and one that returns a list of books as JSON. You can run this and test it in your browser immediately.

# quick_api.py
from flask import Flask, jsonify

app = Flask(__name__)

books = [
    {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes"},
    {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho"},
]

@app.route("/")
def home():
    return jsonify({"message": "Welcome to the Book API"})

@app.route("/api/books")
def get_books():
    return jsonify(books)

if __name__ == "__main__":
    app.run(debug=True)

Output (visiting http://127.0.0.1:5000/api/books):

[
  {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes"},
  {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho"}
]

Run the script with python quick_api.py, then open http://127.0.0.1:5000/api/books in your browser. You will see the JSON list of books. The @app.route() decorator maps a URL path to a Python function, and jsonify() converts Python dictionaries and lists into proper JSON responses with the correct Content-Type header.

Want to go deeper? Below we cover what REST actually means, how to build full CRUD endpoints, handle errors properly, test your API, and build a complete Bookmark Manager project.

What Is a REST API and Why Use Flask?

REST stands for Representational State Transfer. It is an architectural style for designing networked applications. In practice, a REST API is a web service that uses HTTP methods (GET, POST, PUT, DELETE) to perform operations on resources identified by URLs. When you visit /api/books, you are requesting the “books” resource. When you send a POST request to that same URL with JSON data, you are creating a new book.

The beauty of REST is its simplicity — it maps naturally to the four CRUD operations that every application needs. Here is how HTTP methods correspond to database-style operations:

HTTP MethodCRUD OperationExample URLWhat It Does
GETRead/api/booksRetrieve all books
GETRead/api/books/1Retrieve book with ID 1
POSTCreate/api/booksCreate a new book
PUTUpdate/api/books/1Update book with ID 1
DELETEDelete/api/books/1Delete book with ID 1

Flask is ideal for building REST APIs because it gives you just enough structure without imposing decisions. Unlike Django (which includes an ORM, admin panel, and templating engine), Flask lets you pick only the pieces you need. For a REST API, that means routes, request parsing, and JSON responses — Flask handles all three beautifully out of the box.

Installing Flask and Project Setup

Flask installs in seconds and has no mandatory dependencies beyond its own core libraries. Let us install it and verify everything works.

# setup_check.py
# Install from terminal: pip install flask

# Verify the installation
import flask
print(f"Flask version: {flask.__version__}")

Output:

Flask version: 3.1.0

That is all you need. Flask includes its own development server, so you do not need to install a separate web server for development. For production, you would use a WSGI server like Gunicorn, but for learning and testing, the built-in server is perfect.

Your First Flask Route

A route in Flask is a URL pattern mapped to a Python function. When someone visits that URL, Flask calls your function and returns whatever it sends back. Let us build a slightly more detailed example that shows how routes, methods, and response codes work together.

# first_routes.py
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Welcome to my API",
        "version": "1.0",
        "endpoints": ["/api/hello", "/api/greet?name=YourName"]
    })

@app.route("/api/hello")
def hello():
    return jsonify({"greeting": "Hello, World!"})

@app.route("/api/greet")
def greet():
    # Read a query parameter from the URL
    name = request.args.get("name", "stranger")
    return jsonify({"greeting": f"Hello, {name}!"})

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Output (visiting http://127.0.0.1:5000/api/greet?name=Alice):

{"greeting": "Hello, Alice!"}

The request.args.get() method reads query parameters from the URL. The second argument ("stranger") is the default value if the parameter is not provided. The debug=True flag enables auto-reloading — Flask will restart the server automatically whenever you save changes to your code, which makes development much faster. It also shows detailed error pages in the browser when something goes wrong.

Sudo Sam at a crossroads of glowing routes
Flask routing: because every URL deserves a function that loves it.

Building CRUD Endpoints With JSON

Now let us build a complete set of CRUD endpoints for a books resource. This is the pattern you will use for nearly every REST API — a collection endpoint (/api/books) for listing and creating, and an item endpoint (/api/books/<id>) for reading, updating, and deleting individual records.

# crud_api.py
from flask import Flask, jsonify, request

app = Flask(__name__)

# In-memory data store (replace with a database in production)
books = [
    {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes", "year": 2019},
    {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho", "year": 2022},
    {"id": 3, "title": "Automate the Boring Stuff", "author": "Al Sweigart", "year": 2019},
]
next_id = 4  # Track the next available ID

@app.route("/api/books", methods=["GET"])
def get_all_books():
    return jsonify(books)

@app.route("/api/books/<int:book_id>", methods=["GET"])
def get_book(book_id):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404
    return jsonify(book)

@app.route("/api/books", methods=["POST"])
def create_book():
    global next_id
    data = request.get_json()

    # Validate required fields
    if not data or "title" not in data or "author" not in data:
        return jsonify({"error": "Title and author are required"}), 400

    new_book = {
        "id": next_id,
        "title": data["title"],
        "author": data["author"],
        "year": data.get("year", "Unknown")
    }
    books.append(new_book)
    next_id += 1
    return jsonify(new_book), 201  # 201 = Created

@app.route("/api/books/<int:book_id>", methods=["PUT"])
def update_book(book_id):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404

    data = request.get_json()
    book["title"] = data.get("title", book["title"])
    book["author"] = data.get("author", book["author"])
    book["year"] = data.get("year", book["year"])
    return jsonify(book)

@app.route("/api/books/<int:book_id>", methods=["DELETE"])
def delete_book(book_id):
    global books
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404

    books = [b for b in books if b["id"] != book_id]
    return jsonify({"message": f"Book {book_id} deleted"})

if __name__ == "__main__":
    app.run(debug=True)

Output (POST request to create a book):

# Request: POST /api/books with {"title": "Clean Code", "author": "Robert Martin", "year": 2008}

# Response (201 Created):
{"id": 4, "title": "Clean Code", "author": "Robert Martin", "year": 2008}

There are several important patterns in this code. The methods parameter on @app.route() restricts which HTTP methods a route accepts — without it, Flask only allows GET. The request.get_json() method parses the request body as JSON. We return appropriate HTTP status codes: 201 for successful creation, 404 when a resource is not found, and 400 for bad requests. The <int:book_id> URL parameter tells Flask to extract an integer from the URL and pass it to your function — if someone sends a non-integer, Flask automatically returns a 404.

Error Handling in Flask APIs

A well-designed API needs consistent error responses. Flask lets you register custom error handlers that return JSON instead of the default HTML error pages. This is critical for APIs — your clients are programs, not browsers, and they need machine-readable error messages.

# error_handling.py
from flask import Flask, jsonify, request

app = Flask(__name__)

# Custom error handlers for common HTTP errors
@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found", "status": 404}), 404

@app.errorhandler(405)
def method_not_allowed(error):
    return jsonify({"error": "Method not allowed", "status": 405}), 405

@app.errorhandler(400)
def bad_request(error):
    return jsonify({"error": "Bad request", "status": 400}), 400

@app.errorhandler(500)
def internal_error(error):
    return jsonify({"error": "Internal server error", "status": 500}), 500

# Example route with input validation
@app.route("/api/calculate", methods=["POST"])
def calculate():
    data = request.get_json()
    if not data:
        return jsonify({"error": "Request body must be JSON"}), 400

    a = data.get("a")
    b = data.get("b")
    operation = data.get("operation", "add")

    if a is None or b is None:
        return jsonify({"error": "Fields 'a' and 'b' are required"}), 400

    try:
        a, b = float(a), float(b)
    except (ValueError, TypeError):
        return jsonify({"error": "Fields 'a' and 'b' must be numbers"}), 400

    operations = {
        "add": a + b,
        "subtract": a - b,
        "multiply": a * b,
    }

    if operation == "divide":
        if b == 0:
            return jsonify({"error": "Cannot divide by zero"}), 400
        result = a / b
    elif operation in operations:
        result = operations[operation]
    else:
        return jsonify({"error": f"Unknown operation: {operation}"}), 400

    return jsonify({"result": result, "operation": operation})

if __name__ == "__main__":
    app.run(debug=True)

Output:

# Request: POST /api/calculate with {"a": 10, "b": 3, "operation": "multiply"}
# Response: {"result": 30.0, "operation": "multiply"}

# Request: POST /api/calculate with {"a": 10, "b": 0, "operation": "divide"}
# Response (400): {"error": "Cannot divide by zero"}

# Request: GET /api/nonexistent
# Response (404): {"error": "Resource not found", "status": 404}

The @app.errorhandler() decorator registers functions that handle specific HTTP error codes globally. Every 404 in your entire application will now return JSON instead of an HTML page. This is important because API clients like mobile apps and JavaScript frontends expect JSON responses for everything, including errors. The calculate endpoint demonstrates thorough input validation — checking for missing fields, wrong types, and edge cases like division by zero before processing the request.

Debug Dee examining a cracked error symbol
404: Resource not found. 200: Everything is fine. 500: Run.

Testing Your API With curl and requests

Building an API is only half the job — you also need to test it. The two most common tools for testing REST APIs from the command line are curl (built into most operating systems) and Python’s requests library. Let us see both approaches for testing our books API.

Testing With curl

The curl command is the fastest way to test an endpoint from your terminal. Here are the commands for each CRUD operation:

# testing_curl.sh
# GET all books
curl http://127.0.0.1:5000/api/books

# GET a single book
curl http://127.0.0.1:5000/api/books/1

# POST - create a new book
curl -X POST http://127.0.0.1:5000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Clean Code", "author": "Robert Martin", "year": 2008}'

# PUT - update an existing book
curl -X PUT http://127.0.0.1:5000/api/books/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Python Crash Course (3rd Ed)", "year": 2023}'

# DELETE - remove a book
curl -X DELETE http://127.0.0.1:5000/api/books/3

Output (from the POST command):

{"author":"Robert Martin","id":4,"title":"Clean Code","year":2008}

The -X flag specifies the HTTP method, -H sets headers, and -d sends the request body. Always include Content-Type: application/json when sending JSON data — without it, Flask’s request.get_json() returns None.

Testing With Python’s requests Library

For more complex testing or when you want to automate API tests, Python’s requests library is more convenient than curl:

# test_api.py
import requests

BASE_URL = "http://127.0.0.1:5000/api/books"

# GET all books
response = requests.get(BASE_URL)
print(f"GET all: {response.status_code}")
print(f"Books: {response.json()}\n")

# POST a new book
new_book = {"title": "Clean Code", "author": "Robert Martin", "year": 2008}
response = requests.post(BASE_URL, json=new_book)
print(f"POST: {response.status_code}")
print(f"Created: {response.json()}\n")

# GET a single book
response = requests.get(f"{BASE_URL}/1")
print(f"GET one: {response.status_code}")
print(f"Book: {response.json()}\n")

# PUT update
update_data = {"title": "Python Crash Course (3rd Ed)"}
response = requests.put(f"{BASE_URL}/1", json=update_data)
print(f"PUT: {response.status_code}")
print(f"Updated: {response.json()}\n")

# DELETE
response = requests.delete(f"{BASE_URL}/3")
print(f"DELETE: {response.status_code}")
print(f"Result: {response.json()}")

Output:

GET all: 200
Books: [{"author": "Eric Matthes", "id": 1, ...}, ...]

POST: 201
Created: {"author": "Robert Martin", "id": 4, "title": "Clean Code", "year": 2008}

GET one: 200
Book: {"author": "Eric Matthes", "id": 1, "title": "Python Crash Course", "year": 2019}

PUT: 200
Updated: {"author": "Eric Matthes", "id": 1, "title": "Python Crash Course (3rd Ed)", "year": 2019}

DELETE: 200
Result: {"message": "Book 3 deleted"}

The requests library is much easier to work with programmatically. The json= parameter automatically serializes your dictionary to JSON and sets the correct Content-Type header. The response.json() method parses the response body back into a Python dictionary. This makes it trivial to write automated test scripts that verify your API behaves correctly after every code change.

Real-Life Example: Bookmark Manager API

Pyro Pete launching a colorful rocket ship
Your Flask API just launched into production. Time to celebrate!

Let us build something practical — a Bookmark Manager API that stores website bookmarks with titles, URLs, tags, and timestamps. It supports full CRUD operations, searching by tag, and persists data to a JSON file so bookmarks survive server restarts. This project uses every concept from the article.

# bookmark_api.py
import os
import json
from datetime import datetime
from flask import Flask, jsonify, request

app = Flask(__name__)
DATA_FILE = "bookmarks.json"

def load_data():
    """Load bookmarks from JSON file."""
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, "r") as f:
            return json.load(f)
    return {"bookmarks": [], "next_id": 1}

def save_data(data):
    """Save bookmarks to JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(data, f, indent=2)

@app.route("/api/bookmarks", methods=["GET"])
def get_bookmarks():
    data = load_data()
    tag = request.args.get("tag")  # Optional tag filter
    bookmarks = data["bookmarks"]
    if tag:
        bookmarks = [b for b in bookmarks if tag.lower() in [t.lower() for t in b["tags"]]]
    return jsonify(bookmarks)

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["GET"])
def get_bookmark(bookmark_id):
    data = load_data()
    bookmark = next((b for b in data["bookmarks"] if b["id"] == bookmark_id), None)
    if not bookmark:
        return jsonify({"error": "Bookmark not found"}), 404
    return jsonify(bookmark)

@app.route("/api/bookmarks", methods=["POST"])
def create_bookmark():
    data = load_data()
    body = request.get_json()

    if not body or "url" not in body:
        return jsonify({"error": "URL is required"}), 400

    bookmark = {
        "id": data["next_id"],
        "title": body.get("title", "Untitled"),
        "url": body["url"],
        "tags": body.get("tags", []),
        "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    }
    data["bookmarks"].append(bookmark)
    data["next_id"] += 1
    save_data(data)
    return jsonify(bookmark), 201

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["PUT"])
def update_bookmark(bookmark_id):
    data = load_data()
    bookmark = next((b for b in data["bookmarks"] if b["id"] == bookmark_id), None)
    if not bookmark:
        return jsonify({"error": "Bookmark not found"}), 404

    body = request.get_json()
    bookmark["title"] = body.get("title", bookmark["title"])
    bookmark["url"] = body.get("url", bookmark["url"])
    bookmark["tags"] = body.get("tags", bookmark["tags"])
    save_data(data)
    return jsonify(bookmark)

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["DELETE"])
def delete_bookmark(bookmark_id):
    data = load_data()
    original_count = len(data["bookmarks"])
    data["bookmarks"] = [b for b in data["bookmarks"] if b["id"] != bookmark_id]

    if len(data["bookmarks"]) == original_count:
        return jsonify({"error": "Bookmark not found"}), 404

    save_data(data)
    return jsonify({"message": f"Bookmark {bookmark_id} deleted"})

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

@app.errorhandler(400)
def bad_request(error):
    return jsonify({"error": "Bad request"}), 400

if __name__ == "__main__":
    app.run(debug=True)

Output (testing the API):

# POST - Create bookmarks
curl -X POST http://127.0.0.1:5000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Python Docs", "url": "https://docs.python.org", "tags": ["python", "docs"]}'
# Response: {"id": 1, "title": "Python Docs", "url": "https://docs.python.org", "tags": ["python", "docs"], "created_at": "2026-03-13 15:30:00"}

curl -X POST http://127.0.0.1:5000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Flask Docs", "url": "https://flask.palletsprojects.com", "tags": ["python", "flask", "web"]}'
# Response: {"id": 2, "title": "Flask Docs", ...}

# GET - Filter by tag
curl http://127.0.0.1:5000/api/bookmarks?tag=flask
# Response: [{"id": 2, "title": "Flask Docs", ...}]

# PUT - Update tags
curl -X PUT http://127.0.0.1:5000/api/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{"tags": ["python", "docs", "reference"]}'
# Response: {"id": 1, "title": "Python Docs", "tags": ["python", "docs", "reference"], ...}

# DELETE
curl -X DELETE http://127.0.0.1:5000/api/bookmarks/1
# Response: {"message": "Bookmark 1 deleted"}

This project demonstrates a complete, production-style API pattern. Every endpoint validates its input, returns appropriate status codes, and handles edge cases like missing resources. The tag filtering on the GET endpoint shows how to support query parameters for searching and filtering. The JSON file persistence means bookmarks survive server restarts without needing a database. You could extend this with pagination (limit and offset parameters), full-text search across titles, import/export from browser bookmark files, or a simple HTML frontend.

Frequently Asked Questions

Should I use Flask or Django for my API?

Flask is better for small-to-medium APIs and microservices where you want full control over your stack. Django REST Framework is better when you need an admin panel, ORM, authentication, and other batteries-included features out of the box. If you are building a simple API or learning, start with Flask. If you are building a large application with user accounts and a database, Django might save you time.

How do I connect Flask to a database?

For SQLite (great for learning and small apps), use Python’s built-in sqlite3 module directly. For production, Flask-SQLAlchemy is the most popular choice — it provides an ORM that works with PostgreSQL, MySQL, and SQLite. Install it with pip install flask-sqlalchemy and define your models as Python classes. For simple projects, a JSON file (like we used in the Bookmark Manager) works fine and avoids the complexity of database setup.

Why do I get CORS errors when calling my Flask API from JavaScript?

Browsers block JavaScript from making requests to a different domain than the page was loaded from — this is called Cross-Origin Resource Sharing (CORS). To fix it, install flask-cors with pip install flask-cors, then add CORS(app) to your Flask app. This enables all origins by default. For production, configure it to only allow your specific frontend domain.

How do I deploy a Flask API to production?

Never use Flask’s built-in development server in production — it is single-threaded and not secure. Instead, use a WSGI server like Gunicorn (pip install gunicorn, then gunicorn app:app). Popular deployment platforms include Railway, Render, Heroku, and AWS. For a simple setup, a DigitalOcean droplet with Gunicorn behind Nginx works well and can handle thousands of requests per second.

How do I add authentication to my Flask API?

The simplest approach is API key authentication — check for a key in the request headers. For token-based auth, use Flask-JWT-Extended (pip install flask-jwt-extended), which handles JWT token creation, validation, and refresh. For OAuth and social login, Flask-Login combined with Flask-Dance works well. Start with API keys for internal tools and move to JWT when you need user accounts.

How do I write automated tests for a Flask API?

Flask includes a built-in test client that simulates HTTP requests without starting a server. Use app.test_client() in your test functions with pytest: call client.get("/api/books"), then assert response.status_code == 200 and check response.get_json() for the expected data. This lets you test every endpoint and edge case automatically as part of your CI pipeline.

Conclusion

You now know how to build a complete REST API with Flask. We covered the fundamentals: routing with @app.route(), handling JSON with request.get_json() and jsonify(), building full CRUD endpoints for creating, reading, updating, and deleting resources, returning proper HTTP status codes, implementing custom error handlers, and testing with both curl and Python’s requests library. We tied it all together with a Bookmark Manager API that includes tag-based filtering and JSON file persistence.

The Bookmark Manager is a solid foundation for your own projects. Try extending it with pagination, user authentication, a SQLite database, or a React frontend that consumes the API. The REST patterns you learned here are universal — they apply whether you are building a personal project or a production microservice.

For more advanced Flask features like blueprints, middleware, and extensions, check out the official Flask documentation. For REST API design best practices, the RESTful API tutorial is an excellent reference.