Skip to main content

Versioning Schemas: Evolving APIs

Function schemas change as your tools evolve: you add new parameters, rename fields, or change types. If you break the schema without warning, existing LLM calls fail. A production system must handle schema versioning carefully. This article teaches you versioning strategies: how to add fields without breaking old callers, when and how to deprecate parameters, and how to retire old schema versions safely. Best practices from API design apply here, with specific adjustments for LLM tool calling.

Versioning Strategies: Three Approaches

Add new fields, never remove or change existing ones. Existing LLM calls continue to work. This is the safest approach:

Original schema (v1):

{
"name": "search",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10}
},
"required": ["query"]
}
}

After adding a new optional field:

{
"name": "search",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
"sort_by": {
"type": "string",
"enum": ["relevance", "date"],
"default": "relevance",
"description": "Optionally sort results (new in v1.1)"
}
},
"required": ["query"]
}
}

Old LLM calls that omit sort_by still work (it defaults to "relevance"). New calls can use it. No breaking change.

When to use: Small APIs with simple evolution. No explicit versioning needed.

Strategy 2: Explicit Versioning with Version Parameter

Add a version field to the schema. The model specifies which version it's targeting. This lets you support multiple versions simultaneously:

{
"name": "search",
"parameters": {
"type": "object",
"properties": {
"version": {
"type": "string",
"enum": ["v1", "v2"],
"default": "v1",
"description": "API version (v1 legacy, v2 current)"
},
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
"filters": {
"type": "object",
"description": "Advanced filters (v2 only)"
}
},
"required": ["query"]
}
}

Your code routes based on version:

def search(data: dict) -> dict:
version = data.get("version", "v1")

if version == "v1":
return search_v1(data["query"], data.get("limit", 10))
elif version == "v2":
return search_v2(data["query"], data.get("limit", 10), data.get("filters"))

When to use: Mature APIs with significant breaking changes. Requires explicit version handling.

Strategy 3: URL/Function Name Versioning

Create separate functions for each major version:

[
{
"name": "search_v1",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"}
}
}
},
{
"name": "search_v2",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"filters": {"type": "object"}
}
}
}
]

The model calls the version it needs. Old prompts use search_v1; new ones use search_v2. You maintain both implementations until v1 sunset.

When to use: Major API overhauls where you need long deprecation periods. Requires supporting multiple implementations.

Deprecation and Sunset

When you need to retire a field or version, follow these steps:

  1. Mark as deprecated (Phase 1): Add a note in the description that the field is deprecated and when it will be removed.
{
"old_parameter": {
"type": "string",
"description": "DEPRECATED as of 2026-06-01. Use new_parameter instead. Will be removed 2026-09-01."
}
}
  1. Support during grace period (Phase 2): Keep the field functional for 90+ days. Your code accepts both old and new fields:
def process(data: dict) -> dict:
# Handle both old and new field names
value = data.get("new_parameter") or data.get("old_parameter")
if data.get("old_parameter"):
log_deprecation_warning("old_parameter is deprecated; use new_parameter")
return {"result": do_something(value)}
  1. Remove after grace period (Phase 3): Delete the deprecated field from the schema. Old calls that still use it will fail validation. Log these failures and contact users.

  2. Announce in changelog and docs: Always notify users in a changelog, email, or documentation update.

Handling Type Changes

Changing a parameter's type is a breaking change. If you must do it, use these patterns:

Pattern 1: Accept Both Types During Transition

{
"user_id": {
"type": ["string", "integer"],
"description": "User ID. Can be a numeric ID (legacy, deprecated) or username (current). Example: 'alice' or 12345. Numeric IDs will stop working 2026-09-01."
}
}

Code:

def get_user(data: dict) -> dict:
user_id = data["user_id"]
if isinstance(user_id, int):
# Legacy path
log_deprecation_warning("numeric user_id is deprecated")
user = lookup_by_numeric_id(user_id)
else:
# Current path
user = lookup_by_username(user_id)
return user

Pattern 2: Create a New Parameter, Deprecate the Old

Instead of changing the type of user_id, add username and deprecate user_id:

{
"properties": {
"user_id": {
"type": "integer",
"description": "DEPRECATED. Use username instead. Will be removed 2026-09-01."
},
"username": {
"type": "string",
"description": "User's login name. Preferred over user_id."
}
},
"required": [] // Both optional during transition
}

Semantic Versioning

Use semantic versioning (MAJOR.MINOR.PATCH) for your schemas:

  • PATCH (1.0.1 → 1.0.2): Bug fixes, clarifications in descriptions, cosmetic changes.
  • MINOR (1.0 → 1.1): New optional fields, new optional enum values, new functions.
  • MAJOR (1.0 → 2.0): Breaking changes (removed fields, type changes, renamed required fields).

In your schema metadata, include the version:

{
"name": "search",
"version": "1.2.0",
"description": "...",
"parameters": { ... }
}

Or in a separate manifest file:

{
"schema_version": "1.2.0",
"functions": [
{
"name": "search",
"version": "1.2.0",
"parameters": { ... }
}
]
}

Practical Example: Gradual Migration

Here's a real scenario: Your get_user function originally accepts user_id: integer. You want to switch to username: string to be more flexible.

Phase 1 (Release 1.1, June 2026): Add username, mark user_id as deprecated.

{
"name": "get_user",
"version": "1.1.0",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "integer",
"description": "DEPRECATED as of v1.1.0. Use username instead. Will be removed in v2.0.0 (Sept 2026)."
},
"username": {
"type": "string",
"description": "User's login name (new in v1.1.0). Preferred."
}
},
"required": []
}
}

Phase 2 (June–Sept 2026): Both fields work. Monitor usage of user_id. Send deprecation notices to users.

def get_user(data: dict) -> dict:
if "user_id" in data:
log_deprecation_warning("get_user's user_id parameter will be removed Sept 1, 2026. Use username instead.")
return lookup_user(numeric_id=data["user_id"])
elif "username" in data:
return lookup_user(username=data["username"])
else:
raise ValueError("Provide either user_id or username")

Phase 3 (v2.0, Sept 2026): Remove user_id from the schema. Require username.

{
"name": "get_user",
"version": "2.0.0",
"parameters": {
"type": "object",
"properties": {
"username": {"type": "string", "description": "User's login name"}
},
"required": ["username"]
}
}

Tools and Automation

Several tools help manage schema versioning:

  • OpenAPI / Swagger: Document your API with semantic versioning. Tools like Swagger UI can visualize changes.
  • JSON Schema Diff Tools: Compare old and new schemas to detect breaking changes automatically.
  • CI/CD Integration: Add schema validation to your CI pipeline: reject PRs that introduce breaking changes without incrementing MAJOR version.

Example GitHub Actions check:

- name: Check schema compatibility
run: |
schema_diff $(git show HEAD~1:schema.json) schema.json
if breaking_changes_detected; then
echo "Error: Breaking changes detected. Increment MAJOR version."
exit 1
fi

Key Takeaways

  • Use additive-only versioning for simplicity: add fields, never remove.
  • For major changes, use explicit version parameters or separate versioned functions.
  • Always deprecate before removing: mark fields as deprecated 90+ days before removal.
  • Support both old and new parameters during the grace period.
  • Use semantic versioning (MAJOR.MINOR.PATCH) and document changes in a changelog.

Frequently Asked Questions

What if the LLM uses a deprecated parameter?

Your code should handle it gracefully: log a deprecation warning, process the request using the old logic, and return a success response. Don't break the call; just warn the user.

How long should the deprecation period be?

Minimum 60–90 days. For critical APIs used by many LLMs/users, 6+ months. Check your usage logs to see when the last call using the deprecated field occurred before retiring it.

Can I version individual parameters without versioning the entire function?

Yes. Each parameter can have a different "introduced in" or "deprecated in" version noted in its description. But tracking becomes complex. Keep it simple: version the function as a whole.

Should I include schema version in every API call?

No, unless you're explicitly versioning (Strategy 2). For Strategy 1 (additive-only), clients don't need to specify a version. For Strategy 3 (separate functions), the function name is the version.

What happens if the LLM calls an old function that no longer exists?

It gets an error (function not found). Ensure you provide a helpful error message telling the model to use the new function version. Some frameworks let you suggest alternatives.

Further Reading