FastAPI para principiantes: tu primera API en Python con documentación automática

Rutas, validación con Pydantic y errores claros sin escribir validaciones a mano, con una API de tienda y el IVA calculado en el servidor

Has hecho un formulario con JavaScript, lo envías con fetch y el servidor se cae porque alguien escribió "gratis" en el campo del precio. O peor: no se cae, guarda el texto tal cual y dos semanas después una suma da un error rarísimo. Comprobar a mano que cada dato llega con el tipo correcto es un trabajo pesado, y lo que más cuesta es acordarse de hacerlo en todas las rutas.

Para eso existe FastAPI, un framework de Python que te deja declarar qué datos espera cada ruta y se encarga él de validarlos, de devolver el error adecuado y, de regalo, de generar una página de documentación donde puedes probar tu API sin escribir ni una línea de JavaScript. Si ya pasaste por el servidor web con Python y Flask, verás que la idea es la misma, pero con mucho menos código repetido.

Vamos a construir la API de una tienda pequeña: listar productos, ver uno por su id y añadir productos nuevos, con el precio con IVA del 21% calculado en el servidor. Sin base de datos todavía; eso va en el siguiente paso.

Preparar el entorno

Necesitas Python instalado. Los ejemplos de la documentación oficial de FastAPI están escritos para Python 3.10 o superior (usan str | None para decir "texto o nada"), así que si estás por debajo, actualiza antes de seguir. Si todavía no lo tienes, el post de Python para principiantes explica la instalación paso a paso.

Lo probé en octubre de 2026 con Python 3.14 (vale cualquier 3.14.x) y FastAPI 0.143.0. Si instalas una versión más nueva, lo que ves aquí debería seguir valiendo, pero si algo no cuadra, mira primero las notas de versión de FastAPI.

Crea una carpeta para el proyecto y un entorno virtual dentro. El entorno virtual es una copia aislada de Python donde se instalan las librerías de este proyecto sin mezclarlas con las de los demás:

Terminal (Mac y Linux) 📋
mkdir tienda-api
cd tienda-api
python3 -m venv .venv
source .venv/bin/activate

En Windows cambian los dos comandos (python3 pasa a ser python y el entorno se activa con otra ruta):

Terminal (Windows) 📋
python -m venv .venv
.venv\Scripts\activate

Si usas PowerShell y te sale un error en rojo que dice que la ejecución de scripts está deshabilitada, es una protección de Windows. Ejecuta Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, confirma con S y vuelve a activar el entorno. También puedes usar el "Símbolo del sistema" (cmd), donde no pasa.

Cuando el entorno está activo, tu terminal muestra (.venv) al principio de la línea. Aquí es donde la mayoría se atasca: instalan FastAPI sin activar el entorno, luego abren otra terminal, y Python dice que no encuentra el módulo. Si te pasa, mira si ves ese (.venv).

Ahora instala FastAPI con el extra standard, que trae también el servidor que ejecuta la API y el comando fastapi para arrancarla:

Terminal 📋
pip install "fastapi[standard]"

Las comillas importan. En zsh (el terminal por defecto de los Mac) sin comillas los corchetes se interpretan como un patrón de archivos y te sale un error de "no matches found". Esto me costó pillarlo la primera vez, porque el mensaje no tiene pinta de tener nada que ver con pip.

Tu primera ruta

Piensa en cómo funciona cualquier web: escribes una dirección, el navegador la pide y un servidor responde. Detrás de cada dirección hay una función que alguien programó para contestar. En FastAPI haces exactamente eso: escribes una función normal de Python y le pones encima un cartel que dice "esta función atiende esta dirección".

Ese cartel se llama decorador y la dirección con su método HTTP se llama ruta (o endpoint, en inglés). Es como la ventanilla de un ayuntamiento: cada ventanilla atiende un único trámite, no hace nada mientras no llega nadie y, cuando llega alguien, ejecuta lo que le tocaba.

Crea un archivo main.py dentro de la carpeta:

Python: main.py 📋
from fastapi import FastAPI

app = FastAPI(title="API de la tienda")


@app.get("/")
def inicio():
    return {"mensaje": "La API de la tienda funciona"}

FastAPI() crea la aplicación. @app.get("/") dice que cuando alguien haga una petición GET a la raíz se ejecute inicio. Y lo que devuelves, un diccionario normal de Python, FastAPI lo convierte solo a JSON, el formato de texto en el que las webs y los servidores se pasan datos. Y una petición GET es la que hace el navegador cuando solo quiere leer algo, sin enviar nada.

Arranca el servidor desde la carpeta donde está main.py:

Terminal 📋
fastapi dev main.py

Verás un mensaje que incluye Uvicorn running on http://127.0.0.1:8000. Abre esa dirección en el navegador y te sale el JSON. El modo dev reinicia el servidor cada vez que guardas el archivo, así que no hace falta pararlo y volver a lanzarlo.

⚠️ Errores típicos al arrancar:

Si el terminal está en otra carpeta, FastAPI te dirá que no puede importar el módulo main: haz un cd a la carpeta correcta. Y no llames fastapi.py a tu archivo: Python lo confundiría con la propia librería y los errores de importación que salen son muy confusos.

La documentación que aparece sola

Con el servidor en marcha, abre http://127.0.0.1:8000/docs. Aparece una página con todas las rutas de tu API, lo que acepta cada una y un botón para probarlas. Es la interfaz que se conoce como Swagger UI. Hay otra versión más pensada para leer en /redoc, y los datos en crudo (el esquema OpenAPI) están en /openapi.json.

Lo útil de verdad es que esa página se genera a partir de tu código. No la mantienes tú: si cambias una ruta, la documentación cambia con ella. Vas a usarla en el resto del post para probar sin escribir clientes.

Listar productos y ver uno concreto

Amplía main.py con una lista de productos en memoria y dos rutas nuevas. De momento los productos son diccionarios sueltos:

Python: main.py 📋
productos = [
    {"id": 1, "nombre": "Teclado mecánico", "precio": 50.00},
    {"id": 2, "nombre": "Ratón inalámbrico", "precio": 20.00},
    {"id": 3, "nombre": "Monitor 24 pulgadas", "precio": 100.00},
]


@app.get("/productos")
def listar_productos():
    return productos


@app.get("/productos/{producto_id}")
def ver_producto(producto_id: int):
    for producto in productos:
        if producto["id"] == producto_id:
            return producto

Las llaves en /productos/{producto_id} marcan una parte variable de la dirección. Si visitas /productos/2, FastAPI pasa un 2 a la función. Y fíjate en producto_id: int: esa anotación no es decoración. Le dice a FastAPI que convierta el valor a número entero y que rechace la petición si no puede.

Pruébalo: ve a /productos/abc. No ejecuta tu función. Devuelve un error con este aspecto:

Respuesta 📋
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "producto_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

El código de estado de esa respuesta es 422, que significa "he entendido tu petición, pero los datos no son válidos". El campo loc te dice dónde falló (en la ruta, en el parámetro producto_id) y input qué valor recibió. Todo eso sin escribir un solo if.

Ahora el primer fallo mío con este código. Visita /productos/99. No hay ningún producto con ese id, y la función termina el bucle sin devolver nada. Python devuelve None y FastAPI lo convierte en null, con código 200, que es "todo ha ido bien". Para el cliente la API dice que ha ido bien y le manda un null. Un frontend que haga producto.nombre con eso se rompe, y el error aparece lejos de donde está el problema.

Lo correcto es avisar de que no existe, con un 404. En FastAPI eso se hace lanzando una excepción, no devolviéndola. Cambia la línea del import y sustituye tu ver_producto por esta versión; no la pegues debajo de la anterior, o FastAPI seguirá usando la primera:

Python: main.py 📋
from fastapi import FastAPI, HTTPException


@app.get("/productos/{producto_id}")
def ver_producto(producto_id: int):
    for producto in productos:
        if producto["id"] == producto_id:
            return producto
    raise HTTPException(status_code=404, detail="Producto no encontrado")

Ojo con raise: si escribes return HTTPException(...) el código funciona sin error, pero lo que recibe el cliente es un 200 con un objeto raro dentro. Es un fallo muy típico y difícil de ver a simple vista.

El orden de las rutas importa

Imagina que quieres una ruta /productos/baratos que devuelva los productos de menos de 30 euros. La añades debajo de ver_producto y, al probarla, te sale el error 422 de antes, diciendo que baratos no es un entero.

Pasa porque FastAPI evalúa las rutas en el orden en que las escribes. Cuando llega /productos/baratos, la primera que encaja es /productos/{producto_id}, que cree que baratos es un id. La solución es poner la ruta fija antes de la que tiene la parte variable. En el código, [p for p in productos if p["precio"] < 30] es una forma compacta de decir "recorre los productos y quédate solo con los que cuestan menos de 30":

Python: main.py 📋
@app.get("/productos/baratos")
def listar_baratos():
    return [p for p in productos if p["precio"] < 30]


@app.get("/productos/{producto_id}")
def ver_producto(producto_id: int):
    ...

Esa línea con corchetes equivale a un bucle for que va metiendo en una lista nueva los productos que cumplen la condición, pero escrito en una sola línea. Si prefieres el bucle de toda la vida, funciona igual. Para repasar bucles y listas, tienes el post de Python para principiantes.

Parámetros en la URL para filtrar

Seguro que has visto direcciones del estilo tienda.es/productos?max_precio=50. Lo que va detrás de la interrogación son parámetros de consulta, y sirven para filtrar o ajustar lo que devuelve una ruta sin cambiar la ruta en sí.

En FastAPI cualquier parámetro de tu función que no aparezca entre llaves en la ruta se convierte automáticamente en uno de estos. Si le das un valor por defecto, es opcional. Sustituye tu listar_productos por esta versión y no la dejes debajo de la anterior: FastAPI usaría la primera ruta que encaja y el filtro no haría nada, sin darte ningún error.

Python: main.py 📋
@app.get("/productos")
def listar_productos(max_precio: float | None = None):
    if max_precio is None:
        return productos
    return [p for p in productos if p["precio"] <= max_precio]

Con /productos devuelve todos. Con /productos?max_precio=60 solo los que cuestan 60 euros o menos. Y si escribes ?max_precio=barato, otra vez el 422, esta vez con query en el campo loc en vez de path.

Añadir productos: el cuerpo de la petición

Hasta ahora el cliente solo pedía datos. Para añadir un producto tiene que enviarlos, normalmente como JSON dentro de una petición POST. Ese JSON se llama cuerpo (body) de la petición, y es lo que ya mandabas con fetch en el post de enviar un formulario al backend.

Aquí entra la pieza que hace especial a FastAPI. Describes la forma que deben tener los datos con una clase, y FastAPI usa Pydantic, la librería de validación que viene con él, para comprobarlos. Es una clase que hereda de BaseModel:

Python: main.py 📋
from pydantic import BaseModel, Field


class ProductoNuevo(BaseModel):
    nombre: str = Field(min_length=2)
    precio: float = Field(gt=0)
    descripcion: str | None = None

Lee la clase como una ficha de lo que aceptas: nombre es texto y obligatorio, con al menos dos caracteres; precio es un número obligatorio y mayor que cero (gt viene de "greater than"); descripcion es opcional porque tiene un valor por defecto. Un campo sin valor por defecto es obligatorio, y esa es toda la regla.

Ahora la ruta que lo usa. Fíjate en que el modelo se pone como parámetro de la función, igual que un tipo cualquiera:

Python: main.py 📋
@app.post("/productos", status_code=201)
def anadir_producto(datos: ProductoNuevo):
    nuevo = {
        "id": len(productos) + 1,
        "nombre": datos.nombre,
        "precio": datos.precio,
    }
    productos.append(nuevo)
    return nuevo

Con eso FastAPI lee el JSON del cuerpo, comprueba que cumple la ficha y, solo si es válido, ejecuta la función con datos ya relleno. El status_code=201 indica "creado", que es lo apropiado cuando un POST añade algo nuevo.

Para probarlo ve a /docs, abre POST /productos, pulsa "Try it out" y envía este cuerpo:

Cuerpo de la petición 📋
{
  "nombre": "Alfombrilla",
  "precio": 10
}

Devuelve el producto con su id. Ahora rómpelo a propósito: cambia el precio a "gratis", o a -5, o borra el nombre. Cada caso devuelve un 422 que señala el campo exacto (body y el nombre del campo en loc) y qué regla no cumple. Tu función ni se entera de que alguien lo intentó.

⚠️ Dos fallos que me tocaron:

Si pruebas el POST con la dirección escrita en el navegador, te sale "Method Not Allowed": el navegador siempre hace GET al escribir una URL. Para probar POST usa /docs. Y no intentes enviar un cuerpo con una ruta GET: la especificación de HTTP no define qué debe pasar y Swagger UI ni lo muestra.

Calcular el IVA en el servidor

Una API de tienda que solo guarda el precio sin impuestos obliga a cada cliente (la web, la app, lo que venga) a calcular el IVA por su cuenta. Y tarde o temprano dos clientes lo calculan distinto. Lo más sensato es hacerlo una vez, en el servidor.

Para eso describimos también la forma de lo que devuelve, con un segundo modelo. Con response_model FastAPI comprueba que la respuesta tiene esa forma, y la documentación la muestra tal cual:

Python: main.py 📋
IVA = 0.21


class Producto(BaseModel):
    id: int
    nombre: str
    precio: float
    precio_con_iva: float
    descripcion: str | None = None


def construir_producto(id: int, datos: ProductoNuevo) -> dict:
    return {
        "id": id,
        "nombre": datos.nombre,
        "precio": datos.precio,
        "precio_con_iva": round(datos.precio * (1 + IVA), 2),
        "descripcion": datos.descripcion,
    }

IVA = 0.21 es el 21% escrito como decimal. El precio con IVA es el precio multiplicado por 1 + 0.21, o sea por 1,21.

Una nota sobre dinero: usar float para precios vale para aprender, pero con decimales binarios hay redondeos que sorprenden (el típico 0.1 + 0.2 que no da 0.3). En una tienda de verdad se guardan los céntimos como enteros o se usa Decimal. Para esta API de práctica nos basta con round(..., 2).

El código completo

Junto todo en un único main.py. Los productos de partida pasan por construir_producto para que ya traigan el IVA calculado. Compáralo con tu versión si algo no te funciona:

Python: main.py 📋
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="API de la tienda")

IVA = 0.21


class ProductoNuevo(BaseModel):
    nombre: str = Field(min_length=2)
    precio: float = Field(gt=0)
    descripcion: str | None = None


class Producto(BaseModel):
    id: int
    nombre: str
    precio: float
    precio_con_iva: float
    descripcion: str | None = None


def construir_producto(id: int, datos: ProductoNuevo) -> dict:
    return {
        "id": id,
        "nombre": datos.nombre,
        "precio": datos.precio,
        "precio_con_iva": round(datos.precio * (1 + IVA), 2),
        "descripcion": datos.descripcion,
    }


productos = [
    construir_producto(1, ProductoNuevo(nombre="Teclado mecánico", precio=50)),
    construir_producto(2, ProductoNuevo(nombre="Ratón inalámbrico", precio=20)),
    construir_producto(3, ProductoNuevo(nombre="Monitor 24 pulgadas", precio=100)),
]


@app.get("/")
def inicio():
    return {"mensaje": "La API de la tienda funciona"}


@app.get("/productos", response_model=list[Producto])
def listar_productos(max_precio: float | None = None):
    if max_precio is None:
        return productos
    return [p for p in productos if p["precio"] <= max_precio]


@app.get("/productos/{producto_id}", response_model=Producto)
def ver_producto(producto_id: int):
    for producto in productos:
        if producto["id"] == producto_id:
            return producto
    raise HTTPException(status_code=404, detail="Producto no encontrado")


@app.post("/productos", response_model=Producto, status_code=201)
def anadir_producto(datos: ProductoNuevo):
    nuevo = construir_producto(len(productos) + 1, datos)
    productos.append(nuevo)
    return nuevo

Con el POST de la alfombrilla a 10 euros, la respuesta trae "precio_con_iva": 12.1. Y fíjate en que /productos/baratos ya no está: lo dejé fuera para que el código completo se quede corto. Si lo quieres, ya sabes dónde va: antes de /productos/{producto_id}.

Otra cosa que notarás: los productos que añades desaparecen cuando se reinicia el servidor, y en modo dev eso pasa cada vez que guardas el archivo. No es un fallo, es lo que cabe esperar de una lista en memoria. Y el id sale de len(productos) + 1, que funciona mientras no borres productos; con una base de datos lo asigna ella.

Llamarla desde tu frontend (y el CORS que te va a saltar)

Cuando tengas una página con JavaScript en otra dirección (por ejemplo 127.0.0.1:5500, la de Live Server en VS Code) y le pidas datos a 127.0.0.1:8000, el navegador bloqueará la respuesta. Verás en la consola un mensaje que habla de CORS. No es un fallo de tu API: el navegador no deja a una web leer datos de otro origen si el servidor no lo permite expresamente. Lo expliqué con calma en el post del formulario con fetch.

Un detalle que despista: si abres el HTML con doble clic (la dirección empieza por file://), el navegador envía como origen null y tu lista de permitidos no lo reconoce. Abre la página con Live Server, que la sirve desde 127.0.0.1:5500.

En FastAPI se soluciona con un middleware que enumera los orígenes permitidos. Nombra los que necesitas, no pongas "*" por comodidad:

Python: main.py 📋
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1:5500", "http://localhost:5500"],
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type"],
)

Para la página necesitas un index.html con una lista vacía y el script al final del body:

HTML: index.html 📋
<ul id="lista"></ul>
<script src="app.js"></script>

Y en app.js, el código para pintar la lista, sin innerHTML, con createElement y textContent:

JavaScript: app.js 📋
async function cargarProductos() {
  const respuesta = await fetch("http://127.0.0.1:8000/productos");
  const productos = await respuesta.json();
  const lista = document.querySelector("#lista");

  productos.forEach((producto) => {
    const li = document.createElement("li");
    li.textContent = `${producto.nombre}: ${producto.precio_con_iva} € con IVA`;
    lista.appendChild(li);
  });
}

cargarProductos();

def o async def

En la documentación de FastAPI verás rutas escritas con async def y aquí he usado def normal. Para empezar es lo más seguro: la propia documentación dice que, si no sabes cuál usar, uses def. async def aporta cuando dentro esperas a algo con await (una librería asíncrona, una llamada a otra API), y mezclarlo con código que bloquea, como una librería de base de datos síncrona, empeora las cosas en vez de mejorarlas. Cuando llegues a ese punto lo verás claro.

Qué toca ahora

El siguiente paso concreto es conectar esta API con una página real. Coge el formulario del post de enviar un formulario al backend con fetch y apunta su fetch a POST /productos. Cuando los errores 422 de FastAPI te lleguen al navegador, tendrás que decidir cómo pintarlos junto a cada campo, y ahí es donde se aprende de verdad.

Cuando eso funcione, el paso siguiente es sustituir la lista en memoria por una base de datos para que los productos sobrevivan a un reinicio. Para quitarte el miedo al SQL, empieza por SQL básico: INSERT, SELECT, UPDATE y DELETE. Y si la lista de productos crece, mira cómo se resuelve en el post de paginación frontend y backend, que incluye un ejemplo con FastAPI.