Combining Image Input with Structured Output

Ma Mahalakshmi V Updated 16 Sep 2026
7 min read ·Lesson 101 of 224

Why Free-Text Answers Aren't Enough for Real Pipelines

Every example so far has printed response.output_text — a free-form string. That's fine for a human reading a chat window, but it's a poor fit for code that needs to store a value in a database column, populate a form, or trigger a business rule. If you ask the model for "the total and the date" and the answer comes back as the sentence "The total is $42.50, and the receipt is dated March 3rd," your code now has to parse a sentence to extract a number and a date — fragile work that structured output eliminates entirely.

Unit 6 covered structured outputs in the text-only context: defining a schema for the shape of the response and having the SDK enforce that the model's output matches it exactly. That same mechanism works with image input. This lesson combines the two: an image goes in, and a strictly-shaped object comes out.

Defining a Schema for Image-Derived Data

The most convenient way to define a schema in Python is with Pydantic, and the SDK integrates with it through the responses.parse helper:

from openai import OpenAI
from pydantic import BaseModel
import base64

client = OpenAI()


class ReceiptData(BaseModel):
    merchant_name: str
    total_amount: float
    purchase_date: str
    currency: str


def encode_image(path: str) -> str:
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")


image_b64 = encode_image("receipts/coffee_shop.jpg")

response = client.responses.parse(
    model="gpt-5.6-terra",
    input=[
        {
            "role": "user",
            "content": [
                {
                    "type": "input_text",
                    "text": "Extract the merchant name, total amount, purchase date, and currency from this receipt.",
                },
                {
                    "type": "input_image",
                    "image_url": f"data:image/jpeg;base64,{image_b64}",
                    "detail": "high",
                },
            ],
        }
    ],
    text_format=ReceiptData,
)

receipt = response.output_parsed
print(receipt.merchant_name, receipt.total_amount, receipt.purchase_date, receipt.currency)

Walking through what changed compared to earlier lessons:

  • ReceiptData is a Pydantic model declaring exactly four fields, each with a specific type: two strings, one float, one string used for currency code. This is the schema — it defines the shape the answer must take, not just a description of what you'd like to see.
  • client.responses.parse (rather than create) is used together with text_format=ReceiptData. This tells the SDK to constrain the model's output to match the schema and to automatically parse the result into an actual ReceiptData instance for you.
  • response.output_parsed gives you that instance directly — a real Python object with real attributes, not a string you have to parse yourself. receipt.total_amount is already a float, ready to use in a calculation, not a string like "$42.50" that still needs cleaning.

Why does this matter more with images than with plain text? Because unconstrained text answers about images are especially prone to including extra commentary — hedges like "it looks like the total might be around $42, though the print is a bit faint" — that make naive string parsing even less reliable than it already is for text-only tasks. Structured output removes that ambiguity: the field is either populated with the model's best value or, with appropriate schema design, marked absent — not buried inside a paragraph of hedging prose.

Handling Fields That Might Not Be Visible

Real images are imperfect. A receipt might be torn, a screenshot might be cropped, and a field your schema expects might genuinely not be present. Declare optional fields explicitly rather than forcing the model to invent a value:

from typing import Optional
from pydantic import BaseModel


class ReceiptData(BaseModel):
    merchant_name: str
    total_amount: Optional[float] = None
    purchase_date: Optional[str] = None
    currency: Optional[str] = None

With Optional[float] = None, the schema tells both the model and your own code that total_amount may legitimately be absent. Your downstream code can then check if receipt.total_amount is None: and branch accordingly — asking the user to confirm the amount manually, for instance — instead of silently trusting a guessed number that happened to satisfy a required field. This is the same anti-hallucination principle from Lesson 4 ("write 'not found' for missing fields"), but expressed as an actual type-level guarantee instead of a string convention you'd otherwise have to parse for yourself.

Note: The exact behavior of optional versus required fields under structured output enforcement can vary by SDK and model version. Confirm current behavior against the official OpenAI documentation, particularly around how the model is expected to represent an intentionally absent value.

A More Realistic Example: Structured Extraction with Nested Data

Real-world documents often contain repeated structures — a receipt has multiple line items, not just one total. Pydantic models can nest to represent this:

from typing import List, Optional
from pydantic import BaseModel


class LineItem(BaseModel):
    description: str
    price: float


class DetailedReceipt(BaseModel):
    merchant_name: str
    line_items: List[LineItem]
    total_amount: Optional[float] = None
    purchase_date: Optional[str] = None


response = client.responses.parse(
    model="gpt-5.6-terra",
    input=[
        {
            "role": "user",
            "content": [
                {
                    "type": "input_text",
                    "text": "Extract every line item with its price, plus the merchant name, total, and date.",
                },
                {
                    "type": "input_image",
                    "image_url": f"data:image/jpeg;base64,{image_b64}",
                    "detail": "high",
                },
            ],
        }
    ],
    text_format=DetailedReceipt,
)

receipt = response.output_parsed
for item in receipt.line_items:
    print(f"{item.description}: {item.price}")

LineItem models a single row of the receipt, and List[LineItem] in DetailedReceipt tells the schema that any number of these rows can appear — zero, one, or many. This nested structure mirrors the real shape of the data far more closely than a flat schema could, and it means your code can iterate over receipt.line_items directly with a simple for loop, exactly as it would over any other list of typed objects.

Testing Structured-Output Logic Without Calling the API

Because the object returned by output_parsed behaves like any normal Pydantic instance, code that processes it can be tested with a fake instance instead of a real API call:

def calculate_items_total(receipt: DetailedReceipt) -> float:
    return sum(item.price for item in receipt.line_items)


def test_calculate_items_total():
    fake_receipt = DetailedReceipt(
        merchant_name="Test Cafe",
        line_items=[
            LineItem(description="Latte", price=4.50),
            LineItem(description="Croissant", price=3.25),
        ],
        total_amount=7.75,
        purchase_date="2026-01-15",
    )
    result = calculate_items_total(fake_receipt)
    assert result == 7.75, f"Expected 7.75, got {result}"
    print("PASS: calculate_items_total sums line item prices correctly")


if __name__ == "__main__":
    test_calculate_items_total()

calculate_items_total takes a DetailedReceipt and has no idea whether it came from a real API call or was constructed by hand in a test — it just reads .line_items and sums the prices. The test builds a fake_receipt directly with known values and checks that the function produces the expected sum, entirely offline. This is the dependency-injection principle in action: the business logic (calculate_items_total) is decoupled from the data source (the API call that produced the original receipt), so it can be verified independently and cheaply.

Common Mistakes

Making every field required when some genuinely might not be visible in the image, which forces the model to fabricate a plausible-looking value rather than honestly reporting that a field is missing. Use Optional fields with a sensible default wherever a value might legitimately be absent from the source image.

Parsing response.output_text manually instead of using responses.parse with a schema, re-implementing fragile string parsing that structured output already solves more reliably. If you find yourself writing regular expressions to pull a number out of a sentence the model generated, that's a strong signal you should switch to a schema-based approach instead.

Designing a schema that doesn't match what's actually extractable from the image, such as requiring a field like tax_rate that receipts often don't display explicitly, forcing the model to either compute it (potentially incorrectly) or invent it. Design the schema around what a careful human could actually read off the image, not around what your downstream system would ideally like to have.

Best Practices

Keep schemas as flat as the data allows, reserving nested lists and objects for genuinely repeated or hierarchical data (like line items), since deeply nested schemas are harder for both you and the model to reason about correctly.

Make uncertain fields Optional and check for None downstream, rather than trusting that every field will always be populated with a correct value.

Reuse the same schema classes across both real requests and tests, as shown with DetailedReceipt and LineItem above, so your test fixtures stay in sync with your actual data contract instead of drifting into a separate, hand-maintained shape.

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 Image Input with Structured Output and get answers drawn from it.

Signed-in readers only.