PARAS MADAN
All writing

Latest in AI

Structured Outputs vs JSON Mode vs Function Calling: Which Should You Use?

Choose the right OpenAI output format, validate a reproducible Python example, and handle schema errors, missing values and unsafe tool actions.

For most extraction and classification features, I would start with Structured Outputs, because a typed response gives the application a contract it can check. Function calling is the better fit when the model needs your application to retrieve information or perform an operation, while JSON mode is a fallback for integrations that need valid JSON but cannot use schema-constrained output.

An invoice makes the distinction concrete: reading its total, fetching its payment status and authorizing a payment involve different responsibilities, even if all three eventually produce JSON. Designing around those responsibilities makes the application easier to test and prevents a formatting choice from quietly becoming an authorization decision.

The practical comparison

ChoiceBest fitWhat to check yourself
Structured response with a JSON SchemaExtraction, classifications, UI dataFacts, missing information and business rules
JSON modeLegacy integrations that only require JSONExpected keys, types, values and business rules
Function calling with strict argumentsLookup or action requests to your applicationUser permission, arguments, execution and results

OpenAI's Structured Outputs guide distinguishes schema adherence from JSON validity.

For a document-processing feature, I would start with a structured response. Adding a pretend extract_invoice tool merely to obtain JSON creates another interface without adding an external capability. For an invoice lookup, a real tool is useful because the answer depends on records the model does not have.

A reproducible validation example

Suppose an application reads this synthetic source:

Invoice DEMO-7. Total: USD 42.50.

The application expects an identifier, one of three supported currencies, and an integer amount in minor units. In this deliberately limited example, USD, EUR and INR all use two decimal places, so USD 42.50 becomes 4,250 minor units. Do not generalize that conversion to every currency.

The example below uses Pydantic models to create a schema and validate candidate JSON. Every field must be present, but a value may be null when the source does not provide it. Missing information then goes to review instead of becoming an invented answer.

Run it with Python 3.10 or later and Pydantic 2. The local check for this article used Pydantic 2.13.4:

python -m pip install "pydantic==2.13.4"
python structured-output-example.py

Save this as structured-output-example.py:

import json
from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError

class Invoice(BaseModel):
    model_config = ConfigDict(extra="forbid")
    invoice_id: str | None
    currency: Literal["USD", "EUR", "INR"] | None
    amount_minor: int | None

schema = Invoice.model_json_schema()
assert schema["additionalProperties"] is False
assert set(schema["required"]) == set(schema["properties"])

def validate_candidate(raw: str) -> Invoice:
    item = Invoice.model_validate_json(raw, strict=True)
    if any(value is None for value in item.model_dump().values()):
        raise ValueError("Missing source data: review needed")
    if item.amount_minor < 0:
        raise ValueError("Negative total: review needed")
    return item

# Synthetic source: "Invoice DEMO-7. Total: USD 42.50."
good = {"invoice_id": "DEMO-7", "currency": "USD", "amount_minor": 4250}
missing = {key: value for key, value in good.items() if key != "currency"}
cases = {
    "valid": good,
    "missing key": missing,
    "extra key": {**good, "approved": True},
    "wrong type": {**good, "amount_minor": "4250"},
    "unsupported currency": {**good, "currency": "JPY"},
    "unknown value": {**good, "amount_minor": None},
    "negative total": {**good, "amount_minor": -4250},
    "valid but wrong": {**good, "amount_minor": 4300},
}
for label, candidate in cases.items():
    try:
        validate_candidate(json.dumps(candidate))
        print(f"{label}: accepted by local checks")
    except ValidationError:
        print(f"{label}: schema rejection")
    except ValueError:
        print(f"{label}: business-rule review")

The local result was:

valid: accepted by local checks
missing key: schema rejection
extra key: schema rejection
wrong type: schema rejection
unsupported currency: schema rejection
unknown value: business-rule review
negative total: business-rule review
valid but wrong: accepted by local checks

This was an offline validation test using synthetic candidates, not a live API test or a model-accuracy benchmark.

The last line exposes the limitation that matters most: an amount of 4,300 has the correct type but contradicts the source, and the schema cannot discover that discrepancy on its own. Before a record enters an accounting workflow, compare it with a trusted source, reconcile totals, or route uncertain records to a person.

Two other choices are deliberate:

  • extra="forbid" rejects an unsolicited approved field. A model should not get to invent an approval channel by adding a key.
  • strict=True rejects the string "4250" for the integer field in this test. Pydantic otherwise permits some coercion. Its strict-mode documentation explains the type-specific behavior.

The currency list and negative-total rule belong to this application's policy. A production system handling refunds or additional currencies needs different rules. Validation should express your workflow, not quietly turn example assumptions into universal restrictions.

Connect the schema to the correct API interface

For a supported model, the Responses API reference defines text.format as the request configuration for the answer format. Continue below the previous Python example to build it:

text_config = {
    "format": {
        "type": "json_schema",
        "name": "invoice_extraction",
        "strict": True,
        "schema": schema,
    }
}

Pass text=text_config when creating the response. Chat Completions instead uses a response_format object with a nested json_schema definition; do not copy the Responses wrapper unchanged.

OpenAI supports a JSON Schema subset: use an object at the root, require every field, and disallow extra properties. Nullable values can represent unknowns. Check model support and schema restrictions before deployment. The local assertions above are basic checks, not certification that an API will accept every generated schema.

If you choose JSON mode, explicitly instruct the model to produce JSON and validate the result yourself. Do not silently downgrade to it after a schema error. That changes the contract your application relies on.

When function calling is the better choice

Consider the request, “Look up invoice DEMO-7 and tell me whether it has been paid.” Your application needs a database result, not an invented status.

OpenAI's function-calling guide describes a cycle in which the model requests a function, the application executes it, and the result returns to the model. With strict: true, argument generation follows the supported schema. Specify strictness explicitly rather than relying on defaults, which differ between Responses and Chat Completions.

A useful implementation keeps four decisions separate:

  1. Selection: Is get_invoice a tool this request is allowed to use?
  2. Validation: Does the proposed invoice identifier fit the argument contract?
  3. Authorization: Does the signed-in user have access to that invoice?
  4. Execution: Can the application perform the lookup and return a controlled result?

A well-formed identifier answers the validation question without establishing access, just as a valid send_payment argument object cannot grant permission to move money. Approval, idempotency and audit controls belong in application code, where the model cannot waive them by producing a convincing explanation.

After a successful lookup, a structured final response can contain the invoice identifier, payment status and a display message. That is a reasonable use of both mechanisms in one workflow.

Failure cases to test before shipping

Handle refusals and incomplete responses before reading application fields. Schema-constrained output still needs those branches.

Then test the complete pipeline, not just its happy path:

  • Unsupported schema: Fail configuration checks during development. Do not retry the same invalid request indefinitely.
  • Missing source information: Preserve an explicit unknown value and ask for clarification or review.
  • Valid but wrong data: Compare high-impact fields with independent evidence. The 4,300 example should remain in the test suite.
  • Unexpected tool arguments: Reject them before any operation runs. Record a safe error without leaking private records.
  • Interrupted processing: Keep the record pending rather than treating a partially received answer as complete.
  • Schema changes: Version stored records and test older clients before adding or renaming fields.

I would choose a structured response for extraction, a real tool for access to outside data or actions, and JSON mode only where the stricter interface is unavailable. Whichever route you take, keep the valid-but-wrong case in the test suite: accepting an object is a much smaller achievement than knowing its contents are correct and its proposed actions are allowed.

I'm Paras, a technical writer working with AI and developer-tool teams.

I help turn model capabilities and API edge cases into clear documentation and practical technical content.

Book a 30-minute call.