Skip to main content

Structured Outputs vs Function Calling: Choose the Right OpenAI API Contract

••10 min read•API Guides

If the model is returning the final object your UI or service consumes, use Structured Outputs through text.format. If it must ask your application to fetch or change something, use function calling. A strict schema controls shape; it does not execute code or prove the business result.

Decision map separating a typed final response from an application-executed function call and a hybrid flow

Use Structured Outputs through text.format when the model's next response is the final typed object your UI, database pipeline, or service will consume. Use function calling when the model must ask your application to retrieve live data, run code, or perform an action before the task is complete. Use both only when a tool result must be turned into a final schema-controlled response.

The overlap causes the confusion: strict function arguments also use Structured Outputs under the hood. But the two interfaces do not own the same part of your application. A schema can constrain data shape. It cannot execute a function, authorize an action, make a value true, or prove a side effect succeeded.

What must happen next?UseWhat your code must verify
Return a typed final answer from information already in contexttext.formatrefusal, parse result, field semantics
Ask the application for live data or an actionfunction callingtool selection, arguments, authorization, execution, returned result
Fetch or act first, then return a typed UI/API objectfunction calling, then text.formatthe complete tool loop plus the final schema and meaning
Only produce valid JSON on an older/limited surfaceJSON mode as a fallbackparse, then validate against your own schema

The decision is about ownership, not JSON

Ask one question before writing a schema: who owns the next step?

If the model already has everything it needs and you want an object such as a ticket classification, quiz result, UI card, or extraction record, the model owns the final response. text.format expresses that output contract directly.

If the answer depends on an order database, a search index, a payment service, a calendar, or any private application state, your application owns the work. The model may return a function_call with arguments, but your code must inspect it, enforce permissions, execute the operation, and return function_call_output.

This is why “function calling is structured output” is technically useful but architecturally incomplete. The argument envelope can be schema-constrained; the function call still introduces control flow and an external execution boundary.

Use text.format for a final typed response

Suppose a support transcript is already in the request and your UI needs a predictable classification card. No database lookup or side effect is required. The OpenAI Structured Outputs guide documents this as the direct-response case.

python
import os from typing import Literal from openai import OpenAI from pydantic import BaseModel client = OpenAI() class SupportCard(BaseModel): issue_type: Literal["delivery", "billing", "account", "other"] needs_live_data: bool user_message: str response = client.responses.parse( model=os.environ["OPENAI_MODEL"], input=[ { "role": "system", "content": ( "Classify the supplied message. " "If the message cannot be classified, use issue_type='other'." ), }, { "role": "user", "content": "My replacement order is two days late. What should I do?", }, ], text_format=SupportCard, ) card = response.output_parsed if card is None: raise RuntimeError("No parsed support card; inspect refusal or incomplete output") print(card)

This path is a good fit for extraction, normalization, grading against a supplied rubric, and data that renders directly into UI components. It avoids inventing a fake function that your application never intends to execute.

Do not stop at card is not None. The schema can prove that needs_live_data is a Boolean. It cannot prove that the model chose the correct Boolean for this message. Add domain validation and test examples where the input is missing, contradictory, irrelevant, or unsafe.

Use function calling when your application must do work

The same late-order question changes architecture if the answer needs current tracking data. A tool request is not the tracking result. The official function-calling lifecycle requires application execution and a second model turn.

javascript
import OpenAI from "openai"; const openai = new OpenAI(); const model = process.env.OPENAI_MODEL; if (!model) throw new Error("Set OPENAI_MODEL to a supported model in this project"); const tools = [{ type: "function", name: "get_order_status", description: "Read the latest status for an order the signed-in user owns.", strict: true, parameters: { type: "object", properties: { order_id: { type: "string", description: "The order identifier supplied by the user" } }, required: ["order_id"], additionalProperties: false } }]; let input = [{ role: "user", content: "Where is my replacement order ORDER-8831?" }]; let response = await openai.responses.create({ model, tools, input, tool_choice: "required" }); if (response.status !== "completed") { throw new Error(`Tool-selection response did not complete: ${response.status}`); } input.push(...response.output); let toolCallCount = 0; for (const item of response.output) { if (item.type !== "function_call") continue; if (item.name !== "get_order_status") { throw new Error(`Unknown or unauthorized tool: ${item.name}`); } toolCallCount += 1; const { order_id } = JSON.parse(item.arguments); if (typeof order_id !== "string" || !/^ORDER-\d+$/.test(order_id)) { throw new Error("order_id failed application validation"); } // Your application owns authentication, authorization, timeout handling, // idempotency, the database/API request, and audit logging. const result = await getOrderStatusForSignedInUser(order_id); input.push({ type: "function_call_output", call_id: item.call_id, output: JSON.stringify(result) }); } if (toolCallCount === 0) { throw new Error("The required live order lookup was not requested"); } response = await openai.responses.create({ model, tools, input, tool_choice: "none", instructions: "Answer using only the returned order status." }); if (response.status !== "completed") { throw new Error(`Final response did not complete: ${response.status}`); } console.log(response.output_text);

The model does not receive a database credential and does not become your authorization layer. Reject unknown tools, validate arguments again in application code, scope the lookup to the signed-in user, place timeouts around dependencies, and treat tool output as untrusted input when appropriate.

For an action such as charging a card or sending a message, idempotency is essential. A retry can repeat a side effect even when every function argument matches the schema. Use an application-generated idempotency key and record the provider's result before allowing another attempt.

Use both for “act, then format”

A hybrid flow is justified when live work must happen first and the final product still needs a stable object:

  1. The model returns get_order_status(order_id).
  2. Your application validates and executes the lookup.
  3. Your application returns function_call_output.
  4. A continuation produces a final SupportCard with text.format.

That final object might contain current_status, customer_safe_summary, available_actions, and needs_human_review. The tool schema governs the lookup arguments; the response schema governs the UI contract. Neither replaces the other.

Do not automatically make every request hybrid. If all necessary information is already in context, a tool round trip adds failure points. If the final response can be plain language, a second schema may add complexity without protecting a real consumer contract.

What strict: true guarantees—and what it does not

OpenAI's current strict-mode documentation recommends strict function tools. For compatible schemas:

  • every object sets additionalProperties: false;
  • every property appears in required;
  • an optional value is represented with a nullable type, such as ["string", "null"].

Strict mode makes the function arguments adhere to that schema. It does not guarantee that the model selected the right tool, copied the correct order ID, obtained user consent, had permission to act, or that the downstream service succeeded.

The same distinction applies to direct Structured Outputs. A required enum prevents an unknown enum token, but an incompatible input can still push the model toward a semantically wrong allowed value. The official guide advises handling inputs that cannot produce a valid response and checking the separate refusal path.

Four checks are better than “HTTP 200 plus valid JSON”

Use this verification sequence for both interfaces:

  1. Transport: Did the response complete, or was it incomplete, refused, rate-limited, or interrupted?
  2. Shape: Did the structured result or strict tool arguments match the expected schema?
  3. Meaning: Do identifiers, enum choices, dates, totals, and user-facing claims agree with trusted input and business rules?
  4. Effect: If a tool ran, did the database/API operation actually succeed exactly once, and is there an authoritative result to prove it?

A parsed object can fail check 3. A perfect tool call can fail check 4. Treat these as different failure classes so your retry logic does not repeat an unsafe operation or ask the model to repair a database outage.

Where JSON mode still fits

JSON mode is not an equal substitute for either architecture. OpenAI's comparison says JSON mode ensures valid JSON, while Structured Outputs ensure schema adherence. Use JSON mode only when the chosen model or integration surface cannot use the needed JSON Schema path, then validate the parsed object in your application and decide whether a retry is safe.

If a tool or live-data boundary exists, JSON mode does not remove it. Returning {"action":"refund"} is only a data object until trusted application code authorizes and performs the refund.

Production checklist

  • Use text.format when the schema is the final response contract.
  • Use a function tool only when application data, code, or a side effect is genuinely required.
  • Enable strict tool schemas and validate them in CI.
  • Branch on refusal, incomplete output, incompatible input, tool error, and timeout.
  • Authorize every tool in application code; never derive permission from model text.
  • Use idempotency for side effects and store an authoritative execution result.
  • Preserve the response items required for a correct tool continuation.
  • Test shape, meaning, and effects separately.
  • Keep schema definitions close to typed application models so they do not drift.

If you have not made a successful Responses API request yet, start with the OpenAI API key and first-response guide. Once that baseline works, add one contract at a time: first a typed response or one read-only tool, then the hybrid path only when the product actually needs it.

#OpenAI API#Structured Outputs#Function Calling#Responses API#JSON Schema
Share: