}

OpenAI Function Calling in Python: Complete Guide 2026 (Tools, Parallel Calls, Streaming)

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:

  1. You send a message and a list of available tools to the API
  2. The model responds with a tool_calls object instead of plain text
  3. You parse the tool call, run the actual function, and send the result back
  4. 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_calls must be appended to messages before the tool results. The API requires this ordering.
  • Each tool result message uses "role": "tool" and must include the tool_call_id that matches the corresponding tool call.
  • The model may or may not call a tool. Always check message.tool_calls before 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:

ValueBehavior
"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.

Related Guides

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro