Writing
Structured Output + Pydantic in Genkit Python: What Actually Works
You called response.json expecting parsed fields. You set output_format='json' without a schema. You have a list model and nothing is parsing. You're in the right place.
You called response.json expecting parsed fields. You set output_format='json' without a schema and got a string back. You have a list model and nothing is parsing. You’re in the right place.
This is the complete guide to structured output in Genkit Python: not only the happy path, but the mistakes that will burn you and the patterns that actually work.
The Most Common Mistake (Read This First)
# This is what most developers try first
response = await ai.generate(prompt='Give me user data as JSON.')
data = response.json # Pydantic's serializer, not parsed schema output
# This is the correct pattern
response = await ai.generate(
prompt='Give me user data.',
output_schema=UserData, # Pydantic model
)
data = response.output # typed UserData instance
Two things wrong in the first version:
response.jsonis Pydantic’s method for serializing theModelResponseitself. Preferresponse.outputfor parsed schema data.- Without
output_schema=, the SDK has nothing to validate against. When you do pass a schema, Genkit defaultsoutput_formatto'json', so you usually don’t need to set the format for single objects.
The Correct Pattern: Basic Structured Output
from pydantic import BaseModel
from genkit import Genkit
from genkit_google_genai import GoogleAI
ai = Genkit(
plugins=[GoogleAI()], # reads GEMINI_API_KEY from env
model='googleai/gemini-2.0-flash',
)
class ProductReview(BaseModel):
product_name: str
rating: int # 1-5
sentiment: str # positive/negative/neutral
key_complaint: str
async def extract_review(raw_text: str) -> ProductReview:
response = await ai.generate(
prompt=f'Extract review data from this text:\n\n{raw_text}',
output_schema=ProductReview,
)
review = response.output # ProductReview instance, fully typed
return review
async def main():
review = await extract_review(
"The XPS 15 is amazing but the battery life is terrible. 3/5 stars."
)
print(review.product_name) # "XPS 15"
print(review.rating) # 3
print(review.sentiment) # "negative" or "neutral"
if __name__ == '__main__':
ai.run_main(main()) # always ai.run_main(), never asyncio.run()
Common Mistakes Table
- Wrong:
response.jsonfor parsed fields. Right:response.output. Why:.jsonserializes the response object, not your schema. - Wrong: no
output_schema=. Right: pass your Pydantic model. Why: without a schema, output is unvalidated text. - Wrong: assuming you must always pass
output_format='json'. Right: omit it for single objects with a schema; set'array'or'enum'when you need those formatters. Why: schema alone defaults format to json. - Wrong:
output_format='json'for a list. Right:output_format='array'. Why: different internal formatter. - Wrong: bare
model='gemini-2.0-flash'. Right:model='googleai/gemini-2.0-flash'. Why: plugin prefix required. - Wrong:
asyncio.run(main()). Right:ai.run_main(main()). Why: SDK manages its own event loop.
Nested Models
Nested Pydantic models work. Two levels of nesting is the sweet spot. Deeply nested structures (4+ levels) can cause the model to miss fields.
from pydantic import BaseModel
from typing import Optional
class Address(BaseModel):
street: str
city: str
country: str
class Company(BaseModel):
name: str
founded_year: int
headquarters: Address # nested model
employee_count: Optional[int] # model may not fill this
async def extract_company(text: str) -> Company:
response = await ai.generate(
prompt=f'Extract company information:\n\n{text}',
output_schema=Company,
)
company = response.output
# company.headquarters is an Address instance
print(company.headquarters.city)
return company
Genkit serializes the full Pydantic JSON schema and injects it into the prompt as a system instruction. The model sees the complete nested structure. For deeply nested schemas, add a system prompt hint: “Fill all fields including nested objects.”
Lists of Structured Objects
For lists, the output_format changes to 'array' and you pass a JSON schema dict via TypeAdapter:
from pydantic import BaseModel, TypeAdapter
class Ingredient(BaseModel):
name: str
quantity: str
unit: str
async def extract_ingredients(recipe_text: str) -> list[Ingredient]:
# Build the array schema
schema = TypeAdapter(list[Ingredient]).json_schema()
response = await ai.generate(
prompt=f'Extract all ingredients from this recipe:\n\n{recipe_text}',
output_format='array',
output_schema=schema, # dict, not the class
)
# response.output is a list of dicts here, not Ingredient instances
ingredients_raw = response.output
# Validate manually if you need typed objects:
return [Ingredient(**item) for item in ingredients_raw]
When using output_format='array' with a schema dict, response.output returns a list of dicts, not Pydantic instances. Call Ingredient(**item) to get typed objects.
Enums for Classification
output_format='enum' is the cleanest way to do classification:
from enum import Enum
from pydantic import BaseModel
class Sentiment(str, Enum):
POSITIVE = 'positive'
NEGATIVE = 'negative'
NEUTRAL = 'neutral'
async def classify_sentiment(text: str) -> str:
response = await ai.generate(
prompt=f'Classify the sentiment of this text: {text}',
output_format='enum',
output_schema={
'type': 'string',
'enum': [e.value for e in Sentiment]
},
)
# response.output is a raw string: 'positive', 'negative', or 'neutral'
result = response.output
return Sentiment(result) # convert to enum
async def main():
label = await classify_sentiment("This product exceeded my expectations!")
print(label) # Sentiment.POSITIVE
print(label.value) # 'positive'
The enum formatter strips surrounding quotes and validates the model returned one of the allowed values. If it doesn’t match, you’ll get a GenkitError.
Optional Fields and Defaults
This is where most production code breaks. Models frequently skip optional fields. Always provide defaults:
from pydantic import BaseModel, Field
from typing import Optional
class ArticleMetadata(BaseModel):
title: str
author: Optional[str] = None # model may leave this blank
word_count: Optional[int] = None
tags: list[str] = Field(default_factory=list)
is_paywalled: bool = False
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
async def extract_metadata(article: str) -> ArticleMetadata:
response = await ai.generate(
prompt=f'Extract article metadata. Use null for unknown fields.\n\n{article}',
output_schema=ArticleMetadata,
)
return response.output # Optional fields default safely
async def main():
meta = await extract_metadata("Breaking: AI startup raises $50M...")
print(meta.title) # filled
print(meta.author) # None if model skipped it; no KeyError
print(meta.tags) # [] if model skipped it; no AttributeError
Use Optional[T] = None for fields the model might skip. Use Field(default_factory=list) for list fields. Never assume the model will fill every field; it won’t.
Validation Errors and Graceful Handling
When the model returns malformed JSON, Genkit raises a GenkitError. You should always wrap structured output calls with a fallback:
from genkit import GenkitError
async def safe_extract(text: str) -> ProductReview | None:
try:
response = await ai.generate(
prompt=f'Extract review data:\n\n{text}',
output_schema=ProductReview,
)
return response.output
except GenkitError as e:
# GenkitError carries a status code (INVALID_ARGUMENT, INTERNAL, etc.)
print(f'Extraction failed: {e.status}: {e.message}')
return None
except Exception as e:
# Network errors, model unavailable, etc.
print(f'Unexpected error: {e}')
return None
async def main():
result = await safe_extract("Ambiguous text that might not parse...")
if result is None:
# fallback: retry with a more explicit prompt or use unstructured output
print("Extraction failed; using fallback")
else:
print(result.product_name)
When validation fails, Genkit’s error will include the raw model output in the message field. That’s useful for debugging. Log e.message to see what the model actually returned.
Streaming Structured Output
Short answer: streaming and structured JSON output do not mix cleanly.
generate_stream accepts output_format and output_schema, but chunks arrive as partial text. The output field on each chunk is not a valid parsed object until the stream is complete.
# The right way to get structured output from a stream
async def stream_with_structured_final():
sr = ai.generate_stream(
prompt='List 5 programming languages with year created.',
output_format='array',
output_schema=TypeAdapter(list[Language]).json_schema(),
)
# Show chunks to the user as they arrive (raw text)
async for chunk in sr.stream:
if chunk.text:
print(chunk.text, end='', flush=True)
# Get the final response; this is the parsed structured output
final = await sr.response
languages = final.output # list of dicts, parsed from the completed stream
return languages
The SDK parses the accumulated stream at the end. So you get streaming display with a structured final result, which is usually exactly what you want.
Do not try to parse chunk.output mid-stream for a JSON schema. The intermediate output is incomplete JSON.
Complete Example: Classification + Extraction Pipeline
Here’s everything in one pattern: a content moderation pipeline that classifies and then extracts structured detail.
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from genkit import Genkit, GenkitError
from genkit_google_genai import GoogleAI
ai = Genkit(
plugins=[GoogleAI()],
model='googleai/gemini-2.0-flash',
)
# Step 1: Classify
class ContentCategory(str, Enum):
SAFE = 'safe'
SPAM = 'spam'
HARMFUL = 'harmful'
# Step 2: Extract detail (only for non-safe content)
class ModerationDetail(BaseModel):
category: str
reason: str
severity: int = Field(ge=1, le=5)
flagged_phrases: list[str] = Field(default_factory=list)
recommended_action: Optional[str] = None
class ModerationResult(BaseModel):
category: ContentCategory
detail: Optional[ModerationDetail] = None
@ai.flow()
async def moderate_content(content: str) -> ModerationResult:
# Phase 1: classify
try:
classify_response = await ai.generate(
prompt=f'Classify this content. Return ONLY one word: safe, spam, or harmful.\n\n{content}',
output_format='enum',
output_schema={
'type': 'string',
'enum': ['safe', 'spam', 'harmful']
},
)
category = ContentCategory(classify_response.output)
except (GenkitError, ValueError):
category = ContentCategory.SAFE # safe default on failure
# Phase 2: extract detail only for flagged content
if category == ContentCategory.SAFE:
return ModerationResult(category=category, detail=None)
try:
detail_response = await ai.generate(
prompt=f'This content was classified as {category.value}. Extract moderation details.\n\n{content}',
output_schema=ModerationDetail,
)
detail = detail_response.output
except GenkitError as e:
# If extraction fails, return category without detail
print(f'Detail extraction failed: {e.message}')
detail = None
return ModerationResult(category=category, detail=detail)
async def main():
test_cases = [
"Great product! Highly recommend.",
"BUY NOW! CLICK HERE! FREE MONEY!!!",
"I want to harm someone at the local school.",
]
for content in test_cases:
result = await moderate_content(content)
print(f'\n--- Content: {content[:40]}...')
print(f'Category: {result.category.value}')
if result.detail:
print(f'Severity: {result.detail.severity}/5')
print(f'Reason: {result.detail.reason}')
print(f'Action: {result.detail.recommended_action}')
if __name__ == '__main__':
ai.run_main(main())
This pipeline demonstrates the full set:
output_format='enum'for classificationoutput_schemawith a typed Pydantic model (format defaults to json)- Optional fields with defaults
GenkitErrorhandling with fallbacks@ai.flow()for automatic tracingai.run_main()for correct event loop management
Common Mistakes Table (Extended)
- Mistake:
response.json. What happens: you get a serializer (or a dump of the whole response), not schema fields. Fix: useresponse.output. - Mistake:
output_format='json'withoutoutput_schema=. What happens: unvalidated string. Fix: pass the schema; format is optional for single objects. - Mistake:
output_format='json'for a list. What happens: parser fails. Fix: useoutput_format='array'. - Mistake:
TypeAdapterschema withoutput_format='json'. What happens: schema mismatch. Fix: useoutput_format='array'. - Mistake: required fields on nested models. What happens: validation error when the model skips. Fix: make fields
Optional[T] = None. - Mistake:
asyncio.run(main()). What happens: event loop conflict. Fix:ai.run_main(main()). - Mistake: parsing
chunk.outputmid-stream. What happens: incomplete JSON. Fix: only usefinal.outputafterawait sr.response. - Mistake: no
GenkitErrorhandling. What happens: unhandled exception in prod. Fix: wrap structured output calls.
Quick Reference
# Single object (output_format defaults to 'json' when schema is set)
response = await ai.generate(
prompt='...',
output_schema=MyModel, # Pydantic class
)
obj = response.output # MyModel instance
# List of objects
from pydantic import TypeAdapter
schema = TypeAdapter(list[MyModel]).json_schema()
response = await ai.generate(
prompt='...',
output_format='array',
output_schema=schema, # dict
)
items = [MyModel(**i) for i in response.output] # list of dicts → typed
# Enum classification
response = await ai.generate(
prompt='...',
output_format='enum',
output_schema={'type': 'string', 'enum': ['a', 'b', 'c']},
)
label = response.output # 'a', 'b', or 'c' (raw string)
# Streaming (display chunks, get structured final)
sr = ai.generate_stream(prompt='...', output_schema=MyModel)
async for chunk in sr.stream:
print(chunk.text, end='')
final = await sr.response
obj = final.output # MyModel, parsed after stream completes
Install
uv pip install "genkit[google-genai]==0.8.1"
Set GEMINI_API_KEY from Google AI Studio.
The patterns in this article are verified against Genkit Python 0.8.1 on PyPI (genkit + genkit-google-genai).