Skip to main content
Lesson 7 of 12 · Structured Output: JSON and Schemas

Define Schemas with Pydantic

Lesson 7 of 12 Updated July 29, 2026 Developer
On this page
  1. Pydantic: The Foundation of Custom Structured Outputs in Python
  2. Define a Simple Model
  3. Types You Will Use
  4. Nested Models
  5. Guide the Model with Descriptions
  6. Validation Comes for Free
  7. Key Takeaways

Pydantic: The Foundation of Custom Structured Outputs in Python

Pydantic is the most popular data validation library in Python. It lets you define data models with strict types, which is exactly what the Mistral API uses for Custom Structured Outputs. If you work in Python, Pydantic becomes your primary tool for describing output schemas: what you write in a class is what the model will be constrained to produce.

Here’s the good news on installation: Pydantic is included as a dependency of the Mistral SDK. If you already have mistralai installed, you don’t need to do anything else.

pip install mistralai
# Pydantic v2 is included automatically

A quick version check avoids surprises, since validator syntax changed between major versions:

import pydantic
print(pydantic.__version__)  # 2.x required

Define a Simple Model

A Pydantic model is a class that inherits from BaseModel, where each attribute is a field with an explicit type.

from pydantic import BaseModel

class Contact(BaseModel):
    name: str
    email: str
    phone: str
    company: str

This model tells Mistral: “Your output must be a JSON object with exactly these 4 fields, all of type string.” No additional instructions are needed in the prompt to get this structure.

Types You Will Use

Pydantic supports all standard Python types, and Mistral understands them all. The following model brings them together in a realistic case—analyzing a support ticket—and shows how they combine: basic types for simple fields, Optional for an assignee not yet designated, Enum for a priority that can only take four values, and lists for tags and related IDs.

from pydantic import BaseModel
from typing import Optional
from enum import Enum

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    critical = "critical"

class TicketAnalysis(BaseModel):
    # Basic types
    title: str
    description: str
    ticket_id: int
    confidence: float
    is_urgent: bool

    # Optional types
    assignee: Optional[str] = None

    # Enums (constrained values)
    priority: Priority

    # Lists
    tags: list[str]
    related_ids: list[int]

Here is the corresponding cheat sheet:

TypeMeaning
strstring
intinteger
floatdecimal number
boolboolean (true/false)
list[T]list of elements of type T
Optional[T]field that can be null
Enumvalue from a finite set

Enums deserve special mention: they are the only way to guarantee that a priority will never be "very urgent" or "P1" depending on the model’s mood.

Nested Models

Real-world data is rarely flat. A contact belongs to a company, which has an address. Pydantic lets you nest models as deeply as needed, and Mistral follows the hierarchy.

from pydantic import BaseModel
from typing import Optional

class Address(BaseModel):
    street: str
    city: str
    postal_code: str
    country: str

class Company(BaseModel):
    name: str
    industry: str
    address: Address  # Nested model

class Person(BaseModel):
    first_name: str
    last_name: str
    email: str
    role: str
    company: Company  # Two-level nesting

The generated JSON reproduces this tree structure exactly:

{
  "first_name": "Marie",
  "last_name": "Dupont",
  "email": "[email protected]",
  "role": "Sales Director",
  "company": {
    "name": "TechVision SAS",
    "industry": "Technology",
    "address": {
      "street": "42 rue de la Paix",
      "city": "Paris",
      "postal_code": "75002",
      "country": "France"
    }
  }
}

The same mechanism extracts multiple items at once: a model wraps a list of another model, and you retrieve a complete catalog in a single call.

from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    category: str
    in_stock: bool

class Catalog(BaseModel):
    products: list[Product]
    total_count: int

Guide the Model with Descriptions

A field name doesn’t tell the whole story. score could mean a grade out of 10, a percentage, or a value between 0 and 1—the model has to guess. Descriptions remove this ambiguity: they are included in the JSON schema sent to Mistral and act as per-field instructions.

from pydantic import BaseModel, Field

class SentimentAnalysis(BaseModel):
    sentiment: str = Field(
        description="Overall sentiment: 'positive', 'negative', or 'neutral'"
    )
    score: float = Field(
        description="Confidence score between 0.0 and 1.0"
    )
    keywords: list[str] = Field(
        description="The 3-5 main keywords from the text"
    )
    summary: str = Field(
        description="Summary in one sentence maximum"
    )

The more precise these descriptions are, the better the output quality. This is the highest-leverage part of the whole setup: a few well-chosen words often replace an entire paragraph of prompt.

Field also sets default values. In the next example, the language defaults to "fr" if the model doesn’t find the information, the word count stays None if it can’t be determined, and tags start as an empty list thanks to default_factory.

from pydantic import BaseModel, Field
from typing import Optional

class ArticleMetadata(BaseModel):
    title: str
    author: str
    language: str = Field(default="fr", description="ISO 639-1 language code")
    word_count: Optional[int] = None
    tags: list[str] = Field(default_factory=list)

Fields with default values will be filled by the model if it finds the information, or will keep the default value otherwise.

Validation Comes for Free

Last but not least: validation is automatic. If a value doesn’t match the declared type, Pydantic immediately raises a ValidationError indicating exactly which field is at fault, without you writing a single line of checking code.

from pydantic import ValidationError

try:
    analysis = SentimentAnalysis(
        sentiment="positive",
        score="not a number",  # Type error
        keywords=["test"],
        summary="Summary"
    )
except ValidationError as e:
    print(e)
    # 1 validation error for SentimentAnalysis
    # score
    #   Input should be a valid number [type=float_parsing, ...]

All the manual verification code written in the previous lesson disappears here, replaced by the model declaration.

Key Takeaways

  • Pydantic BaseModel is the basis of all Custom schemas in Python
  • Use explicit types: str, int, float, bool, list[T], Optional[T]
  • Nested models enable complex JSON structures
  • Field(description=...) improves output quality by guiding the model
  • Validation is automatic—type errors are detected immediately