Si trabajas con Machine Learning, seguro conoces esta situación: entrenas un modelo, consigues una métrica decente, lo guardas en un .pkl... y ahí se queda. El modelo vive en tu notebook, pero nadie más puede usarlo. No hay forma de que una app, un frontend o un compañero de equipo lo consuman.

Ahí es donde entra FastAPI. Es uno de los frameworks que más se usan últimamente para resolver justo ese problema: convertir un modelo entrenado en algo que cualquier sistema pueda llamar mediante una simple petición HTTP. En este artículo te voy a enseñar cómo construir una API que sirva un modelo de Scikit-learn, con validación de datos, manejo de errores y buenas prácticas que puedes usar en tu día a día.

No es un tutorial exhaustivo de FastAPI (eso daría para un libro), sino una guía práctica centrada en el caso de uso más común: exponer un modelo de ML como servicio.

¿Por qué FastAPI?

Antes de meternos en código, vale la pena entender por qué FastAPI se ha convertido en el estándar de facto para servir modelos de ML.

Es rápido, dentro de lo que cabe en Python. Está construido sobre Starlette (para la parte async) y Pydantic (para la validación), lo que le da un rendimiento muy por encima de Flask o Django en benchmarks típicos. No vas a igualar a Node.js o Go en throughput puro (ningún framework de Python lo hace), pero dentro del ecosistema Python es de los más rápidos disponibles.

Valida datos automáticamente. Esto es enorme cuando hablamos de ML. Si tu modelo espera 4 features numéricas y alguien te manda un string, FastAPI lo rechaza antes de que llegue a tu código. Te ahorras la mitad de los try/except que escribirías en Flask.

Documentación automática. Cada endpoint que escribes genera documentación interactiva en /docs (Swagger UI) sin que tengas que tocar nada. Para un equipo que necesita probar tu modelo sin escribir código, esto es oro.

Tipado nativo de Python. Usa type hints para todo, lo cual significa mejor autocompletado, menos bugs y código que se explica solo.

El punto de partida: un modelo entrenado

Vamos a trabajar con un caso clásico como un modelo de clasificación entrenado con Scikit-learn. Imagina que ya tienes esto:

# train_model.py
import joblib
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split

# Cargamos datos y entrenamos
data = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, data.target, test_size=0.2, random_state=42
)

model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)

print(f"Accuracy: {model.score(X_test, y_test):.3f}")

# Guardamos el modelo entrenado
joblib.dump(model, "modelo_iris.pkl")

Hasta aquí, nada que no hayas visto mil veces. Lo interesante empieza ahora: cómo convertimos ese .pkl en un servicio que cualquiera pueda consumir.

Estructura del proyecto

Antes de escribir código, una estructura (aunque sea básica) es muy importante.

ml-api/
├── app/
│   ├── __init__.py
│   ├── main.py          # Punto de entrada de FastAPI
│   ├── models.py        # Esquemas de Pydantic
│   ├── ml_model.py      # Lógica de carga e inferencia
│   └── config.py        # Configuración
├── modelo_iris.pkl
├── requirements.txt
└── .env

Paso 1: definir qué espera y qué devuelve la API

Aquí es donde Pydantic entra en juego, y es probablemente la parte más importante de todo el artículo. Pydantic te permite definir un esquema de datos con tipos, y FastAPI lo usa automáticamente para validar cada petición.

# app/models.py
from pydantic import BaseModel, ConfigDict, Field, field_validator

class IrisInput(BaseModel):
    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "sepal_length": 5.1,
                "sepal_width": 3.5,
                "petal_length": 1.4,
                "petal_width": 0.2
            }
        }
    )

    sepal_length: float = Field(..., gt=0, le=15, description="Longitud del sépalo en cm")
    sepal_width: float = Field(..., gt=0, le=15, description="Ancho del sépalo en cm")
    petal_length: float = Field(..., gt=0, le=15, description="Longitud del pétalo en cm")
    petal_width: float = Field(..., gt=0, le=15, description="Ancho del pétalo en cm")

    @field_validator("*")
    @classmethod
    def check_no_nan(cls, value: float) -> float:
        if value != value:  # forma rápida de detectar NaN
            raise ValueError("El valor no puede ser NaN")
        return value


class PredictionOutput(BaseModel):
    prediction: str
    confidence: float
    probabilities: dict[str, float]

Fíjate en un par de detalles que marcan la diferencia entre un esquema "que funciona" y uno bien pensado:

  • Field(..., gt=0, le=15) no solo dice que el campo es obligatorio (el ...), también define un rango razonable. Si alguien manda un sépalo de -3 cm, FastAPI rechaza la petición sin que tu modelo tenga que enterarse. En este caso concreto, ese rango ya descarta NaN por sí solo (nan <= 15 siempre da False en Python), así que el field_validator de abajo es estrictamente redundante aquí. Lo dejo igualmente porque es el patrón que vas a necesitar en cuanto tengas un campo sin límites numéricos claros, donde un NaN sí podría colarse sin que ningún Field lo detecte.

  • El model_config con json_schema_extra no afecta a la lógica, pero hace que la documentación automática (/docs) muestre un ejemplo realista, lo cual ayuda muchísimo a quien va a consumir tu API por primera vez. Usa ConfigDict y no la antigua class Config, que Pydantic 2 marca como obsoleta.

Paso 2: encapsular la lógica del modelo

Es tentador cargar el modelo directamente en main.py y listo. No lo hagas. Separar esta lógica te permite testear el modelo sin levantar un servidor, y cambiar de implementación (por ejemplo, pasar de Scikit-learn a ONNX) sin tocar la API.

# app/ml_model.py
import joblib
import numpy as np
from pathlib import Path
from functools import lru_cache

MODEL_PATH = Path(__file__).parent.parent / "modelo_iris.pkl"
CLASS_NAMES = ["setosa", "versicolor", "virginica"]


class IrisModel:
    def __init__(self, model_path: Path):
        self.model = joblib.load(model_path)

    def predict(self, features: list[float]) -> dict:
        X = np.array([features])
        prediction = self.model.predict(X)[0]
        probabilities = self.model.predict_proba(X)[0]

        return {
            "prediction": CLASS_NAMES[prediction],
            "confidence": float(max(probabilities)),
            "probabilities": {
                name: float(prob)
                for name, prob in zip(CLASS_NAMES, probabilities)
            }
        }


@lru_cache
def get_model() -> IrisModel:
    return IrisModel(MODEL_PATH)

El @lru_cache en get_model() es un truco pequeño pero útil: garantiza que el modelo se cargue una sola vez en memoria, sin importar cuántas veces se llame a esta función. Esto se combina muy bien con el sistema de inyección de dependencias de FastAPI, que vemos ahora.

Paso 3: el endpoint

# app/main.py
from fastapi import FastAPI, Depends
from app.models import IrisInput, PredictionOutput
from app.ml_model import IrisModel, get_model

app = FastAPI(
    title="API de Clasificación de Iris",
    description="Sirve un modelo de Random Forest entrenado con el dataset Iris",
    version="1.0.0"
)


@app.get("/health")
def health_check():
    return {"status": "ok"}


@app.post("/predict", response_model=PredictionOutput)
def predict(
    data: IrisInput,
    model: IrisModel = Depends(get_model)
):
    features = [
        data.sepal_length,
        data.sepal_width,
        data.petal_length,
        data.petal_width
    ]
    return model.predict(features)

Hay varias decisiones de diseño aquí que conviene explicar:

Depends(get_model) es la inyección de dependencias de FastAPI. En vez de cargar el modelo dentro de la función predict, se lo pides como argumento. FastAPI se encarga de resolverlo. La ventaja real aparece cuando escribes tests: puedes sustituir get_model por una versión falsa sin tocar el endpoint.

response_model=PredictionOutput le dice a FastAPI qué forma debe tener la respuesta. Esto cumple dos funciones: valida que tu código no esté devolviendo algo inesperado, y documenta automáticamente el formato de salida en /docs.

El endpoint /health parece trivial, pero es imprescindible en producción. Cualquier orquestador (Docker, Kubernetes, un balanceador de carga) necesita una forma de preguntar "¿estás viva?" antes de enviarte tráfico.

Fíjate en que el endpoint no tiene ningún try/except. Es intencional, y es justo lo que vemos en la siguiente sección: si algo falla dentro de model.predict(), la excepción sube sola hasta donde realmente debe resolverse.

Manejo de errores

El error más común que he visto (y que yo mismo he cometido) es envolver el endpoint en un try/except Exception genérico que convierte todo en un 500. El problema es que eso "traga" cualquier excepción más específica antes de que pueda tratarse como lo que realmente es.

# app/main.py (continuación)
from fastapi import Request
from fastapi.responses import JSONResponse
from app.ml_model import PredictionError

class ModelNotLoadedError(Exception):
    pass


@app.exception_handler(ModelNotLoadedError)
async def model_not_loaded_handler(request: Request, exc: ModelNotLoadedError):
    return JSONResponse(
        status_code=503,
        content={"detail": "El modelo no está disponible actualmente. Inténtalo en unos segundos."}
    )


@app.exception_handler(PredictionError)
async def prediction_error_handler(request: Request, exc: PredictionError):
    return JSONResponse(
        status_code=422,
        content={"detail": f"No se pudo generar la predicción: {str(exc)}"}
    )

Fíjate en que PredictionError no se define aquí, sino que se importa desde app.ml_model. Tiene que vivir junto a quien la lanza (IrisModel.predict()), no en main.py; si la defines en los dos sitios acabas con dos clases distintas con el mismo nombre, y el except de una no captura las instancias de la otra.

Para que esto funcione de verdad, hay que volver a IrisModel.predict() (la del Paso 2) y hacer que traduzca cualquier fallo de NumPy o Scikit-learn en PredictionError, en lugar de dejar que se propague tal cual:

# app/ml_model.py (versión final, con PredictionError definida aquí)
import joblib
import numpy as np
from pathlib import Path
from functools import lru_cache

MODEL_PATH = Path(__file__).parent.parent / "modelo_iris.pkl"
CLASS_NAMES = ["setosa", "versicolor", "virginica"]


class PredictionError(Exception):
    """Error al generar una predicción con el modelo ya cargado."""
    pass


class IrisModel:
    def __init__(self, model_path: Path):
        self.model = joblib.load(model_path)

    def predict(self, features: list[float]) -> dict:
        try:
            X = np.array([features])
            prediction = self.model.predict(X)[0]
            probabilities = self.model.predict_proba(X)[0]
        except Exception as e:
            raise PredictionError(str(e)) from e

        return {
            "prediction": CLASS_NAMES[prediction],
            "confidence": float(max(probabilities)),
            "probabilities": {
                name: float(prob)
                for name, prob in zip(CLASS_NAMES, probabilities)
            }
        }


@lru_cache
def get_model() -> IrisModel:
    return IrisModel(MODEL_PATH)

El try/except Exception solo tiene sentido en la capa donde se genera la predicción, para transformar cualquier error inesperado en una excepción de dominio (PredictionError). El endpoint no necesita capturar errores genéricos: simplemente deja que las excepciones lleguen a su exception handler correspondiente.

Así, la API devuelve respuestas claras y útiles en lugar de un 500 genérico. Por ejemplo, un 422 indica que el problema está en los datos enviados por el cliente, mientras que un 503 señala un fallo temporal del servicio.

Cargar el modelo solo una vez

Un error común es recargar el modelo en cada petición, lo cual es lentísimo y, en modelos grandes. FastAPI tiene un mecanismo pensado justo para esto: el ciclo de vida de la aplicación (lifespan).

# app/main.py (versión con lifespan)
from contextlib import asynccontextmanager
from fastapi import FastAPI

ml_models = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Se ejecuta UNA vez, al arrancar la app
    ml_models["iris"] = IrisModel(MODEL_PATH)
    print("Modelo cargado correctamente")
    yield
    # Se ejecuta al apagar la app (limpieza de recursos)
    ml_models.clear()
    print("Modelo descargado, cerrando aplicación")

app = FastAPI(lifespan=lifespan)

Este patrón es preferible al @lru_cache que vimos antes cuando el modelo es pesado o cuando necesitas hacer algo más al arrancar (por ejemplo, conectar a una base de datos o verificar que el modelo carga sin errores antes de aceptar tráfico). Si el modelo falla al cargar, la aplicación ni siquiera arranca, lo cual es exactamente el comportamiento que quieres: prefieres que falle al desplegar, no en mitad de una petición real.

Probando la API

Una vez que tienes el servidor corriendo (uvicorn app.main:app --reload), FastAPI genera automáticamente documentación interactiva en dos formatos:

  • Swagger UI en http://localhost:8000/docs: interfaz donde puedes probar cada endpoint directamente desde el navegador.

  • ReDoc en http://localhost:8000/redoc: documentación más orientada a lectura, útil para compartir con otros equipos.

Para probar el endpoint de predicción desde la terminal:

curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{
    "sepal_length": 5.1,
    "sepal_width": 3.5,
    "petal_length": 1.4,
    "petal_width": 0.2
  }'

Y la respuesta debería verse así:

{
  "prediction": "setosa",
  "confidence": 0.98,
  "probabilities": {
    "setosa": 0.98,
    "versicolor": 0.02,
    "virginica": 0.0
  }
}

Si mandas un dato fuera de rango, por ejemplo "sepal_length": -5, FastAPI te devuelve un 422 con el detalle exacto de qué campo falló y por qué, sin que tu código tenga que hacer nada extra. Esto es validación automática en su máxima expresión.

Escribiendo tests para la API

FastAPI viene preparado para esto gracias a TestClient, que te permite simular peticiones HTTP sin levantar un servidor real.

# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_predict_valid_input():
    payload = {
        "sepal_length": 5.1,
        "sepal_width": 3.5,
        "petal_length": 1.4,
        "petal_width": 0.2
    }
    response = client.post("/predict", json=payload)
    assert response.status_code == 200

    data = response.json()
    assert data["prediction"] in ["setosa", "versicolor", "virginica"]
    assert 0 <= data["confidence"] <= 1


def test_predict_invalid_input_out_of_range():
    payload = {
        "sepal_length": -3,  # valor fuera de rango
        "sepal_width": 3.5,
        "petal_length": 1.4,
        "petal_width": 0.2
    }
    response = client.post("/predict", json=payload)
    assert response.status_code == 422


def test_predict_missing_field():
    payload = {
        "sepal_length": 5.1,
        "sepal_width": 3.5
        # faltan petal_length y petal_width
    }
    response = client.post("/predict", json=payload)
    assert response.status_code == 422

Async vs sync

En FastAPI no siempre conviene usar async def. Si el endpoint solo ejecuta una predicción local de CPU (por ejemplo, con Scikit-learn), lo adecuado es usar def, ya que FastAPI la ejecuta en un thread pool sin bloquear el servidor.

async def solo aporta ventajas cuando el endpoint realiza operaciones de I/O, como consultas a bases de datos, llamadas a APIs o servicios externos. Declarar un endpoint como async y ejecutar dentro código síncrono y bloqueante (como model.predict()) puede perjudicar el rendimiento al bloquear el bucle de eventos.

La regla práctica es sencilla: si todo el código es síncrono, usa def; si trabajas con librerías realmente asíncronas, entonces utiliza async def.

Conclusiones

FastAPI no resuelve el problema de hacer buen Machine Learning, pero resuelve casi perfectamente el problema de exponerlo. La combinación de validación automática con Pydantic, documentación generada sola y un sistema de inyección de dependencias simple lo convierte en la opción más razonable hoy en día para este caso de uso, sobre todo cuando vienes del mundo de los notebooks y quieres dar el salto a algo que otros puedan consumir de forma fiable.

Lo que hemos visto aquí es la base: un endpoint, un modelo cargado correctamente y errores manejados con cabeza. A partir de aquí, el camino natural es añadir autenticación, logging, tests y, eventualmente, contenerizar todo para desplegarlo. Pero si llegas hasta aquí, ya tienes algo mucho más sólido que el típico .pkl abandonado en una carpeta.