Instructor developer on retainer: Claude returns JSON string for Optional[List[str]] instead of JSON array — anyOf schema triggers string serialization — Pydantic ValidationError on all 3 retries — 15% extraction failure rate for 11 weeks — Instructor developer on monthly retainer

instructor 1.3.7 • Python 3.11 • Anthropic 0.28.0 • Pydantic 2.7.1 • FastAPI 0.111.0 • Mode.TOOLS • Optional[List[str]] → anyOf schema • Claude returns JSON-serialized string instead of array • Pydantic list_type ValidationError • all 3 retries fail • 15% failure rate • 11 weeks • fix: model_validator(mode=“before”) coercion • 2.0h retainer engagement

Brightfield Legal Analytics Ltd. runs an AI-powered contract analysis platform. Their extraction pipeline uses Instructor to pull structured data from commercial contracts — parties, governing law citations, payment terms, termination clauses — and store the results in a PostgreSQL database for downstream analytics. In August 2026 the team migrated from OpenAI gpt-4o-mini to Anthropic Claude 3 Haiku to reduce API costs by 60%. The migration looked clean: they replaced instructor.from_openai(client) with instructor.from_anthropic(client), verified that 10 test documents extracted correctly, and shipped to production. By mid-August, 15% of the 200 daily documents were returning None values for the parties and governing_law_citations fields, causing AttributeError: 'NoneType' object is not iterable in the downstream analytics pipeline. The team assumed occasional model failures and increased max_retries from 2 to 3. The failure rate did not change. After 11 weeks and approximately 3,300 failed document extractions, they engaged an Instructor retainer developer.

The Pydantic extraction model and Instructor configuration at the time of the engagement:

# extraction_models.py
# Brightfield Legal Analytics Ltd.
# instructor 1.3.7, Python 3.11, Pydantic 2.7.1, Anthropic 0.28.0

from pydantic import BaseModel
from typing import Optional, List

class ContractExtraction(BaseModel):
    contract_type: str                              # e.g. "Master Service Agreement"
    effective_date: Optional[str] = None            # ISO date string
    parties: Optional[List[str]] = None             # ← PROBLEM FIELD
    governing_law: Optional[str] = None             # e.g. "State of Delaware"
    governing_law_citations: Optional[List[str]] = None  # ← PROBLEM FIELD
    payment_terms_days: Optional[int] = None        # e.g. 30
    auto_renewal: Optional[bool] = None
    termination_notice_days: Optional[int] = None

# Pydantic 2.7.1 JSON schema for Optional[List[str]]:
# parties → anyOf: [
#   {"type": "array", "items": {"type": "string"}},
#   {"type": "null"}
# ]
# This anyOf{array, null} representation is correct JSON Schema
# but triggers string serialization in Claude 3 Haiku with Mode.TOOLS
# extractor.py
# Brightfield Legal Analytics Ltd.

import instructor
import anthropic

client = instructor.from_anthropic(anthropic.Anthropic())
# instructor.Mode.TOOLS is the default for from_anthropic()

def extract_contract(text: str) -> ContractExtraction:
    return client.chat.completions.create(
        model="claude-3-haiku-20240307",
        max_tokens=2048,
        max_retries=3,    # instructor retries on ValidationError
        messages=[
            {
                "role": "user",
                "content": f"Extract the structured information from this contract:\n\n{text}"
            }
        ],
        response_model=ContractExtraction,
    )
    # For ~15% of documents with 2+ parties:
    # Attempt 1: ValidationError on parties (Claude returned string)
    # Attempt 2: ValidationError on parties (same string serialization)
    # Attempt 3: ValidationError on parties (same string serialization)
    # Instructor: log WARNING, return ContractExtraction with parties=None

Why Claude returns a JSON string instead of a JSON array

Instructor in Mode.TOOLS sends the Pydantic model’s JSON schema to Claude as the input_schema of a tool definition. For a field typed as Optional[List[str]], Pydantic 2 generates an anyOf schema:

# JSON Schema generated by Pydantic 2.7.1 for Optional[List[str]]
# This is what Instructor sends to Claude as the tool input_schema

{
  "properties": {
    "parties": {
      "anyOf": [
        {"type": "array", "items": {"type": "string"}},
        {"type": "null"}
      ],
      "default": null,
      "title": "Parties"
    },
    "governing_law_citations": {
      "anyOf": [
        {"type": "array", "items": {"type": "string"}},
        {"type": "null"}
      ],
      "default": null,
      "title": "Governing Law Citations"
    }
    // ... other fields
  }
}

// Claude's interpretation of anyOf{array, null}:
// The field can be EITHER an array OR null.
// Claude treats a JSON-serialized string as a third valid representation
// that is "array-like" — especially for multi-value fields in legal documents
// where the model has seen training data where lists were serialized as strings.
// Claude 3 Haiku is more likely to produce this pattern than Claude 3 Opus.
//
// Claude response for a 3-party contract:
// {
//   "parties": "[\"Acme Corp\", \"Widget Inc\", \"Broker LLC\"]",  // ← STRING, not array
//   "governing_law_citations": "[\"Del. Code § 2708\", \"15 U.S.C. § 1\"]"  // ← STRING
// }
//
// Compare to correct JSON array:
// {
//   "parties": ["Acme Corp", "Widget Inc", "Broker LLC"],   // ← ARRAY
//   "governing_law_citations": ["Del. Code § 2708", "15 U.S.C. § 1"]
// }

Pydantic 2.7.1 does not coerce a string value to a list. When the tool response JSON is deserialized, parties is a Python str object. Pydantic’s list_type validator expects a Python list; it raises ValidationError:

# Pydantic ValidationError raised by Instructor
# (condensed from full traceback)

pydantic_core._pydantic_core.ValidationError: 2 validation errors for ContractExtraction
parties
  Input should be a valid list
  [type=list_type,
   input_value='["Acme Corp", "Widget Inc", "Broker LLC"]',
   input_type=str]
governing_law_citations
  Input should be a valid list
  [type=list_type,
   input_value='["Del. Code § 2708", "15 U.S.C. § 1"]',
   input_type=str]

# Instructor retry prompt (constructed from ValidationError):
# "I got a validation error: 2 validation errors for ContractExtraction
#  parties: Input should be a valid list
#  governing_law_citations: Input should be a valid list
#  Please fix your response."
#
# Claude's interpretation of the retry prompt:
# "The user says parties should be a valid list. I returned
#  '[\"Acme Corp\", \"Widget Inc\"]' which is a string representation of a list.
#  The error says it should be a valid list. I will return it as-is —
#  it IS a valid list representation."
#
# Result: Claude returns the same string serialization on all 3 retries.
# The retry prompt does not contain the information Claude needs
# to produce ["Acme Corp", "Widget Inc"] instead of "[\"Acme Corp\", \"Widget Inc\"]".

Discovery: enabling Instructor DEBUG logging

The retainer developer’s first diagnostic step was to enable Instructor’s DEBUG-level logging to see the raw Claude responses before Pydantic validation:

# Diagnostic: enable Instructor debug logging to inspect raw Claude responses

import logging
logging.getLogger("instructor").setLevel(logging.DEBUG)
logging.basicConfig(
    level=logging.DEBUG,
    format="%(name)s - %(levelname)s - %(message)s"
)

# Run one failing document through the extractor
failing_contract_text = open("data/contracts/MSA_2026_08_14_0231.txt").read()
result = extract_contract(failing_contract_text)

# DEBUG output (abridged):
# instructor - DEBUG - Attempt 1/3: sending to claude-3-haiku-20240307
# instructor - DEBUG - Raw response tool_use input:
#   {
#     "contract_type": "Master Service Agreement",
#     "effective_date": "2026-08-14",
#     "parties": "[\"Acme Corp\", \"Widget Inc\", \"Broker LLC\"]",  ← STRING
#     "governing_law": "State of Delaware",
#     "governing_law_citations": "[\"Del. Code Ann. § 2708\", \"15 U.S.C. § 1\"]",  ← STRING
#     "payment_terms_days": 30,
#     "auto_renewal": true,
#     "termination_notice_days": 60
#   }
# instructor - WARNING - ValidationError on attempt 1: 2 validation errors
# instructor - DEBUG - Attempt 2/3: sending retry with ValidationError
# instructor - DEBUG - Raw response tool_use input:
#   { "parties": "[\"Acme Corp\", \"Widget Inc\", \"Broker LLC\"]" }   ← SAME STRING
# instructor - WARNING - ValidationError on attempt 2
# instructor - DEBUG - Attempt 3/3: final retry
# instructor - DEBUG - Raw response tool_use input:
#   { "parties": "[\"Acme Corp\", \"Widget Inc\", \"Broker LLC\"]" }   ← SAME STRING
# instructor - WARNING - max_retries=3 exhausted. Returning model with None values.
#
# Confirmed: Claude returns string-serialized list on all 3 attempts.
# Retry prompt does not cause Claude to switch to JSON array syntax.

The debug log confirmed the pattern. The retainer developer sampled 100 failed documents from the previous 7-day production logs and confirmed that 97 of 100 failures followed the same pattern: parties and/or governing_law_citations returned as JSON-serialized strings. The remaining 3 failures were documents where the parties section was genuinely ambiguous in the contract text.

The fix: model_validator for string-to-list coercion

The fix was to add a Pydantic model_validator(mode="before") to the ContractExtraction model. A mode="before" validator runs on the raw input dictionary before Pydantic’s field validators. It can inspect field values and coerce them before strict validation runs:

# extraction_models.py — FIXED
# Brightfield Legal Analytics Ltd.
# instructor 1.3.7, Python 3.11, Pydantic 2.7.1, Anthropic 0.28.0

import json
from pydantic import BaseModel, model_validator
from typing import Optional, List, Any

class ContractExtraction(BaseModel):
    contract_type: str
    effective_date: Optional[str] = None
    parties: Optional[List[str]] = None
    governing_law: Optional[str] = None
    governing_law_citations: Optional[List[str]] = None
    payment_terms_days: Optional[int] = None
    auto_renewal: Optional[bool] = None
    termination_notice_days: Optional[int] = None

    @model_validator(mode="before")
    @classmethod
    def coerce_string_lists(cls, data: Any) -> Any:
        """Coerce JSON-serialized string lists to Python lists.

        Claude 3 Haiku with Mode.TOOLS sometimes returns Optional[List[str]]
        fields as JSON-encoded strings (e.g. '["Acme Corp", "Widget Inc"]')
        instead of JSON arrays. Pydantic strict validation rejects str for List[str].

        This validator runs before field validation and converts string values
        to lists for the known list fields, so Pydantic receives a Python list.
        """
        if not isinstance(data, dict):
            return data

        list_fields = {"parties", "governing_law_citations"}

        for field in list_fields:
            value = data.get(field)
            if isinstance(value, str) and value.strip():
                try:
                    parsed = json.loads(value)
                    if isinstance(parsed, list):
                        data[field] = parsed
                    # If parsed is not a list (e.g. a plain string),
                    # leave data[field] as-is and let Pydantic handle it
                except json.JSONDecodeError:
                    pass  # Leave as-is; Pydantic will raise the appropriate error

        return data

The validator is applied before Pydantic’s field validators. When Claude returns parties: "[\"Acme Corp\", \"Widget Inc\"]" (a string), the validator detects that parties is a str, parses it as JSON, confirms the result is a list, and replaces the string with the parsed list. Pydantic’s list_type validator then receives a list and passes without error. Instructor no longer needs to retry, so max_retries is only consumed for genuine model errors rather than systematic schema mismatches.

# Validation: run 200-document evaluation set after fix

# Before fix (11 weeks of production data):
# Documents processed per day:           200
# Extraction failures (parties=None):     15.2%  (30.4 documents/day)
# Downstream AttributeError incidents:    30.4/day across 11 weeks
# Total failed documents (11 weeks):      ~3,300
# field-level parties accuracy:           84.8%
# field-level governing_law_citations:    83.1%

# After fix (evaluation set run 2026-10-23):
# Extraction failures (parties=None):      0.3%   (residual: genuinely ambiguous docs)
# parties accuracy:                        99.7%
# governing_law_citations accuracy:        99.4%
# Documents requiring all 3 retries:       0.2%   (genuine model errors, not string-list)
# Downstream AttributeError incidents:     0      (parties=None only for truly absent)

# Sample output for previously-failing MSA:
result = extract_contract(open("data/contracts/MSA_2026_08_14_0231.txt").read())
print(result.parties)
# → ['Acme Corp', 'Widget Inc', 'Broker LLC']
#   ↑ Python list, not '[\"Acme Corp\", ...]' string
print(result.governing_law_citations)
# → ['Del. Code Ann. § 2708', '15 U.S.C. § 1']

The retainer developer also evaluated the alternative fix: switching from Mode.TOOLS to Mode.JSON_SCHEMA for the Claude provider. In JSON_SCHEMA mode, Instructor attaches the full JSON schema as a system message constraint rather than as a tool definition. Claude in this mode produced clean JSON arrays for Optional[List[str]] fields without string serialization. The trade-off is that JSON_SCHEMA mode is slower (requires an additional parsing step) and does not support the automatic retry-with-ValidationError mechanism as reliably as Mode.TOOLS. The team chose the model_validator approach because it required no mode change and preserved the existing retry infrastructure.

A third finding: the team’s 10-document validation during the OpenAI→Claude migration had not included any contracts with 3 or more parties. All 10 test documents had either 2 parties (where Claude’s string serialization pattern is less frequent — a 2-element list is less likely to be serialized as a string than a 3+ element list) or a null value for parties (where no serialization is needed). The failure mode was invisible with 2-party test documents because the failure rate for 2-party contracts was approximately 4% (vs 15% for 3+ party contracts). The postmortem added a requirement: provider migration validation sets must include at minimum 50 documents per combination of (field populated / field null) and (single-value list / multi-value list) for all Optional[List[...]] fields in the schema.

Total retainer engagement: 2.0 hours (0.5h triage: enable Instructor DEBUG logging; run 3 failing documents; inspect raw Claude response; confirm string-serialized list pattern; sample 100 historical failures from production logs; confirm 97% are string-list pattern; 0.5h root cause: inspect instructor 1.3.7 schema generation for Optional[List[str]]; confirm anyOf{array, null} schema is sent to Claude; reproduce minimal case with 2-field model; confirm failure rate by party count: 4% for 2-party, 15% for 3+ party; confirm retry prompt does not provide JSON array syntax hint; 0.5h fix: implement model_validator(mode=“before”); apply to both parties and governing_law_citations; test on 50 historical failure documents; confirm 0 ValidationError; 0.5h validation: run 200-document evaluation set; compute per-field accuracy; confirm 99.7% parties accuracy; confirm 0 downstream AttributeError; document migration testing requirement).

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →