Generate Structured Dating Profiles with Outlines and Pydantic

Published • guides
PydanticLLMs

Large language models are conversational and creative, but application code needs structure. A model that sometimes returns a JSON object and sometimes returns a rambling paragraph forces you to write brittle parsers, retry loops, and defensive code that still fails in production. Outlines solves this by constraining the model's output with a finite-state machine built from your schema. In practice, you define a Pydantic model or regex, and Outlines restricts the model to tokens that keep the partial output inside the schema. This guide walks through the dating_profile.py example from the Outlines repository, teaching you how to generate a fully structured dating profile with typed fields, nested objects, and constrained enums.

This guide is adapted from the Outlines project's dating_profile.py example, available at https://github.com/dottxt-ai/outlines, and is used under the Apache-2.0 licence.

This is for engineers whose model output must be consumed by a database, API, or UI component: form autofill, product listings, chat responses with structured action calls, and data extraction pipelines. The example uses a 7B-parameter model that requires about 13 GB of GPU VRAM. You can run it on a single GPU with at least that much memory, or swap in a smaller model if VRAM is tight. There is no external API cost for local inference, but constrained decoding is slower than unconstrained sampling because each generated token must remain inside the allowed token set.

Prerequisites

The source code establishes the following requirements:

  • Python 3.9 or later, because the code uses list[Example] type annotations.
  • PyTorch, with CUDA support if you use the GPU path.
  • The transformers, pydantic, and outlines libraries installed.
  • A GPU with at least 13 GB of VRAM for mosaicml/mpt-7b-8k-instruct. You can substitute a smaller model, but the memory figure cited in the source is for this model.
  • The file prompts/dating_profile.txt from the Outlines repository. The code calls Template.from_file and expects a template with description and examples placeholders.

No cloud API account is required for local inference. If you want to use a hosted model with Outlines integrations, check the Outlines documentation for compatible backends.

Step 1: Define the output schema

First, define exactly what a valid dating profile looks like. A str, Enum subclass holds the allowed multiple-choice question stems, a dataclass models the question-and-answer pair, and a Pydantic BaseModel defines the top-level profile. Outlines translates the Pydantic model into a JSON schema at runtime.

from dataclasses import dataclass
from enum import Enum

import torch
import transformers
from pydantic import BaseModel, conlist

import outlines
from outlines import Template


class QuestionChoice(str, Enum):
    A = "The key to my heart is"
    B = "The first item on my bucket list is"
    C = "Perks of dating me"
    D = "Message me if you also love"
    E = "People would describe me as"
    F = "I can beat you in a game of"


@dataclass
class QuestionAnswer:
    question: QuestionChoice
    answer: str


class DatingProfile(BaseModel):
    # It is possible put length constraints on these strings using constr- however, this appears to dramatically increase the generation time
    # This may be resolved in the future with this PR: https://github.com/dottxt-ai/outlines/pull/272
    bio: str
    job: str
    # Ignore mypy checks here because it still doesn't support conlist or constr: https://github.com/pydantic/pydantic/issues/975
    interests: conlist(str, min_length=1, max_length=5)  # type: ignore
    qna1: QuestionAnswer
    qna2: QuestionAnswer

The bio and job fields are plain strings. The source comment warns that adding constr length constraints can dramatically increase generation time, so the authors leave them unconstrained. interests is a constrained list of one to five strings. qna1 and qna2 are nested QuestionAnswer objects, each containing a QuestionChoice enum and a free-form answer string. This forces the model to choose one of six predefined question stems instead of inventing a new one.

Step 2: Create few-shot examples

The next block continues in the same script and uses the imports and classes from Step 1. The source provides three hand-written examples. Each Example pairs a free-form description with a profile object that conforms to the schema. These examples show the model the expected persona and how the story maps into structured fields.

@dataclass
class Example:
    description: str
    profile: DatingProfile


samples: list[Example] = [
    Example(
        description="I'm an author and former professional soccer player living in Seattle who publishes popular fiction books. A typical day for me starts by hanging out with my cat, drinking a coffee, and reading as much as I can in a few hours. Then, I'll prepare a quick smoothie before starting to write for a few hours, take a break with soccer or running a few miles, and finally meet friends for dinner at a new, hip restaurant in the evening. Sometimes we go axe-throwing afterwards, or play poker, or watch a comedy show, or visit a dive bar. On my vacations, I travel extensively to countries South America, Europe, and Asia, with the goal of visiting them all!",
        profile=DatingProfile(
            bio="Adventurer, dreamer, author, and soccer enthusiast. Life’s too short to waste time so I make the most of each day by exploring new places and playing with my friends on the pitch. What’s your favorite way to get out and have fun?",
            job="Famous Soccer Player -> Famous Author",
            interests=["Soccer", "Travel", "Friends", "Books", "Fluffy Animals"],
            qna1=QuestionAnswer(
                question=QuestionChoice.B, answer="swim in all seven oceans!"
            ),
            qna2=QuestionAnswer(
                question=QuestionChoice.E,
                answer="fun-loving, adventurous, and a little bit crazy",
            ),
        ),
    ),
    Example(
        description="I run my company and build houses for a living. I'm a big fan of the outdoors and love to go hiking, camping, and fishing. I don't like video games, but do like to watch movies. My love language is home-cooked food, and I'm looking for someone who isn't afraid to get their hands dirty.",
        profile=DatingProfile(
            bio="If you're looking for a Montana man who loves to get outdoors and hunt, and who's in-tune with his masculinity then I'm your guy!",
            job="House Construction Manager / Entrepreneur",
            interests=["Hunting", "Hiking", "The outdoors", "Home-cooked food"],
            qna1=QuestionAnswer(question=QuestionChoice.A, answer="food made at home"),
            qna2=QuestionAnswer(
                question=QuestionChoice.C,
                answer="having a man in your life who can fix anything",
            ),
        ),
    ),
    Example(
        description="I run my own Youtube channel with 10M subscribers. I love working with kids, and my audience skews pretty young too. In my free time, I play Fortnite and Roblox. I'm looking for someone who is also a gamer and likes to have fun. I'm learning Japanese in my free time as well as how to cook.",
        profile=DatingProfile(
            bio="Easy on the eyes (find me on Youtube!) and great with kids. What more do you need?",
            job="Youtuber 10M+ subscribers",
            interests=["Kids", "Gaming", "Japanese"],
            qna1=QuestionAnswer(question=QuestionChoice.D, answer="anime and gaming!"),
            qna2=QuestionAnswer(question=QuestionChoice.F, answer="Fortnite, gg ez"),
        ),
    ),
]

The examples do not have to be exhaustive. They signal tone and show how free-form descriptions map to fields. More diverse examples generally help the model handle different inputs.

Here is what each schema constraint enforces when generation is unconstrained versus constrained:

ConstraintWithout OutlinesWith Outlines
JSON structureModel may return raw text, missing brackets, or trailing commasGuaranteed valid JSON matching the Pydantic model
Enum valuesModel can invent new question stems like "What's your sign?"Only the six predefined `QuestionChoice` strings appear
List lengthModel may return zero interests or twentyExactly 1–5 items in `interests`
Nested objectsModel may flatten `qna1` into a string or omit itAlways a `QuestionAnswer` object with `question` and `answer`
Field orderModel may reorder fields unpredictablyOrder follows the Pydantic model definition

Step 3: Load the model and tokenizer

The source loads mosaicml/mpt-7b-8k-instruct through Outlines' from_transformers wrapper. The comments state the model needs about 13 GB of GPU memory and was chosen because it fits on a single GPU while supporting a larger context window for the long few-shot examples.

# Below requires ~13GB of GPU memory
# https://huggingface.co/mosaicml/mpt-7b-8k-instruct
# Motivation: Reasonably large model that fits on a single GPU and has been fine-tuned for a larger context window
model_name = "mosaicml/mpt-7b-8k-instruct"
model = outlines.from_transformers(
    transformers.AutoModelForCausalLM.from_pretrained(model_name),
    transformers.AutoTokenizer.from_pretrained(model_name),
)

If you do not have 13 GB of VRAM, you can replace this with a smaller causal model such as gpt2. Structured generation is independent of model size, but output quality drops sharply with smaller models, and a short context window may truncate the few-shot examples.

Step 4: Generate a constrained dating profile

Now create a new free-form description, load the external prompt template, fill it, and call the model with outlines.json_schema(DatingProfile). This call converts the Pydantic model into a JSON schema and constrains token selection so the output stays valid.

new_description = "I'm a laid-back lawyer who spends a lot of his free-time gaming. I work in a corporate office, but ended up here after the start-up I cofounded got acquired, so still play ping pong with my cool coworkers every day. I have a bar at home where I make cocktails, which is great for entertaining friends. I secretly like to wear suits and get a new one tailored every few months. I also like weddings because I get to wear those suits, and it's a good excuse for a date. I watch the latest series because I'm paying, with my hard-earned money, for every streaming service."

dating_profile_prompt = Template.from_file("prompts/dating_profile.txt")
prompt = dating_profile_prompt(description=new_description, examples=samples)
profile = model(prompt, outlines.json_schema(DatingProfile), max_tokens=500)  # type: ignore
print(profile)

The Template.from_file call loads the Jinja-style template from the local filesystem. The max_tokens=500 value gives the model enough room for the full profile and nested answers. The # type: ignore on the model call is present because static type checkers do not infer Outlines' dynamic structured-generation return type.

Step 5: Inspect the generated output

The model is called with outlines.json_schema(DatingProfile), so the return value is a DatingProfile object rather than raw text. The repository comments include two generated profiles shown below.

{
    "bio": "I'm an ambitious lawyer with a casual and fashionable style. I love games and sports, but my true passion is preparing refreshing cocktails at home and dressing to the nines at weddings. I'm currently looking for a woman to show a good time to and get a kiss on the opulent suit I just had made. Send resumà € to this inbox.",
    "job": "Lawyer",
    "interests":
    [
        "Stylish guys",
        "Gaming",
        "Ping pong",
        "Cocktails",
        "Weddings"
    ],
    "qna1":
    {
        "question": "The first item on my bucket list is",
        "answer": "be married and have a family."
    },
    "qna2":
    {
        "question": "People would describe me as",
        "answer": "charming, stylish, and funny."
    }
}
{
    "bio": "I’m a sexy lawyer with time on my hands. I love to game and play ping pong, but the real reason you should swipe to the right is because I look great in a suit. Who doesn’t love a man in a suit? Just saying. Send me a message if you think it’s time to take your dating life to the next level.",
    "job": "Lawyer",
    "interests":
    [
        "Gaming",
        "Ping Pong",
        "Tailored Suits",
        "Weddings",
        "Streaming Services"
    ],
    "qna1":
    {
        "question": "The first item on my bucket list is",
        "answer": "simulate space but stay alive for as long as possible"
    },
    "qna2":
    {
        "question": "People would describe me as",
        "answer": "easy-going, a little nerdy but with a mature essence"
    }
}

Both profiles are valid according to the schema: the question values are exactly the enum strings, the interests lists contain exactly five items, and the nested objects have the correct keys. The free-form fields still vary, but the structure is guaranteed. The first output contains resumà €, which appears to be a tokenizer encoding artifact rather than a schema violation; the surrounding JSON is still parseable.

What to watch out for

Several failure modes are not obvious from the source alone.

Length constraints on strings are expensive. The schema comment warns that using constr on bio or job dramatically increases generation time. Outlines must track emitted character counts and mask tokens that would violate those bounds. Test the speed with your model before adding length constraints.

The prompt template file is external and easy to miss. The script calls Template.from_file("prompts/dating_profile.txt"), but the template content is not in the script. Without that file, you get a FileNotFoundError. Clone the repository or create a template that exposes the description and examples variables used in the call.

GPU memory is a hard requirement. The source states that mosaicml/mpt-7b-8k-instruct needs about 13 GB of VRAM. An 8 GB or 10 GB GPU may fail with an out-of-memory error or offload to CPU, making generation impractically slow. A smaller model reduces memory pressure but must still have enough context window for the long few-shot examples.

Structured output does not fix semantic quality. The constraint guarantees valid JSON, enum values, list lengths, and nested keys. It does not make the model sound coherent or on-tone. The second sample profile begins with "I'm a sexy lawyer" and answers with "simulate space but stay alive for as long as possible," which is odd but structurally valid. Tone and factual consistency still require post-processing or fine-tuning.

Token encoding artifacts can appear. The first sample contains resumà € instead of a clean character. This is a byte-pair encoding artifact rather than invalid JSON. If a downstream consumer is strict about character encoding, clean the output before use.

Using a hosted API adds per-token cost. The repository example runs locally, so its only direct cost is GPU electricity and time. If you move the same constraint to a hosted backend, that provider's per-token pricing applies; the source does not publish API cost figures. For structured-agent cost reduction work, see TokenEconomizer.

Where to go next

The same technique applies to function-calling arguments, database records, form data, and JSON APIs. Outlines also supports regex constraints, context-free grammars, and custom finite-state machines, so you are not limited to Pydantic schemas. Experiment with different models, sampling parameters, or a production serving setup. The key insight is that the model can return a typed value instead of a string that must be parsed and validated after the fact.

Frequently asked questions

What is Outlines structured generation?

Outlines constrains LLM token sampling with a finite-state machine built from a schema, such as a Pydantic model, regex, or grammar. In the guide example, `outlines.json_schema(DatingProfile)` masks logits at each step so only schema-valid tokens remain eligible. It guarantees structural validity, not factual or tone quality.

How much VRAM does the dating_profile.py example need?

The source comment states that `mosaicml/mpt-7b-8k-instruct` requires about 13 GB of GPU memory. You need a GPU with at least that much VRAM to run the example as written. Smaller models reduce memory use but may lower output quality or truncate the long few-shot examples.

Where do I get prompts/dating_profile.txt?

Clone the Outlines repository or copy the `prompts/dating_profile.txt` file into your working directory. The script calls `Template.from_file` with that path and expects it to expose `description` and `examples` placeholders. Without the file, the script raises `FileNotFoundError`.

Does constrained generation make profiles factually correct?

No. Constrained generation guarantees syntactically valid JSON, enum values, list lengths, and nested keys in the declared schema. The model can still produce semantically odd text, such as the repository's sexy-lawyer sample. Additional post-processing or fine-tuning is required for tone and factual consistency.

Can I use a different Hugging Face causal model with Outlines?

Yes. Outlines' `from_transformers` accepts any Transformers causal language model and tokenizer. Replace `mosaicml/mpt-7b-8k-instruct` with a smaller model if VRAM is tight, but the prompt includes long few-shot examples, so a small context window may truncate them.

Related Guides