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

> Use text.format when your application needs the model's final typed answer. Use function calling when the answer requires application data or an action. Combine them when a real tool result must become a schema-controlled object; then check both the schema and the business result.

- URL: https://blog.laozhang.ai/en/posts/structured-outputs-vs-function-calling
- Published: 2026-07-27
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/en/about)
- Topic: API Guides
- Tags: OpenAI API, Structured Outputs, Function Calling, Responses API, JSON Schema

---
Use **Structured Outputs with `text.format`** when the model should return the final typed object your application consumes. Use **function calling** when it needs your application to fetch data or perform an operation. Use **both** when that operation must happen first and the final answer must also follow a schema.

The distinction is about what happens to the output. A product availability object can go straight into a UI. A request to `lookup_stock` must go through your application's validation and execution code before it becomes useful stock data. OpenAI's [Structured Outputs guide](https://developers.openai.com/api/docs/guides/structured-outputs) makes this distinction; strict function arguments use Structured Outputs under the hood, so the two approaches share schema enforcement without sharing the same execution flow.

| What the product needs | Start with | What counts as success |
|---|---|---|
| Extract availability from a supplied stock report | `text.format` with `json_schema` | A complete, accepted object whose values agree with the report |
| Read today's stock from your application | A function tool | An authorized lookup and its actual result |
| Read today's stock, then render typed availability cards | Function calling plus `text.format` | Successful lookups and a final object that agrees with them |
| Generate JSON on a surface that lacks the needed schema support | JSON mode plus application validation | Complete JSON, accepted schema, and valid business values |

![Choosing between a typed response and an application-executed tool](https://blog.laozhang.ai/posts/en/structured-outputs-vs-function-calling/img/cover.webp)

## The decision is about ownership, not JSON

A schema describes data. Function calling also supplies a protocol for asking the application to do work and returning its result. The model requests the operation; your code decides whether it is permitted and runs it. A function call is not evidence that the database was queried, a reservation was created, or a payment was accepted.

For example, these are three different requirements:

- “Turn this supplied inventory report into cards.” The information is already in context; use a final response schema.
- “Check whether these products are available now.” The answer depends on application state; expose a lookup tool.
- “Check availability and return cards for my frontend.” Use the lookup tool first, then the response schema.

You can design a custom dispatcher that reads a structured object such as `{"action":"lookup_stock"}`. Your application still has to define routing, allowed operations, execution, error handling, and continuation. Structured JSON does not execute itself. Native function calling provides a dedicated call/result interface when the intended output is a request for application work.

Neither contract has an inherent, universal cost or latency advantage. Compare the actual workflow: a direct extraction needs no external lookup, while a current-stock answer does. Removing that lookup changes what the product can honestly answer.

## Use `text.format` for a final typed response

Start with the object the consumer needs. This availability panel has a root object, an array of product cards, and an explicit unknown state. All fields remain present even when a value is unavailable:

```python
import json

PANEL_SCHEMA = {
    "type": "object",
    "properties": {
        "products": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "sku": {"type": ["string", "null"]},
                    "stock_state": {
                        "type": "string",
                        "enum": ["in_stock", "out_of_stock", "unknown"]
                    },
                    "quantity": {"type": ["integer", "null"]}
                },
                "required": ["sku", "stock_state", "quantity"],
                "additionalProperties": False
            }
        }
    },
    "required": ["products"],
    "additionalProperties": False
}

PANEL_FORMAT = {
    "type": "json_schema",
    "name": "availability_panel",
    "strict": True,
    "schema": PANEL_SCHEMA
}

def extraction_request(model, report):
    return {
        "model": model,
        "input": [
            {"role": "system", "content": (
                "Extract only the supplied stock report. Never infer a SKU "
                "or quantity. Use null for missing values and unknown for "
                "availability that the report does not establish."
            )},
            {"role": "user", "content": report}
        ],
        "text": {"format": PANEL_FORMAT}
    }
```

`extraction_request(model, report)` builds the JSON body for `POST /v1/responses`; it does not send a request. Supply a model that supports the feature in your project. The example below assumes the report describes one product but omits both its identifier and its stock count:

```json
{
  "products": [
    {"sku": null, "stock_state": "unknown", "quantity": null}
  ]
}
```

“Required” means the key must exist, not that the information is known. A missing quantity should not become zero: zero means out of stock, whereas `null` means the count has not been established. Your application should also check that a non-null quantity is nonnegative and that the state follows the quantity. A schema-valid `in_stock` with `quantity: 0` is still wrong for this product's rules.

![Illustration contrasting an unknown stock count with confirmed zero and positive stock counts](https://blog.laozhang.ai/posts/en/structured-outputs-vs-function-calling/img/unknown-stock.webp)

In the Responses API, the REST field is `text.format`. The Python SDK's parsing helper instead accepts `text_format=YourPydanticModel`; the JavaScript parsing helper uses `text.format` with `zodTextFormat`. Chat Completions uses `response_format`, with its own request structure. Do not mix these interfaces. See the [official SDK examples](https://developers.openai.com/api/docs/guides/structured-outputs) for the appropriate helper.

## Design strict schemas without guessing missing values

For strict function arguments and strict final responses, close every object with `additionalProperties: false` and include every declared property in `required`. Use nullable values for information that may be absent. OpenAI supports a [subset of JSON Schema](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas): the root must be an object, a root-level `anyOf` is unsupported, and composition keywords such as `if`, `then`, `else`, `allOf`, and `not` are unsupported. Express cross-field business rules in application code.

A nullable enum needs special care. Under JSON Schema semantics, both the type rule and the enum rule apply:

```json
{"type": ["string", "null"], "enum": ["warehouse", "store", null]}
```

This permits `null`. Removing `null` from the enum excludes it even though the type list includes it. If you do not need an enum, a plain nullable string is simpler. The local checks for this article verified this basic intersection rule; they do not prove that an OpenAI server accepts a particular schema.

Set `strict: true` explicitly when strictness is part of your contract. Under the [current function strict-mode rules](https://developers.openai.com/api/docs/guides/function-calling#strict-mode), an incompatible explicitly strict schema is rejected. If you omit `strict`, Responses attempts to normalize the schema into strict mode when possible, but falls back to best-effort non-strict calling when it cannot; the response tool then shows `strict: false`. Chat Completions remains non-strict by default. Explicit `strict: false` opts out in Responses.

Omission is therefore not a reliable way to demand strict arguments. If a schema is rejected, fix it while preserving the consumer's intended meaning. Silently dropping strictness or replacing an unknown quantity with a guessed number changes the contract.

## Use function calling when your application must do work

A function definition tells the model what it may request. In Responses, function fields are flat under the tool object:

```python
STOCK_TOOL = {
    "type": "function",
    "name": "lookup_stock",
    "description": "Read stock for a SKU explicitly requested by the user.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {"sku": {"type": "string"}},
        "required": ["sku"],
        "additionalProperties": False
    }
}
```

The returned `function_call` contains `name`, JSON-encoded `arguments`, an item `id`, and a `call_id`. Return the result using **the original `call_id`**, not the item ID. A result for `call_A` cannot satisfy `call_B`, even if both request the same function. Read the entire output array: it may contain no calls, one call, or several calls, alongside other items. These rules are part of the [official function-calling lifecycle](https://developers.openai.com/api/docs/guides/function-calling#handling-function-calls).

Tool choice controls what the model requests:

| Setting | Documented behavior | Application implication |
|---|---|---|
| `auto` | Zero, one, or multiple calls | Handle a final response or a call batch |
| `required` | One or more calls | Require suitable business inputs before choosing it |
| A forced function | A call to the specified function | Still validate and authorize its arguments |
| `none` | No function calls | Useful when formatting already obtained results |
| `parallel_tool_calls: false` | Zero or one call | Does not itself force a call |

These controls do not grant permission or guarantee execution success. The [parallel-calling documentation](https://developers.openai.com/api/docs/guides/function-calling#parallel-function-calling) also notes that multiple parallel calls on fine-tuned models disable strict mode for those calls. Check the specific model and configuration rather than assuming all combinations preserve strictness. The current guide says GPT-6 Astra and GPT-6.1 Sol require Responses for tool calling; that is a feature-specific condition, not a claim that every text request must use Responses.

If the user says only “check my item,” ask for the SKU or let the application return a missing-input state. Forcing a stock lookup without a usable identifier encourages a request your application should reject.

## Use both for “look up, then return typed cards”

Here is the complete critical path for the inventory example. It uses fictitious tenant-scoped inventory and an injected `send(payload)` function. The next section supplies a fully offline sender, so the assembled Python example runs without an SDK, credentials, or network access. A real integration must supply its own authenticated Responses transport and replace the in-memory inventory with its authorized backend.

```python
INVENTORY = {
    ("shop_A", "SKU-17"): 8,
    ("shop_A", "SKU-28"): 0,
    ("shop_B", "SKU-17"): 99
}

def response_parts(response):
    if response.get("status") != "completed":
        reason = (response.get("incomplete_details") or {}).get("reason")
        raise ValueError(f"Response did not complete: {reason or response.get('status')}")
    items = response.get("output", [])
    content = [part for item in items if item.get("type") == "message"
               for part in item.get("content", [])]
    if any(part.get("type") == "refusal" for part in content):
        raise ValueError("Model refused; do not render an availability panel")
    return items, content

def execute_lookup(call, tenant, requested_skus):
    if call.get("name") != "lookup_stock":
        raise ValueError("Tool is not allowed")
    args = json.loads(call["arguments"])
    if (not isinstance(args, dict) or set(args) != {"sku"}
            or not isinstance(args["sku"], str)
            or args["sku"] not in requested_skus):
        raise ValueError("SKU is not an explicitly requested product")
    sku = args["sku"]
    quantity = INVENTORY.get((tenant, sku))
    return {"sku": sku, "quantity": quantity}

def validate_panel(panel, results):
    if not isinstance(panel, dict) or set(panel) != {"products"}:
        raise ValueError("Invalid panel object")
    cards = panel["products"]
    if not isinstance(cards, list) or len(cards) != len(results):
        raise ValueError("Missing or extra product cards")
    seen = set()
    for card in cards:
        if not isinstance(card, dict) or set(card) != {"sku", "stock_state", "quantity"}:
            raise ValueError("Invalid card fields")
        sku, quantity = card["sku"], card["quantity"]
        if not isinstance(sku, str) or sku not in results or sku in seen:
            raise ValueError("Wrong or repeated SKU")
        if quantity is not None and (type(quantity) is not int or quantity < 0):
            raise ValueError("Invalid stock count")
        expected = results[sku]["quantity"]
        state = "unknown" if expected is None else ("in_stock" if expected > 0 else "out_of_stock")
        if quantity != expected or card["stock_state"] != state:
            raise ValueError("Card contradicts the lookup result")
        seen.add(sku)
    return panel

def availability_flow(send, model, tenant, requested_skus):
    if not requested_skus or any(not isinstance(s, str) or not s for s in requested_skus):
        raise ValueError("Ask for product identifiers before requesting tools")
    history = [{"role": "user", "content":
                "Check current stock for " + ", ".join(sorted(requested_skus))}]
    first = send({"model": model, "input": history.copy(),
                  "tools": [STOCK_TOOL], "tool_choice": "required"})
    items, _ = response_parts(first)
    calls = [item for item in items if item.get("type") == "function_call"]
    if not calls:
        raise ValueError("No stock lookup was requested")
    history.extend(items)
    by_call_id, results = {}, {}
    for call in calls:
        call_id = call.get("call_id")
        if not isinstance(call_id, str) or not call_id:
            raise ValueError("Missing call_id")
        signature = (call.get("name"), call.get("arguments"))
        if call_id in by_call_id:
            if by_call_id[call_id][0] != signature:
                raise ValueError("Conflicting reuse of call_id")
            continue
        result = execute_lookup(call, tenant, requested_skus)
        by_call_id[call_id] = (signature, result)
        results[result["sku"]] = result
        history.append({"type": "function_call_output", "call_id": call_id,
                        "output": json.dumps(result)})
    if set(results) != set(requested_skus):
        raise ValueError("Not every requested product was looked up")
    final = send({"model": model, "input": history,
                  "tools": [STOCK_TOOL], "tool_choice": "none",
                  "text": {"format": PANEL_FORMAT},
                  "instructions": "Return one card per lookup result. Copy quantities exactly; null means unknown."})
    final_items, content = response_parts(final)
    if any(item.get("type") == "function_call" for item in final_items):
        raise ValueError("Unexpected call during final formatting")
    texts = [part["text"] for part in content if part.get("type") == "output_text"]
    if len(texts) != 1:
        raise ValueError("Expected one final JSON document")
    return validate_panel(json.loads(texts[0]), results)
```

This example requests a single lookup batch, then deliberately disables tools for formatting. If your task permits further lookups, repeat the execution/continuation step with a bounded number of turns instead. Do not stop merely because the model returned a sentence. Stop when the required application work and acceptable final response are both present.

![Illustration of two product lookup requests matched to their own call IDs and then used for typed availability cards](https://blog.laozhang.ai/posts/en/structured-outputs-vs-function-calling/img/call-result-matching.webp)

The history keeps **all** first-response items, including any reasoning items, before adding tool results. The Python guide uses this form of continuation. In JavaScript, the current guide converts output with `toResponseInputItems(response.output)` before appending it to input. Keep the appropriate representation for your SDK; filtering history down to the calls or first message can lose required continuation state. See the [function-calling examples](https://developers.openai.com/api/docs/guides/function-calling). For an existing Chat Completions implementation, use the separate [Responses migration guide](https://blog.laozhang.ai/en/posts/chat-completions-to-responses-api-function-calling-migration).

### Run the critical path locally with synthetic responses

Append this sender to the preceding Python blocks. It checks that both results reached the continuation with their call IDs and that an intervening reasoning item was retained. Its response objects are fixtures, not actual provider output:

```python
def make_offline_sender():
    turn = 0
    def send(payload):
        nonlocal turn
        turn += 1
        if turn == 1:
            assert payload["tool_choice"] == "required"
            return {"status": "completed", "output": [
                {"type": "reasoning", "id": "rs_fixture", "summary": []},
                {"type": "function_call", "id": "fc_item_17", "call_id": "call_17",
                 "name": "lookup_stock", "arguments": '{"sku":"SKU-17"}'},
                {"type": "function_call", "id": "fc_item_28", "call_id": "call_28",
                 "name": "lookup_stock", "arguments": '{"sku":"SKU-28"}'}
            ]}
        assert turn == 2 and payload["tool_choice"] == "none"
        assert any(item.get("id") == "rs_fixture" for item in payload["input"])
        outputs = {item["call_id"]: json.loads(item["output"])
                   for item in payload["input"] if item.get("type") == "function_call_output"}
        assert outputs == {
            "call_17": {"sku": "SKU-17", "quantity": 8},
            "call_28": {"sku": "SKU-28", "quantity": 0}
        }
        panel = {"products": [
            {"sku": "SKU-17", "stock_state": "in_stock", "quantity": 8},
            {"sku": "SKU-28", "stock_state": "out_of_stock", "quantity": 0}
        ]}
        return {"status": "completed", "output": [{"type": "message", "content": [
            {"type": "output_text", "text": json.dumps(panel)}
        ]}]}
    return send

panel = availability_flow(make_offline_sender(), "SYNTHETIC_FIXTURE_ONLY",
                          "shop_A", {"SKU-17", "SKU-28"})
print(json.dumps(panel, indent=2))
```

The result has quantities `8` and `0`; it cannot substitute the other tenant's `99`. The tenant comes from application context, not model arguments. In a production backend, obtain that context from your authenticated session and enforce access in the backend itself. This local example demonstrates the control flow and comparison rules; it does not test authentication, a database, an installed SDK, server schema acceptance, or model accuracy.

## Handle refusal, incomplete output, and tool errors separately

A completed response can contain a `refusal` instead of your schema-controlled answer. Conversely, an incomplete response may contain only a fragment of JSON. Check response status and refusal content before accepting an object. OpenAI documents `max_output_tokens` and `content_filter` as incomplete-output reasons in the [Structured Outputs edge cases](https://developers.openai.com/api/docs/guides/structured-outputs). Do not parse a truncated document and fill its missing fields as if the task succeeded.

| Failure | Useful next action |
|---|---|
| Required input is absent | Ask for it, or return an explicit unknown state without inventing data |
| Model refusal | Return a refusal state to the caller; do not substitute a success object |
| Incomplete response | Inspect the reason; adjust the request only when appropriate and retry within a limit |
| Unknown tool, malformed arguments, or wrong identifier | Reject the request before backend execution |
| Lookup timeout or backend error | Surface an application error; do not turn it into a stock count of zero |
| Final card contradicts a successful lookup | Reject or regenerate the final formatting from the same saved result |
| Uncertain outcome after a write | Check the operation record before attempting the write again |

A successful lookup with no known count can legitimately produce `quantity: null` and `stock_state: "unknown"`. A timeout is different: it means the lookup did not establish a result. If you need errors to travel back to the model, return a documented error object under the matching `call_id` and define how the final consumer represents that error. The example stops on execution errors rather than silently converting them to unknown stock.

## Verify shape, meaning, and effects separately

Schema validation answers whether the object has the allowed fields and types. Business validation answers whether the object agrees with the input and backend result. Execution evidence answers whether the operation happened.

For the worked example, `quantity: 8` with `stock_state: "out_of_stock"` passes the basic schema but fails business validation. A perfectly formed call for `SKU-99` also fails because the user requested different products. A model-generated “I reserved eight units” proves no reservation at all: the only exposed function performs a lookup.

If you add a stock reservation tool, treat it as a separate write operation. Generate an idempotency key in application code for the intended operation, persist the requested operation and its outcome, and return an authoritative reservation ID or error. A `call_id` associates a result with a model call; it is not user permission, a backend transaction ID, or a universal exactly-once guarantee. A retry can have a new call ID for the same intended write, so deduplicating only by `call_id` is insufficient.

The article's local checks cover Python syntax, schema/nullable-value behavior, two-call result matching, retained history, tenant-scoped fixture data, refusal and incomplete branches, and contradictory final objects. They use synthetic data only. They do not measure latency, prices, model reliability, or actual execution effects.

## Where JSON mode still fits

JSON mode is a fallback when the chosen model or integration cannot use the required Structured Outputs schema. In Responses, use `text.format: {"type":"json_object"}` and explicitly instruct the model to produce JSON. The [official JSON-mode guidance](https://developers.openai.com/api/docs/guides/structured-outputs#json-mode) requires JSON instructions and the string “JSON” in the context; it also calls out edge cases such as incomplete output. JSON mode does not enforce your application's schema.

Parse the complete result, validate its shape, and apply the same business rules. If a live lookup or write is needed, JSON mode does not eliminate that execution boundary. It also does not make a custom JSON dispatcher safer by itself.

## Frequently asked questions

### Is function calling a form of Structured Outputs?

Strict function arguments use Structured Outputs to enforce their schema. Function calling additionally defines a request/result exchange for application work. A final response schema controls what the model returns to its consumer; it does not perform that work. This distinction is explicit in the [OpenAI guide](https://developers.openai.com/api/docs/guides/structured-outputs).

### Can I use function calling without executing a function?

You can consume the arguments as data, but then you are choosing a call-shaped interface for a final data object. When no application work is intended, `text.format` communicates that requirement more directly. A custom dispatcher remains an option when you intentionally own the dispatch protocol and its implementation.

### Does `tool_choice: "required"` guarantee that my tool succeeded?

No. It controls the model's request for one or more tools, under the documented [tool-choice behavior](https://developers.openai.com/api/docs/guides/function-calling#tool-choice). Your application must still inspect the response, validate the request, execute the permitted operation, and check its result. `parallel_tool_calls: false` limits the number of calls; it does not itself require a call.

### Should unknown fields be omitted, empty strings, or null?

For strict schemas, keep declared fields required and make genuinely missing values nullable. Choose meanings your consumer can distinguish: zero stock, an unknown count, and an execution error are different states. If a nullable field also uses `enum`, include `null` in that enum when it must be accepted.

### Does a parsed object prove that the answer is correct?

No. Schema adherence does not establish factual correctness, permission, or execution success. Compare identifiers and values with trusted input or actual backend results before rendering or storing the answer.

If you still need to configure the backend credential and make your first request, start with the [OpenAI API key setup guide](https://blog.laozhang.ai/en/posts/openai-api-key-free-trial). Once that connection works, choose the contract around the next step your product needs and keep the application's result as the source of truth.

## Sources

External pages this guide links to, in the order they appear. Last updated 2026-10-07.

- [Structured Outputs guide](https://developers.openai.com/api/docs/guides/structured-outputs) (developers.openai.com)
- [current function strict-mode rules](https://developers.openai.com/api/docs/guides/function-calling) (developers.openai.com)
