OpenAI Function Calling in Python: Complete Guide 2026 (Tools, Parallel Calls, Streaming)
Function calling — also called tool use — is one of the most practical capabilities in the OpenAI API. It lets language models interact with your code instead of just generating text. Rather than asking a model to "tell you the weather", you give it a tool that actually fetches the weather, and the model decides when to call it and what arguments to pass.
This guide covers everything you need to build reliable, production-ready function calling in Python: defining tools, handling tool_calls in the response, running parallel calls, forcing specific tools, extracting structured data, and streaming with tools.
What is Function Calling?
Function calling (also called "tool use") lets LLMs call your Python functions. Instead of just generating text, the model can:
- Query a database
- Call an API
- Run a calculation
- Read a file
The model decides when to call a function and what arguments to pass. You execute the function and return the result.
The flow looks like this:
- You send a message and a list of available tools to the API
- The model responds with a
tool_callsobject instead of plain text - You parse the tool call, run the actual function, and send the result back
- The model uses that result to generate a final response
The model never runs your Python code directly — it only tells you what it wants to call. You remain in control of execution.
Setup
pip install openai
You need an OpenAI API key in your environment:
export OPENAI_API_KEY=sk-...
Basic Function Calling
from openai import OpenAI
import json
client = OpenAI()
# Define tools
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}
},
"required": ["city"]
}
}
}
]
def get_weather(city: str, unit: str = "celsius") -> dict:
"""Your actual implementation here."""
return {"city": city, "temperature": 22, "condition": "sunny", "unit": unit}
def run_conversation(user_message: str):
messages = [{"role": "user", "content": user_message}]
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto" # let model decide
)
message = response.choices[0].message
# Check if model wants to call a function
if message.tool_calls:
messages.append(message) # add assistant message with tool_calls
# Execute each tool call
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
# Call the actual function
if func_name == "get_weather":
result = get_weather(**func_args)
# Add result to messages
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
# Get final response with tool results
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
return final_response.choices[0].message.content
return message.content
print(run_conversation("What's the weather in Berlin?"))
A few things to note about this pattern:
- The assistant message containing
tool_callsmust be appended tomessagesbefore the tool results. The API requires this ordering. - Each tool result message uses
"role": "tool"and must include thetool_call_idthat matches the corresponding tool call. - The model may or may not call a tool. Always check
message.tool_callsbefore assuming it did.
Parallel Tool Calls
GPT-4o can call multiple tools simultaneously when the prompt requires information from more than one source:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Get current time in a timezone",
"parameters": {
"type": "object",
"properties": {"timezone": {"type": "string"}},
"required": ["timezone"]
}
}
}
]
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the weather and time in Tokyo?"}],
tools=tools
)
# Model may call both tools at once
print(f"Tool calls: {len(response.choices[0].message.tool_calls)}")
When handling parallel calls, loop over all tool_calls in the response and append a result message for each one before making the follow-up API call. The model expects a result for every tool call it made.
def handle_parallel_calls(response, messages):
message = response.choices[0].message
messages.append(message)
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
if func_name == "get_weather":
result = get_weather(**func_args)
elif func_name == "get_time":
result = get_time(**func_args)
else:
result = {"error": f"Unknown function: {func_name}"}
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
return messages
Force a Specific Tool
By default, tool_choice="auto" lets the model decide whether to call a tool and which one. You can also force a specific tool:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Tell me about Paris"}],
tools=tools,
tool_choice={"type": "function", "function": {"name": "get_weather"}} # force this tool
)
Other tool_choice options:
| Value | Behavior |
|---|---|
"auto" | Model decides (default) |
"none" | Never call a tool, always respond with text |
{"type": "function", "function": {"name": "..."}} | Always call this specific function |
Forcing a tool is useful for structured data extraction, where you want the model to always fill out a schema rather than sometimes generating prose.
Structured Data Extraction
Function calling is an excellent pattern for extracting structured data from unstructured text. Force a tool call to guarantee the model returns data in your schema:
extract_tool = {
"type": "function",
"function": {
"name": "extract_invoice",
"description": "Extract structured data from an invoice",
"parameters": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total_amount": {"type": "number"},
"date": {"type": "string", "format": "date"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"amount": {"type": "number"}
}
}
}
},
"required": ["vendor", "total_amount", "date"]
}
}
}
invoice_text = """
Invoice from Acme Corp
Date: 2026-05-07
Item: Server hosting - $450.00
Item: Domain renewal - $15.00
Total: $465.00
"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Extract data from this invoice:\n{invoice_text}"}],
tools=[extract_tool],
tool_choice={"type": "function", "function": {"name": "extract_invoice"}}
)
data = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
print(data)
# {'vendor': 'Acme Corp', 'total_amount': 465.0, 'date': '2026-05-07',
# 'items': [{'description': 'Server hosting', 'amount': 450.0},
# {'description': 'Domain renewal', 'amount': 15.0}]}
Because you forced the tool call, you don't need to check if message.tool_calls — you can read the arguments directly. Note that the arguments are always a JSON string; always use json.loads() to parse them.
Error Handling in Tool Calls
Tool execution can fail. Always wrap your function calls and return a structured error rather than letting an exception propagate:
def safe_tool_call(func_name: str, func_args: dict) -> str:
try:
if func_name == "get_weather":
result = get_weather(**func_args)
return json.dumps(result)
else:
return json.dumps({"error": f"Unknown function: {func_name}"})
except Exception as e:
return json.dumps({"error": str(e)})
Return the error as a JSON string in the "tool" role message. The model will read it and typically communicate the failure to the user in its final response. This is better than raising an exception, which would break the conversation loop.
Building a Multi-Tool Assistant
Here is a complete example with two tools, proper error handling, and a conversation loop:
from openai import OpenAI
import json
from datetime import datetime
import pytz
client = OpenAI()
def get_weather(city: str, unit: str = "celsius") -> dict:
# Replace with a real weather API call
return {"city": city, "temperature": 18, "condition": "cloudy", "unit": unit}
def get_calendar_events(date: str) -> dict:
# Replace with your calendar API
return {"date": date, "events": ["Team standup at 10:00", "Code review at 14:00"]}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_calendar_events",
"description": "Get calendar events for a specific date (YYYY-MM-DD format)",
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "Date in YYYY-MM-DD format"}
},
"required": ["date"]
}
}
}
]
FUNCTION_MAP = {
"get_weather": get_weather,
"get_calendar_events": get_calendar_events,
}
def run_agent(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=TOOLS,
tool_choice="auto"
)
message = response.choices[0].message
finish_reason = response.choices[0].finish_reason
if finish_reason == "stop":
return message.content
if finish_reason == "tool_calls":
messages.append(message)
for tool_call in message.tool_calls:
func_name = tool_call.function.name
try:
func_args = json.loads(tool_call.function.arguments)
func = FUNCTION_MAP.get(func_name)
result = func(**func_args) if func else {"error": f"Unknown: {func_name}"}
except Exception as e:
result = {"error": str(e)}
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
print(run_agent("What's the weather in Tokyo and what do I have on my calendar today?"))
Using finish_reason to drive the loop is more robust than checking tool_calls directly. When finish_reason == "stop", the model is done and the content is the final answer.
Streaming with Tool Calls
with client.chat.completions.stream(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the weather in London?"}],
tools=tools
) as stream:
for event in stream:
if event.type == "content.delta":
print(event.delta, end="", flush=True)
elif event.type == "tool_calls.function.arguments.delta":
print(event.delta, end="", flush=True) # stream tool call args
Streaming tool calls lets you display partial argument construction in real time, which is useful for long structured outputs or when you want to show activity to the user while the model thinks.
Common Mistakes
Forgetting to append the assistant message before tool results. The API requires the message with tool_calls to come before the corresponding "tool" role messages. Skip it and you will get a validation error.
Using json.loads() on already-parsed arguments. The tool_call.function.arguments property is always a raw JSON string, even if the parameters are simple. Always parse it.
Not handling the case where tool_calls is None. The model may respond with plain text instead of calling a tool. Always check before iterating.
Sending too many tools. Each tool definition uses tokens. If you have dozens of tools, consider selecting only the relevant subset per request, or use a routing layer to pick the right tools.