Dos capas translúcidas superpuestas, una con forma de rejilla geométrica y otra de filtro fino, por las que pasan bloques de colores que solo encajan en ciertas celdas

Structured outputs con Pydantic: qué impone Claude y qué no


Los structured outputs de Claude con Pydantic trabajan en dos capas: el servidor obliga al modelo a generar JSON que cumple una versión simplificada de tu esquema (tipos, campos obligatorios, enums), y el SDK de Python valida después la respuesta contra tu modelo completo. Restricciones como ge, le, max_length o pattern solo se comprueban en la segunda capa.

Saber qué cae en cada capa explica por qué client.messages.parse() puede lanzar una excepción aunque la salida sea "estructurada", y cambia cómo diseñas el modelo, cómo gestionas los errores y cuándo merece la pena reintentar.

Las dos capas: gramática en el servidor y Pydantic en tu proceso

La capa del servidor garantiza la forma; la de Pydantic, las reglas de negocio. Según la documentación oficial de structured outputs, la funcionalidad está en disponibilidad general, el parámetro de la API es output_config.format y el esquema JSON se compila en una gramática que restringe los tokens que Claude puede emitir. Si un campo es integer, el modelo literalmente no puede escribir una cadena en ese hueco.

Esa gramática no admite todo JSON Schema. La documentación lista como no soportados, entre otros, minimum, maximum, multipleOf, minLength, maxLength, esquemas recursivos y minItems distinto de 0 o 1. Para que tu modelo Pydantic no provoque un error 400, los helpers del SDK hacen cinco cosas:

  1. Quitan las restricciones no soportadas del esquema enviado.
  2. Las añaden como texto a la descripción del campo, por ejemplo {minimum: 100}.
  3. Ponen additionalProperties: false en todos los objetos.
  4. Filtran los format de cadena a la lista soportada.
  5. Validan la respuesta contra tu esquema original, con todas sus restricciones.

El punto 2 es la clave: una restricción movida a la descripción pasa a ser una sugerencia para el modelo, no una garantía. Si Claude devuelve prioridad: 7 cuando pediste le=5, la gramática lo deja pasar y es Pydantic quien lo rechaza en tu proceso.

Un modelo de ejemplo y lo que realmente se envía

Para verlo en concreto, sirve un clasificador de tickets de soporte. El ejemplo usa Python 3.12, anthropic 1.11.0 (publicada el 30/09/2026, requiere Python 3.10 o superior) y pydantic 2.13.5. La instalación es pip install "anthropic==1.11.0" "pydantic==2.13.5" y el cliente lee la clave de la variable de entorno ANTHROPIC_API_KEY.

Este modelo mezcla restricciones que sí llegan a la gramática con otras que no:

# Python 3.12 · anthropic 1.11.0 · pydantic 2.13.5
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field

class Ticket(BaseModel):
    categoria: Literal["facturacion", "bug", "cuenta", "otro"]
    prioridad: int = Field(ge=1, le=5, description="1 baja, 5 crítica")
    resumen: str = Field(max_length=120)
    pedido_id: str | None = Field(default=None, pattern=r"^PED-\d{6}$")
    fecha_incidencia: date | None = None

No hace falta adivinar qué recibe Claude. El SDK exporta transform_schema, la misma función que usa internamente, y la documentación indica que acepta un modelo Pydantic y devuelve el esquema transformado sin enviarlo:

import json
from anthropic import transform_schema

print(json.dumps(transform_schema(Ticket), indent=2, ensure_ascii=False))

Según el código de transform_schema, la salida esperada conserva type, enum, required, anyOf y los format de cadena soportados (date entre ellos). En cambio, prioridad pierde minimum y maximum, que acaban al final de su descripción entre llaves, y resumen pierde maxLength de la misma manera.

Hay un detalle que no es evidente: la API sí admite pattern con expresiones regulares sencillas, pero la versión actual de transform_schema solo conserva format en las cadenas, así que pattern también termina en la descripción. Si necesitas que la regex forme parte de la gramática, la documentación propone editar el esquema transformado antes de enviarlo, siempre que la regex evite lo no soportado (backreferences, lookahead, lookbehind y word boundaries).

Tabla: qué declaración de Pydantic se impone y dónde

Esta tabla resume el recorrido de cada declaración habitual, a partir de la documentación y del código del SDK citados arriba. Úsala al diseñar el modelo para saber qué está garantizado en la generación y qué puede fallar después.

Declaración en PydanticSe convierte enLo impone la gramáticaLo valida PydanticSi se incumple
int, str, bool, floattypeSíSíNo puede ocurrir salvo corte o rechazo
Literal["a", "b"]enumSíSíNo puede ocurrir salvo corte o rechazo
date, datetimeformat soportadoSíSíNo puede ocurrir salvo corte o rechazo
Field(ge=1, le=5)Texto en la descripciónNoSíValidationError
Field(max_length=120)Texto en la descripciónNoSíValidationError
Field(pattern=...)Texto en la descripción (con el helper)Solo si editas el esquemaSíValidationError
list[X] = Field(min_length=1)minItems: 1SíSíNo puede ocurrir salvo corte o rechazo
list[X] = Field(min_length=3)Texto en la descripciónNoSíValidationError
@field_validator propioNada: no aparece en el esquemaNoSíValidationError

La lectura práctica: todo lo que esté en las filas con "No" en la gramática tiene una probabilidad distinta de cero de fallar, y tu código tiene que estar preparado para ello.

Por qué parse() puede lanzar ValidationError

parse() es cómodo, pero mezcla en una sola llamada la petición y la validación. El ejemplo oficial del SDK lo usa así, con el argumento output_format recibiendo la clase:

import anthropic

client = anthropic.Anthropic()  # lee ANTHROPIC_API_KEY del entorno
texto = "Me habéis cobrado dos veces el pedido PED-123456, es urgente."

respuesta = client.messages.parse(
    model="claude-sonnet-5-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Clasifica este ticket:\n{texto}"}],
    output_format=Ticket,
)
ticket = respuesta.parsed_output

Internamente, el módulo de parseo del SDK crea un TypeAdapter de Pydantic con tu tipo y llama a validate_json sobre el texto de cada bloque. El código no captura el error, así que cualquier fallo de validación sale de la llamada como excepción de Pydantic. Hay tres causas típicas:

  • Una restricción que solo vivía en la descripción, como un resumen de 140 caracteres.
  • stop_reason: "max_tokens": según la documentación, la salida queda cortada y puede no cumplir el esquema. Un JSON a medias no supera validate_json.
  • stop_reason: "refusal": el rechazo tiene prioridad sobre el esquema, la respuesta devuelve código 200 y se facturan los tokens.

El problema de parse() es que, cuando salta la excepción, no tienes a mano el stop_reason para distinguir un caso de otro. Si esa distinción te importa, separa las dos capas tú mismo con create(), output_config y transform_schema:

from anthropic import transform_schema

def clasificar(texto: str) -> Ticket:
    r = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": f"Clasifica este ticket:\n{texto}"}],
        output_config={"format": {"type": "json_schema", "schema": transform_schema(Ticket)}},
    )
    if r.stop_reason in ("refusal", "max_tokens"):
        raise RuntimeError(f"Salida no utilizable: stop_reason={r.stop_reason}")
    bruto = next(b.text for b in r.content if b.type == "text")
    return Ticket.model_validate_json(bruto)  # aquí se aplican ge, le, max_length y pattern

Con esto puedes actuar según la causa: subir max_tokens si se cortó, no reintentar si hubo rechazo, y reintentar una vez incluyendo en el prompt el mensaje de pydantic.ValidationError si falló una regla de negocio. Como referencia práctica, no como dato, un único reintento con el error explícito suele bastar para restricciones simples. Si falla de forma sistemática, el problema está en el prompt o en la restricción, no en la suerte.

Límites de complejidad: los opcionales cuentan doble

Además de qué se impone, importa cuánto esquema cabe en una petición. La documentación fija límites que se suman entre todos los esquemas estrictos de la misma petición (salida JSON más herramientas con strict: true): 20 herramientas estrictas, 24 parámetros opcionales y 16 parámetros con tipos unión (anyOf o arrays de tipos). Si se supera un límite interno de tamaño de gramática, la API responde 400 con "Schema is too complex for compilation".

Aquí Pydantic juega en tu contra sin avisar. Según la documentación de JSON Schema de Pydantic, un campo X | None = None genera un anyOf con null, y cualquier campo con valor por defecto queda fuera de required. Resultado: cada pedido_id: str | None = None consume una plaza de las 24 de opcionales y otra de las 16 de uniones. En Ticket son 2 y 2, pero un modelo de extracción de facturas con veinte campos "por si acaso" opcionales se acerca rápido al límite.

Otros dos efectos de la documentación que afectan al diseño:

  • Orden de las propiedades: en la salida aparecen primero los campos obligatorios y luego los opcionales. Si el orden importa (por ejemplo, porque quieres que el modelo escriba un razonamiento antes del veredicto), marca todos como obligatorios.
  • Caché de la gramática: la primera petición con un esquema nuevo añade latencia de compilación, y la gramática se cachea 24 horas desde el último uso. Cambiar la estructura la invalida; cambiar solo name o description, no. Como el helper mueve ge o max_length a la descripción, es esperable que ajustar esos valores no fuerce una recompilación.

Checklist antes de pasar el modelo a producción

  • Imprimir transform_schema(TuModelo) y revisar qué restricciones han acabado en descripciones.
  • Contar campos fuera de required (máximo 24) y campos con anyOf (máximo 16), sumando las herramientas estrictas de la misma petición.
  • Sustituir opcionales innecesarios por campos obligatorios con un valor centinela documentado, por ejemplo "desconocido" dentro de un Literal.
  • Decidir si alguna pattern debe entrar en la gramática editando el esquema transformado.
  • Comprobar stop_reason antes de validar, o capturar pydantic.ValidationError si usas parse().
  • Confirmar que el modelo elegido está en la lista de modelos compatibles de la documentación.

Cuándo no conviene este enfoque

Structured outputs con Pydantic encaja cuando la salida es un objeto con forma fija y quieres eliminar los fallos de parseo. No es la herramienta adecuada en estos casos:

  • Esquemas recursivos, como un árbol de comentarios con hijos del mismo tipo: la documentación los declara no soportados. Ahí toca JSON sin gramática y validación posterior, o aplanar la estructura.
  • Reglas de negocio que son el núcleo de la tarea: si casi todo el valor está en restricciones que la gramática no impone (rangos, longitudes, validadores propios), la garantía del servidor aporta poco y el coste real está en los reintentos.
  • El modelo tiene que llamar a herramientas, no responder con datos: entonces lo que buscas es strict: true en la definición de la herramienta, que la documentación describe como compatible con la salida JSON en la misma petición.
  • Texto libre con algo de estructura, como un correo con asunto y cuerpo: forzar JSON añade latencia de compilación en la primera llamada y no mejora lo que importa, que es la calidad del texto.

En el resto de casos, la regla de decisión es sencilla: pon en la gramática todo lo que puedas (tipos, Literal, campos obligatorios, format soportados) y trata cada restricción que acabe en una descripción como algo que tu código tiene que validar y saber reintentar.

Compartir X LinkedIn