Gemini 2.0 Function Calling: Real-World Tool Use Examples

Build production-ready Gemini 2.0 function calling in Python. Weather lookups, database queries, multi-tool chaining — with full working code.

Problem: Gemini 2.0 Gives You Text — You Need Actions

Getting Gemini to answer questions is easy. Getting it to reliably call your code, validate arguments, and chain multiple tools in one turn is where most tutorials stop.

This guide shows four production-ready patterns: a single tool call, parallel tool calls, multi-turn chaining, and error handling when the model passes bad arguments.

You'll learn:

  • How to define tools Gemini 2.0 actually calls reliably
  • How to handle parallel and sequential tool calls in one response
  • How to feed tool results back into the conversation correctly

Time: 20 min | Difficulty: Intermediate


Why Function Calling Fails in Practice

Gemini's function calling works differently from simple chat. The model doesn't execute your code — it returns a structured function_call object. Your code runs the function, then sends the result back. If you skip that loop, the model never finishes.

Common failure modes:

  • Passing the result as a user message instead of a tool message
  • Ignoring finish_reason: STOP vs finish_reason: TOOL_CALLS
  • Defining schemas with optional fields the model misses entirely

Setup

# Requires Python 3.11+
pip install google-genai
import os
from google import genai
from google.genai import types

client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
MODEL = "gemini-2.0-flash"

Get your API key at Google AI Studio. The gemini-2.0-flash model handles function calling at low latency and cost — use gemini-2.0-pro if you need stronger reasoning for complex schemas.


Example 1: Single Tool Call — Weather Lookup

This is the baseline pattern. One function, one call, one result.

Step 1: Define the Tool Schema

get_weather = types.FunctionDeclaration(
    name="get_weather",
    description="Returns current temperature and conditions for a city. "
                "Use when the user asks about weather in a specific location.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "city": types.Schema(
                type=types.Type.STRING,
                description="City name, e.g. 'Tokyo' or 'New York'",
            ),
            "unit": types.Schema(
                type=types.Type.STRING,
                enum=["celsius", "fahrenheit"],
                description="Temperature unit. Default to celsius.",
            ),
        },
        required=["city"],  # unit is optional — model fills it from context
    ),
)

tools = types.Tool(function_declarations=[get_weather])

Step 2: Send the Initial Request

response = client.models.generate_content(
    model=MODEL,
    contents="What's the weather in Tokyo right now?",
    config=types.GenerateContentConfig(tools=[tools]),
)

Step 3: Handle the Tool Call and Return the Result

import json

def handle_tool_calls(response, tools_config):
    """Run the tool call loop until the model returns a final text response."""
    contents = [
        types.Content(role="user", parts=[types.Part(text="What's the weather in Tokyo right now?")])
    ]

    # Append model's function_call turn
    contents.append(response.candidates[0].content)

    for part in response.candidates[0].content.parts:
        if part.function_call:
            fc = part.function_call

            # Your actual implementation goes here
            result = call_real_weather_api(fc.args["city"], fc.args.get("unit", "celsius"))

            # Return result as a tool role message — NOT user role
            contents.append(
                types.Content(
                    role="tool",
                    parts=[
                        types.Part(
                            function_response=types.FunctionResponse(
                                name=fc.name,
                                response={"result": result},
                            )
                        )
                    ],
                )
            )

    # Send tool result back to get final answer
    final = client.models.generate_content(
        model=MODEL,
        contents=contents,
        config=types.GenerateContentConfig(tools=tools_config),
    )
    return final.text


def call_real_weather_api(city: str, unit: str) -> dict:
    # Replace with real API call (e.g. Open-Meteo, WeatherAPI)
    return {"city": city, "temperature": 18, "unit": unit, "conditions": "Partly cloudy"}

Expected output:

The weather in Tokyo is currently 18°C and partly cloudy.

If it fails:

  • AttributeError: 'NoneType' has no attribute 'function_call' → Check finish_reason. If it's STOP not TOOL_CALLS, the model answered without calling the tool. Strengthen your function description.
  • Model calls wrong function → Add a system_instruction that maps intents to tools explicitly.

Example 2: Parallel Tool Calls — Multi-City Weather

Gemini 2.0 can call multiple tools in a single response. One loop handles all of them.

response = client.models.generate_content(
    model=MODEL,
    contents="Compare the weather in Tokyo, London, and São Paulo.",
    config=types.GenerateContentConfig(tools=[tools]),
)

# Gemini returns multiple function_call parts in one candidate
tool_results = []

for part in response.candidates[0].content.parts:
    if part.function_call:
        fc = part.function_call
        result = call_real_weather_api(fc.args["city"], fc.args.get("unit", "celsius"))
        tool_results.append(
            types.Part(
                function_response=types.FunctionResponse(
                    name=fc.name,
                    response={"result": result},
                )
            )
        )

# All results go back in a single tool message
contents = [
    types.Content(role="user", parts=[types.Part(text="Compare the weather in Tokyo, London, and São Paulo.")]),
    response.candidates[0].content,
    types.Content(role="tool", parts=tool_results),
]

final = client.models.generate_content(
    model=MODEL,
    contents=contents,
    config=types.GenerateContentConfig(tools=[tools]),
)

print(final.text)

Expected output:

Tokyo: 18°C, partly cloudy
London: 11°C, overcast
São Paulo: 27°C, sunny

Tokyo is the coolest of the three, while São Paulo is the warmest...

All three results go into a single tool content block. Sending them as separate messages causes a 400 INVALID_ARGUMENT error.


Example 3: Multi-Tool Chaining — Search Then Summarize

Real agents chain tools: look up data with one function, pass it to another.

Define Two Tools

search_docs = types.FunctionDeclaration(
    name="search_docs",
    description="Searches the internal documentation database. "
                "Use when the user asks about a product feature or policy.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "query": types.Schema(type=types.Type.STRING, description="Search query"),
            "top_k": types.Schema(type=types.Type.INTEGER, description="Number of results, default 3"),
        },
        required=["query"],
    ),
)

create_summary = types.FunctionDeclaration(
    name="create_summary",
    description="Generates a structured summary from raw text chunks. "
                "Use after search_docs returns results.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "chunks": types.Schema(
                type=types.Type.ARRAY,
                items=types.Schema(type=types.Type.STRING),
                description="List of text chunks to summarize",
            ),
            "format": types.Schema(
                type=types.Type.STRING,
                enum=["bullets", "paragraph"],
            ),
        },
        required=["chunks", "format"],
    ),
)

tools = types.Tool(function_declarations=[search_docs, create_summary])

Run the Chain

def run_agent(user_query: str) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=user_query)])]

    # Allow up to 5 tool rounds before forcing a final answer
    for _ in range(5):
        response = client.models.generate_content(
            model=MODEL,
            contents=contents,
            config=types.GenerateContentConfig(tools=[tools]),
        )

        candidate = response.candidates[0]

        # No more tool calls — return the final text
        if candidate.finish_reason.name == "STOP":
            return candidate.content.parts[0].text

        # Append model's turn and process tool calls
        contents.append(candidate.content)
        tool_results = []

        for part in candidate.content.parts:
            if part.function_call:
                fc = part.function_call
                result = dispatch_tool(fc.name, fc.args)
                tool_results.append(
                    types.Part(
                        function_response=types.FunctionResponse(
                            name=fc.name,
                            response={"result": result},
                        )
                    )
                )

        contents.append(types.Content(role="tool", parts=tool_results))

    return "Agent reached max iterations without finishing."


def dispatch_tool(name: str, args: dict):
    if name == "search_docs":
        return fake_doc_search(args["query"], args.get("top_k", 3))
    if name == "create_summary":
        return fake_summarizer(args["chunks"], args["format"])
    raise ValueError(f"Unknown tool: {name}")

The finish_reason == "STOP" check is what ends the loop. Without it you'll keep sending empty tool results back forever.


Example 4: Schema Validation and Error Handling

The model occasionally passes arguments your function can't handle. Catch this at the tool layer, not the model layer.

from pydantic import BaseModel, ValidationError

class WeatherArgs(BaseModel):
    city: str
    unit: str = "celsius"

def safe_weather_call(raw_args: dict) -> dict:
    try:
        args = WeatherArgs(**raw_args)
    except ValidationError as e:
        # Return the error as the tool result — model will self-correct
        return {"error": str(e), "hint": "city must be a non-empty string"}

    return call_real_weather_api(args.city, args.unit)

Returning the error as a tool result lets Gemini retry with corrected arguments in the next turn. Raising an exception in your dispatcher breaks the loop and gives the user nothing.


Verification

# Smoke test — confirms the tool loop works end to end
result = run_agent("What's the weather in Berlin?")
assert isinstance(result, str)
assert len(result) > 10
print("✅ Tool loop works:", result)

You should see:

✅ Tool loop works: The weather in Berlin is currently...

Check that finish_reason is STOP on the final response, not MAX_TOKENS or SAFETY.


What You Learned

  • Gemini function calling is a request/response loop — you execute the function, send results back as role: tool
  • Parallel calls from one response go into a single tool content block, not multiple messages
  • A for _ in range(N) loop with a finish_reason == STOP exit handles any depth of chaining
  • Return errors as tool responses so the model can self-correct instead of crashing

Limitation: gemini-2.0-flash truncates long tool responses. If your function returns > 10k tokens, chunk the result before sending it back or switch to gemini-2.0-pro.

Tested on google-genai 1.7.0, Python 3.12, gemini-2.0-flash and gemini-2.0-pro