Combining Web Search with Structured Outputs

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read ·Lesson 71 of 224

Why Free-Text Answers Are Not Enough for Application Logic

Every example so far in this unit has produced a free-text answer — a paragraph of prose that a human can read, but that application code cannot reliably parse. If you want to store search-backed findings in a database, render them in a structured UI component, or feed them into another step of a pipeline, you need the model's output as data with a defined shape, not as an essay you have to regex apart.

Unit 6 covered structured outputs in depth: defining a schema (typically through a Pydantic model) and having the API return a response that conforms to it exactly, rather than hoping the model's free text happens to be parseable. This lesson combines that capability with the web search tool, so you get an answer that is both grounded in current information and shaped as clean, typed data your code can use directly.

This combination is genuinely useful in practice. A price-comparison feature does not want "the current prices are roughly..." — it wants a list of typed records, each with a product name, a price, a currency, and a source URL. A news digest does not want one long paragraph — it wants an array of headline objects, each with a title, a summary, and a publication date. Structured outputs give you that shape; web search gives you the current, grounded content to fill it with.

Defining the Schema

As in Unit 6, the schema is defined as a Pydantic model, which the Responses API uses to constrain the model's output.

from pydantic import BaseModel
from openai import OpenAI


class PriceFinding(BaseModel):
    product_name: str
    price_usd: float
    source_url: str
    as_of_description: str


class PriceReport(BaseModel):
    findings: list[PriceFinding]
    notes: str

PriceFinding represents a single grounded fact: a product, its price, and where that price came from. price_usd is typed as float rather than str, which matters — if you left it as a string, your application would need to parse currency formatting downstream ("$129.99" versus "129.99" versus "129.99 USD"), reintroducing exactly the kind of brittle parsing that structured outputs are meant to eliminate. source_url keeps the citation attached to the specific finding it supports, rather than as a separate, disconnected list, which solves a problem noted back in Lesson 4 — the risk of losing the link between a claim and its source.

PriceReport wraps a list of findings plus a free-text notes field, giving the model a place to mention anything relevant that does not fit the structured fields — for example, noting that prices vary by region, or that one product was discontinued. This is a common and useful pattern in schema design: keep the fields you need to process programmatically strict and typed, while leaving one small, clearly-scoped field for genuinely unstructured context, rather than trying to force every possible nuance into rigid fields.

Making the Combined Call

from pydantic import BaseModel
from openai import OpenAI


class PriceFinding(BaseModel):
    product_name: str
    price_usd: float
    source_url: str
    as_of_description: str


class PriceReport(BaseModel):
    findings: list[PriceFinding]
    notes: str


client = OpenAI()

response = client.responses.parse(
    model="gpt-5.6-terra",
    tools=[{"type": "web_search"}],
    input=(
        "Search the web for the current listed prices of the base models of three "
        "popular wireless noise-cancelling headphones. For each one, report the "
        "product name, price in US dollars, the source URL you found it on, and a "
        "short description of what 'current' means for that price (e.g. 'as listed "
        "on the manufacturer's site today')."
    ),
    text_format=PriceReport,
)

report: PriceReport = response.output_parsed

for finding in report.findings:
    print(f"{finding.product_name}: ${finding.price_usd} (source: {finding.source_url})")

print("Notes:", report.notes)

This uses client.responses.parse() rather than client.responses.create() — the same distinction introduced in Unit 6 — because parse() is the method that enforces the schema and gives you back a validated, typed object through response.output_parsed, instead of a raw string you would need to parse yourself. The tools parameter works identically to every other example in this unit; enabling web search and requesting structured output are independent, composable configuration choices, not alternatives to each other.

Note: The exact parameter name for specifying the output schema (text_format here, matching the convention used earlier in this course) and the exact behavior of combining it with tool use are details that can vary between SDK versions. Confirm the current parameter name and any constraints on combining structured outputs with built-in tools against the official documentation before relying on this in production.

Note the prompt itself does real work here: it explicitly asks for exactly the fields the schema expects — product name, price, source URL, and an "as of" description — in plain language. Structured outputs constrain the shape of the response, but they do not by themselves guarantee the model gathers all the right content to fill that shape well. A schema with a source_url field will still get filled in with something if you forget to ask for it explicitly, but that something might be a low-quality guess rather than an actual URL from a real search result. Writing your prompt to explicitly ask for every important field, even when a schema already declares it, meaningfully improves output quality.

Handling the Interaction Between Search and Schema Validation

One subtlety worth understanding: structured output validation happens on the model's final answer, after any tool use has already completed. The web search step itself is not schema-constrained — it is an intermediate action the model takes on its way to producing the final structured message. This means a malformed or unhelpful search does not itself cause a schema validation failure; instead, it can cause the model to produce a schema-valid but low-quality answer, for example filling source_url with a URL that does not actually support the claim, simply because the field requires some string and the model needs to produce a complete, valid object.

This is an important distinction from validation errors you may already be familiar with from Unit 6: a PriceReport object can pass schema validation perfectly while still being factually ungrounded in a genuinely useful source. Schema validation checks shape, not truthfulness. This is precisely why Lesson 8, on reducing unsupported claims, and Lesson 7, on handling low-quality sources, matter even after you have adopted structured outputs — a clean schema is necessary for building reliable application logic, but it is not sufficient on its own for building trustworthy application logic.

A Practical Validation Layer on Top of the Schema

Because schema validation alone cannot catch a hollow or fabricated source_url, it is worth adding a lightweight application-level check as a second line of defense.

def validate_price_report(report: PriceReport) -> list[str]:
    """Return a list of problems found in a PriceReport. Empty list means it looks sound."""
    problems = []

    if not report.findings:
        problems.append("No findings were returned.")

    for finding in report.findings:
        if not finding.source_url.startswith("http"):
            problems.append(f"Suspicious source_url for {finding.product_name!r}: {finding.source_url!r}")
        if finding.price_usd <= 0:
            problems.append(f"Non-positive price for {finding.product_name!r}: {finding.price_usd}")

    return problems


def test_validate_price_report_flags_bad_url_and_bad_price():
    good = PriceFinding(
        product_name="Model A",
        price_usd=199.99,
        source_url="https://example.com/model-a",
        as_of_description="as listed today",
    )
    bad_url = PriceFinding(
        product_name="Model B",
        price_usd=149.99,
        source_url="not a real url",
        as_of_description="as listed today",
    )
    bad_price = PriceFinding(
        product_name="Model C",
        price_usd=0,
        source_url="https://example.com/model-c",
        as_of_description="as listed today",
    )

    report = PriceReport(findings=[good, bad_url, bad_price], notes="test report")
    problems = validate_price_report(report)

    assert len(problems) == 2, f"expected exactly 2 problems, got {len(problems)}: {problems}"
    assert any("Model B" in p for p in problems)
    assert any("Model C" in p for p in problems)

    print("PASS: validate_price_report flags a bad URL and a non-positive price without flagging a good entry")


test_validate_price_report_flags_bad_url_and_bad_price()

validate_price_report is a plain function operating on an already-parsed PriceReport object — it does not call the API at all, which is exactly why it can be tested with plainly constructed PriceFinding instances rather than fake API objects. It checks two simple but meaningful things: that source_url at least looks like a URL (a cheap sanity check, not a guarantee of validity — genuinely confirming a URL is reachable and relevant would require an actual HTTP request, which is a heavier check you might add in a production pipeline) and that price_usd is a plausible positive number. Neither check can be expressed inside the Pydantic schema itself in a way that catches a model producing a shape-valid but semantically hollow value, which is exactly the gap this function closes.

Common Mistakes

Assuming a valid schema means a valid answer, which causes ungrounded or fabricated field values to pass silently through your application simply because they satisfy the type checker. Schema validation and factual validation are different concerns; you generally need both, especially for fields like source_url that are supposed to represent real, checkable evidence.

Under-specifying the prompt and relying entirely on the schema's field names to communicate intent, which causes the model to fill fields with a best guess rather than genuinely searched content. Field names like source_url are documentation for your code, not necessarily strong instructions to the model — write the prompt to explicitly ask for what each important field should contain.

Using an overly rigid schema for an inherently variable answer, such as forcing exactly three findings when the real number of comparable products found in a search varies. Use a list[...] field, as shown here, rather than a fixed number of individual named fields, so the schema can flex to however many items the search actually turned up.

Best Practices

Add a lightweight, application-level validation function alongside any schema used with web search, specifically checking the fields that represent grounding — like a source URL — since schema validation alone cannot verify that a value is genuinely well-supported.

Write the prompt to name every field explicitly, even when the schema already declares it, since the schema constrains shape but the prompt drives what content actually goes into that shape.

Keep one small free-text field, like notes, in an otherwise strict schema to give the model a place for legitimate caveats or context that does not fit cleanly into your typed fields, rather than forcing that nuance awkwardly into a structured field it does not belong in.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Combining Web Search with Structured Outputs and get answers drawn from it.

Signed-in readers only.