Function calling enables language models to interact with external tools and services. Instead of just producing text, models can request data from APIs, perform calculations, query databases, or trigger any custom logic you define.
Let's see how function calling works with the OpenAI API. You'll learn how to define functions for the model to use, handle function calls in your code, and return tool call outputs to complete the interaction loop.
How it works
Function calling allows for structured communication between the model and your application's functionality. When you prompt a pre-trained model, "I need budgeting assistance based on my financial data," it can't know your banking data—it needs to call an actual financial service. This capability transforms language models from passive text generators into active agents capable of accomplishing real-world tasks.
The model decides when to use tools based on the user’s input. If prompted to suggest the most suitable loan products based on a customer’s financial situation, the model recognizes it needs external data and generates a function call. If they ask for an explanation of quantum physics, the model responds directly without needing tools. This decision-making happens automatically based on the capabilities you've made available through functions.
The function calling workflow follows a request-execution-response pattern, creating a conversation loop between your application and the model. First, you send a request that includes both user input and available function definitions. The model analyzes the input and determines whether any functions are needed to fulfill the request. If functions are required, the model returns structured outputs instead of text responses.
Your application then becomes the execution engine that actually performs the work the model requests. When the model asks to search for products, your code queries the database. When it wants to send an email, your code interfaces with the email service. This separation means the model focuses on understanding what needs to happen, while your application ensures it happens securely and correctly.
After executing the requested functions, you send the results back to the model by making another request that includes the entire conversation history. This includes the original input, the model's function call requests, and the outputs you obtained after calling the functions. The model then uses this complete context to formulate a natural language response. For this pattern to work effectively, your application architecture needs to handle state properly. You need to properly orchestrate interactions where each exchange might trigger additional requests that feed back into subsequent exchanges.
Function definition
Now that you understand the overall workflow, let's explore how to define the functions that enable this communication. Each function definition serves as documentation that tells the model about a particular capability. It includes these critical components: a type that must always be “function”, a name that identifies the function, a description that helps the model understand when to use it, and a parameters schema that specifies the expected inputs.
{
"type": "function",
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query"]
},
"strict": False
}For the Chat Completions API, the function definition is slightly different:
{
"type": "function",
"function": {
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query"]
},
"strict": False
}
}The function name should be clear and descriptive, using snake_case convention for consistency. The model uses this identifier to choose which function to call when multiple options are available. Keep names concise but meaningful—calculate_shipping_cost conveys purpose better than generic terms like shipping , or overly verbose alternatives like determine_package_delivery_expense_estimation. Good naming helps the model make accurate decisions about which tool to use for any given request.
The description acts as the model's instruction manual for your function. Be specific about what the function does, what kind of input it expects, and any limitations or special behaviors. Instead of writing "search function," use "Search for products in the store inventory by name, category, or specifications." This specificity improves the model's decision-making and reduces incorrect function calls.
The parameters section defines the structure and types of data your function expects. Each parameter should have a clear type, like string, integer, and others. Include descriptions for individual parameters that help the model provide the right data format. For example, specify that a date must be in "YYYY-MM-DD" format to avoid confusion about date representations. Enums are particularly valuable for constraining the model to a specific list of allowed values. The model will choose from these options or ask the user for clarification if the request doesn't map clearly to available choices.
You can include a required list for parameters that must be provided, and include defaults for optional parameters. If you need the model to adhere to the function schema strictly, enable strict mode strict": True. For this mode, you must also set additionalProperties to false for each object, and mark all properties as required. See example below.
Strict mode
{
"type": "function",
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query", "category", "max_price", "limit"],
"additionalProperties": False
},
"strict": True
}{
"type": "function",
"function": {
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query", "category", "max_price", "limit"],
"additionalProperties": False
},
"strict": True
}
}Calling functions
With your functions properly defined, the next step is to handle the actual function calls the model generates. When the model decides to use a function, it creates structured output items that your application must parse and execute. Each function call request contains a unique identifier, a function call ID, a type (function_call), name, and arguments encoded as JSON:
{
"id"="fc_082bef9ace18e6910069721175461881958c8943a345457091",
"name"="search_product_catalog",
"call_id"="call_5PLUW88c61XJqgSVfbzdVMcr",
"type"="function_call",
"arguments"="{\"query\":\"wireless headphones\",\"category\":\"electronics\",\"max_price\":150,\"limit\":10}",
"status"="completed"
}The call_id serves as a crucial tracking mechanism throughout the entire execution process. This identifier enables matching results back to their corresponding requests and is especially important when handling multiple parallel calls. Remember, the model can generate several function calls simultaneously when a user's query requires interaction with multiple systems.
messages = [
{"role": "developer", "content": "Use the right tools."},
{"role": "user", "content": "Find me wireless headphones under $150"}
] # initialize conversation history
response = client.responses.create(
model="gpt-5.1",
input=messages,
tools=my_tools # the tool definition above
)
# Process function calls from the response
for item in response.output:
if item.type == "function_call":
print(f"The model wants to call: {item.name} with arguments {json.loads(item.arguments)} and ID: {item.call_id}")messages = [
{"role": "system", "content": "Use the right tools."},
{"role": "user", "content": "Find me wireless headphones under $150"}
] # initialize conversation history
# Initial request
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=completion_tools
)
assistant_message = completion.choices[0].message
messages.append(assistant_message) # build chat history by adding the assistant's message
# Process tool calls
for item in assistant_message.tool_calls:
if item.type == "function":
print(f"The model wants to call: {item.function.name} with {json.loads(item.function.arguments)} and ID: {item.id}")The arguments arrive as JSON-encoded data rather than a Python dictionary, requiring you to use json.loads() for conversion. This parsing step also provides an opportunity to validate the data and handle any malformed inputs gracefully. Always treat these arguments as user input that requires validation—check types, ranges, and required fields before proceeding with function execution.
Next, you need to execute the called functions and pass the right arguments to them. You might have dozens of functions available, each serving different purposes within your application. Create a routing system that matches function names to actual implementations, ensuring that security checks and business logic remain under your control.
def search_product_catalog(query, category=None, max_price=None, limit=10):
print(f"Executing search for: {query} in category: {category} with max price: {max_price}")
# Mock data
return [
{"id": "h1", "name": "Wireless Headphones", "price": 99.99, "category": "electronics"},
{"id": "h2", "name": "Bluetooth Earbuds", "price": 49.99, "category": "electronics"}
]
def get_user_preferences(user_id):
return {"preferred_category": "electronics", "max_budget": 200}Error handling during function execution falls entirely within your responsibility as well. If a function fails because a service is unavailable or parameters are invalid, catch the error and return a meaningful message as the function output. The model can then incorporate this error information into its response, explaining to users what went wrong and potentially suggesting fixes. This approach maintains a positive user experience even when backend systems encounter problems.
The model treats your functions as black boxes, knowing only what you communicate through the function definition and seeing only what you return as output. This abstraction allows you to change implementation details without affecting how the model interacts with your functions. You maintain complete control over security, data access, and rules while providing a clean interface for the model to work with.
Function call outputs
After successfully executing the requested functions, you must send the results back to the model to complete the interaction cycle. This step transforms the raw function outputs into natural language responses that users can understand and act upon. The process requires careful attention to data formatting and conversation state management.
Function outputs must follow a specific structure that includes the corresponding call_id from the original request. This gives the model the full picture: what was requested, what functions it called, and what results those functions returned. This is particularly useful when you have multiple parallel function calls where execution order might vary. Each output must be JSON-serialized, even for simple values, such as numbers or Boolean.
Once you execute the functions, make a final request including the full conversation history.
for item in response.output:
if item.type == "function_call":
print(f"The model wants to call: {item.name} with arguments {json.loads(item.arguments)} and ID: {item.call_id}")
try:
if item.name == "search_product_catalog":
result_data = search_product_catalog(json.loads(item.arguments))
elif item.name == "get_user_preferences":
result_data = get_user_preferences(json.loads(item.arguments))
else:
result_data = {"error": f"Unknown function: {item.name}"}
except Exception as e:
result_data = {"error": str(e)}
result = {
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(result_data)
}
messages.append(result) # append the function call's outputs
# Send the complete conversation history back to the model
final_response = client.responses.create(
model="gpt-5-mini",
input=messages
)
print("\nFinal Response:")
print(final_response.output_text)For chat completions, you must return the output with the tool role.
for item in assistant_message.tool_calls:
if item.type == "function":
print(f"The model wants to call: {item.function.name} with {json.loads(item.function.arguments)} and ID: {item.id}")
try:
if item.function.name == "search_product_catalog":
result = search_product_catalog(json.loads(item.function.arguments))
elif item.function.name == "get_user_preferences":
result = get_user_preferences(json.loads(item.function.arguments))
else:
result = {"error": f"Unknown function: {item.function.name}"}
except Exception as e:
result = {"error": str(e)}
tool_result = {
"tool_call_id": item.id,
"role": "tool",
"name": item.function.name,
"content": json.dumps(result)
}
messages.append(tool_result) # append the function call's outputs
# Final request with tool results
final_completion = client.chat.completions.create(
model="gpt-5-mini",
messages=messages
)
print("\nFinal Response:")
print(final_completion.choices[0].message.content)When designing your function outputs, consider what information the model actually needs versus what your internal functions might return. If your database query returns 1,000 product records, sending everything could exceed token limits and cause unnecessary costs. Instead, summarize the data or extract the most relevant pieces. Structure outputs to include useful metadata like result counts, query parameters used, or status information that helps the model provide complete answers.
Multi-round function calling takes this even further. The model might determine that additional function calls are necessary based on the results of initial calls. For example, after searching for products, it might realize it needs to check inventory levels or calculate personalized pricing. Your application should be designed to handle these iterative workflows as well.
Full code
from openai import OpenAI
import dotenv
import json
dotenv.load_dotenv()
client = OpenAI()
def search_product_catalog(query, category=None, max_price=None, limit=10):
print(f"Executing search for: {query} in category: {category} with max price: {max_price}")
# Mock data
return [
{"id": "h1", "name": "Wireless Headphones", "price": 99.99, "category": "electronics"},
{"id": "h2", "name": "Bluetooth Earbuds", "price": 49.99, "category": "electronics"}
]
def get_user_preferences(user_id):
return {"preferred_category": "electronics", "max_budget": 200}
# tool definition
tools = [
{
"type": "function",
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query"]
},
"strict": False
}
]
messages = [
{"role": "developer", "content": "Use the right tools."},
{"role": "user", "content": "Find me wireless headphones under $150"}
] # initialize conversation history
# initial request
response = client.responses.create(
model="gpt-5-mini",
input=messages,
tools=tools
)
print(response)
messages += response.output
print(messages)
for item in response.output:
if item.type == "function_call":
print(f"The model wants to call: {item.name} with arguments {json.loads(item.arguments)} and ID: {item.call_id}")
try:
if item.name == "search_product_catalog":
result_data = search_product_catalog(json.loads(item.arguments))
elif item.name == "get_user_preferences":
result_data = get_user_preferences(json.loads(item.arguments))
else:
result_data = {"error": f"Unknown function: {item.name}"}
except Exception as e:
result_data = {"error": str(e)}
result = {
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(result_data)
}
messages.append(result) # append the function call's outputs
# Send the complete conversation history back to the model
final_response = client.responses.create(
model="gpt-5-mini",
input=messages
)
print(final_response.output_text)from openai import OpenAI
from dotenv import load_dotenv
import json
load_dotenv()
client = OpenAI()
def search_product_catalog(query, category=None, max_price=None, limit=10):
print(f"Executing search for: {query} in category: {category} with max price: {max_price}")
# Mock data
return [
{"id": "h1", "name": "Wireless Headphones", "price": 99.99, "category": "electronics"},
{"id": "h2", "name": "Bluetooth Earbuds", "price": 49.99, "category": "electronics"}
]
def get_user_preferences(user_id):
return {"preferred_category": "electronics", "max_budget": 200}
completion_tools = [
{
"type": "function",
"function": {
"name": "search_product_catalog",
"description": "Search for products in the store inventory by name, category, or specifications",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms for product name or description"
},
"category": {
"type": "string",
"description": "Product category filter",
"enum": ["electronics", "clothing", "books", "home", "sports"]
},
"max_price": {
"type": "number",
"description": "Maximum price filter in dollars"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 10
}
},
"required": ["query", "category", "max_price", "limit"],
"additionalProperties": False
},
"strict": True
}
}
]
messages = [
{"role": "system", "content": "Use the right tools."},
{"role": "user", "content": "Find me wireless headphones under $150"}
]
# Initial request
completion = client.chat.completions.create(
model="gpt-5-mini",
messages=messages,
tools=completion_tools
)
assistant_message = completion.choices[0].message
print(assistant_message)
messages.append(assistant_message)
for item in assistant_message.tool_calls:
if item.type == "function":
print(f"The model wants to call: {item.function.name} with {json.loads(item.function.arguments)} and ID: {item.id}")
try:
if item.function.name == "search_product_catalog":
result = search_product_catalog(json.loads(item.function.arguments))
elif item.function.name == "get_user_preferences":
result = get_user_preferences(json.loads(item.function.arguments))
else:
result = {"error": f"Unknown function: {item.function.name}"}
except Exception as e:
result = {"error": str(e)}
tool_result = {
"tool_call_id": item.id,
"role": "tool",
"name": item.function.name,
"content": json.dumps(result)
}
messages.append(tool_result)
# Final request with tool results
final_completion = client.chat.completions.create(
model="gpt-5-mini",
messages=messages
)
print("\nFinal Response:")
print(final_completion.choices[0].message.content)Additional setup
In addition to defining tools and handling tool calls, the Responses API supports several configuration switches. These let you control when tools are used, how many are used per turn, and how tool-call data is delivered during streaming. These controls are especially useful when you need deterministic behavior, want to limit execution risk, or are optimizing for latency and cost.
By default, the model decides whether it should use tools and how many tool calls are needed to complete the request. You can override that behavior with the tool_choice parameter:
"tool_choice": "auto"— This is the default behavior. The model can call zero, one, or multiple tools as needed."tool_choice": "required"— forces the model to call one or more tools. Useful when you want to guarantee that the response is grounded in tool output, such as always fetching fresh data from a database or an API."tool_choice": { "type": "function", "name": "user_info" }— forced function makes the model call exactly one specific function. Use this when the user request must map to a single controlled action, or when your flow expects one tool call before anything else happens."tool_choice": "none"— imitate the behavior of passing no tools. The model will not produce tool calls.
Sometimes you want to keep a larger tool list defined, but only allow a subset to be callable for a specific request. Allowed tools lets you restrict tool execution without changing the tools you send:
"tool_choice": {
"type": "allowed_tools",
"mode": "auto",
"tools": [
{ "type": "function", "name": "search_product_catalog" },
{ "type": "function", "name": "get_user_preferences" }
]
}This pattern is useful when different product surfaces share the same base tool set, but each surface needs tighter permissions. In cases where a model generates multiple tool calls in a single turn, you can execute independent calls in parallel to reduce latency, but this requires more complex orchestration. If you want to prevent multiple tool calls in one turn, set parallel_tool_calls to false. This forces the model to produce either zero tool calls or exactly one tool call per request.
When streaming responses, tool call arguments may arrive incrementally. Your handler should accumulate argument deltas until the model signals completion.
Best practices
First, use descriptive function names and documentation — invest time in clear function names and comprehensive descriptions. The model relies heavily on these to determine when and how to use your functions. A well-documented function with clear parameter descriptions results in more accurate function calls and fewer errors. Keep functions clear, obvious, and intuitive.
Next, it is important to validate and sanitize function arguments before executing functions. The model generates these arguments, but they should be treated as user input. Check types, ranges, and required fields. Handle malformed or unexpected arguments gracefully rather than letting exceptions propagate.
Whenever possible, format function outputs as structured JSON. This makes it easier for the model to extract specific information and incorporate it into responses. Include relevant metadata, such as timestamps, confidence scores, or error codes, to help the model provide better answers. When functions fail, return error information as the function output rather than letting exceptions break your application flow. The model can incorporate error context into its response, explaining to users what went wrong and potentially suggesting changes.
Keep individual functions focused on single responsibilities. If a function tries to do too much, split it into smaller functions that the model can compose together. However, avoid too many functions for higher accuracy. Also, remember that function definitions, calls, and outputs all consume tokens. For frequently-used functions with large schemas, this adds up quickly. Optimize descriptions to be concise yet clear, and summarize large function outputs before sending them back to the model.
Finally, test with edge cases. Try queries that might confuse the model about when to call functions. Test with ambiguous inputs, requests for multiple pieces of information, and scenarios where function calls aren't needed. This reveals gaps in your function descriptions or missing error handling. The playground is very useful for this iterative refinement.
Conclusion
Function calling transforms LLMs from text generators into capable agents that can interact with external systems. You've learned how the request-execution-response pattern creates a communication bridge between natural language understanding and real-world actions. You also learned how to define functions and how to handle the complete lifecycle of function calls and outputs.
As you implement function calling in your applications, focus on clear function definitions, robust error handling, and efficient token usage. These practices ensure reliable, performant systems that leverage the reasoning capabilities of LLMs combined with your application's unique functionality.