{"slug": "snowflake-pydantic-ai-governed-agents-on-your-data", "title": "Snowflake + Pydantic AI: governed agents on your data", "summary": "Snowflake and Pydantic AI have launched a native integration, adding SnowflakeModel and SnowflakeProvider to Pydantic AI, enabling governed AI agents to run on Snowflake data via the Snowflake Cortex Inference API. The integration routes all model families through Cortex's OpenAI-compatible endpoint, requiring only two environment variables, and supports structured output, tool calling, and extended thinking.", "body_md": "This is a guest post written by[Priya Joseph], Sr. Data Cloud Architect at Snowflake. Co-authored by[Douwe Maan], lead developer of Pydantic AI.\n\nPydantic AI now has a native Snowflake provider, with the addition of `SnowflakeModel`\n\nand `SnowflakeProvider`\n\n.\n\nUsers choose Snowflake for secure, governed, trusted enterprise experience. This integration brings [Pydantic AI](https://pydantic.dev/docs/ai/overview/) natively into Snowflake's secure perimeter. Snowflake users can now get Pydantic's built-in data validation and type safety combined with Snowflake's enterprise governance.\n\nRunning a governed AI agent against your Snowflake data is simple:\n\n``` python\nimport logfire\nfrom pydantic_ai import Agent\n\nlogfire.configure()\nlogfire.instrument_pydantic_ai()\n\nagent = Agent('snowflake:claude-sonnet-5')\nresult = agent.run_sync('Summarize Q2 churn trends')\n```\n\nThe two `logfire`\n\nlines are optional, and worth it. With [Pydantic AI instrumented](https://pydantic.dev/docs/logfire/integrations/llms/pydanticai/), every run in the rest of this post lands on one trace: the model call, the validated output, and any tool calls in between. The examples below assume they're in place.\n\nTwo environment variables (`SNOWFLAKE_ACCOUNT`\n\nand `SNOWFLAKE_TOKEN`\n\n) are all the configuration needed. Everything else, including auth, routing, and governance, is handled inside the secure Snowflake perimeter.\n\nWhat is Snowflake Cortex Inference?\n\nCortex Inference is a fully managed REST API that serves Claude, GPT, Llama, Mistral, DeepSeek, Grok (xAI), and Snowflake's own models, all from inside your Snowflake account. Data never leaves the Snowflake security perimeter. That matters if you're in a regulated space like finance or healthcare.\n\nThe interesting design choice: rather than building a separate adapter per model family, everything routes through Cortex's OpenAI-compatible Chat Completions endpoint (`/api/v2/cortex/v1/chat/completions`\n\n). That single API surface covers tool calling, structured output (`json_schema`\n\n), image input, prompt caching, and reasoning, so one integration covers the full feature surface.\n\nSee the [Cortex Inference documentation](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api) for model availability.\n\nAn extended example\n\nHere’s an extended example from the biology domain that showcases the power of Pydantic AI with Snowflake Cortex:\n\n[Structured output](#structured-output-deseq2-results)for DESeq2 gene expression, with validated Ensembl IDs and significance testing[Tool calling](#tool-calling-with-validated-parameters)with BLAST search parameter validation[Nested models](#nested-models-variant-annotation)for variant annotations[Extended thinking](#extended-thinking-claude)for protein analysis questions[Model portability](#model-portability)across frontier and OSS model providers, showing identical Pydantic schemas work with all models[A long-running PubMed research task](#a-long-running-pubmed-research-task-with-polling)with polling\n\nAuthentication that travels\n\nThe same file should run in external Python, Notebooks, Sprocs, and SPCS.\n\n``` python\nimport os\n\nfrom pydantic_ai.providers.snowflake import SnowflakeProvider\n\n# Try to detect if we're running inside Snowflake (Notebook, Sproc, SiS)\ntry:\n    from snowflake.snowpark.context import get_active_session\n    session = get_active_session()\n\n    SNOWFLAKE_ACCOUNT = session.get_current_account()\n    SNOWFLAKE_TOKEN = session.connection.rest._token\n    print(\" Detected Snowflake environment - using session token\")\n\nexcept ImportError:\n    # Running externally (laptop, CI/CD) - use environment variables\n    SNOWFLAKE_ACCOUNT = os.environ.get('SNOWFLAKE_ACCOUNT')\n    SNOWFLAKE_TOKEN = os.environ.get('SNOWFLAKE_TOKEN')\n\n    if not SNOWFLAKE_ACCOUNT or not SNOWFLAKE_TOKEN:\n        raise ValueError(\n            \"Missing required environment variables:\\n\"\n            \"SNOWFLAKE_ACCOUNT: your Snowflake account identifier\\n\"\n            \"SNOWFLAKE_TOKEN: your Personal Access Token (PAT)\\n\"\n            \"Set them with: export SNOWFLAKE_ACCOUNT='...' SNOWFLAKE_TOKEN='...'\"\n        )\n    print(\" Using environment variables for authentication\")\n\n# Initialize provider with explicit credentials\n# Works in: External Python, Notebooks, Streamlit-in-Snowflake, SPCS\nprovider = SnowflakeProvider(\n    account=SNOWFLAKE_ACCOUNT,\n    token=SNOWFLAKE_TOKEN,\n    # For private connectivity (PrivateLink), add custom base_url,needs token as well\n    # base_url='https://myorg-myaccount.privatelink.snowflakecomputing.com'\n)\n```\n\nStructured output (DESeq2 Results)\n\nThe `Gene`\n\nmodel validates the shape of the answer, not just its text. An Ensembl ID that doesn't match the pattern, or a fold change outside the plausible range, fails before it reaches your code.\n\n``` python\nfrom typing import List, Literal\n\nimport logfire\nfrom pydantic import BaseModel, Field\nfrom pydantic_ai import Agent\n\nlogfire.configure()\nlogfire.instrument_pydantic_ai()\n\nclass Gene(BaseModel):\n    \"\"\"Type-safe gene expression result.\"\"\"\n\n    id: str = Field(pattern=r'^ENSG\\d{11}$')  # validates Ensembl ID format\n    symbol: str\n    log2fc: float = Field(ge=-10, le=10)  # Must be between -10 and 10\n    padj: float = Field(gt=0, le=1)  # P-value 0-1\n\n    @property\n    def is_significant(self) -> bool:\n        return self.padj < 0.05 and abs(self.log2fc) > 1\n\n# Simple 2-line setup\nagent = Agent('snowflake:claude-sonnet-5', output_type=List[Gene])\nresult = agent.run_sync('DUSP1 ENSG00000120129 log2FC=2.9 padj=1.2e-10')\nprint(f'Gene: {result.data[0].symbol}, Significant: {result.data[0].is_significant}')\n```\n\nTool calling with validated parameters\n\nTool inputs are validated before the function runs, so a malformed `evalue`\n\nor an unknown database never reaches BLAST.\n\n```\nclass BlastParams(BaseModel):\n    \"\"\"Pydantic validates tool parameters automatically.\"\"\"\n\n    sequence: str = Field(min_length=20)\n    database: Literal['nr', 'nt', 'refseq_protein']\n    evalue: float = Field(default=0.001, gt=0, le=1)\n\ndef blast_search(params: BlastParams) -> dict:\n    \"\"\"Tool input is validated before execution.\"\"\"\n    return {'hits': 15, 'top': f'Match in {params.database}'}\n\nagent_tools = Agent(\n    'snowflake:claude-opus-4-8',\n    tools=[blast_search],\n    system_prompt='You can run BLAST searches.',\n)\n# Agent automatically validates and calls tool\nresult = agent_tools.run_sync('BLAST sequence ATCGATCGATCGATCGATCG against RefSeq proteins')\nprint(f'Tool result: {result.data}')\n```\n\nNested models (Variant Annotation)\n\nOutput types nest, so a variant annotation comes back as a typed object graph rather than a dictionary you have to pick apart.\n\n```\nclass Variant(BaseModel):\n    rsid: str = Field(pattern=r'^rs\\d+$')\n    chromosome: str\n    position: int = Field(gt=0)\n\nclass Annotation(BaseModel):\n    \"\"\"Nested Pydantic model.\"\"\"\n\n    variant: Variant  # Nested!\n    gene: str\n    consequence: Literal['missense', 'nonsense', 'synonymous']\n    pathogenic: bool\n\nagent_nested = Agent('snowflake:claude-sonnet-5', result_type=Annotation)\nresult = agent_nested.run_sync('rs429358 chr19:45411941 APOE missense pathogenic')\nprint(f'Variant: {result.data.variant.rsid} in {result.data.gene}')\n```\n\nExtended thinking (Claude)\n\nClaude's extended thinking is a model setting, and the validated output type still applies.\n\n```\nclass Analysis(BaseModel):\n    finding: str\n    confidence: float = Field(ge=0, le=1)\n\nagent_thinking = Agent(\n    'snowflake:claude-opus-4-8',\n    result_type=Analysis,\n    model_settings={'thinking': {'type': 'enabled', 'budget_tokens': 5000}},\n)\n\nresult = agent_thinking.run_sync('Why is BRCA2 important in DNA repair?')\nprint(f'Analysis: {result.data.finding[:50]}... (confidence: {result.data.confidence})')\n```\n\nModel portability\n\nThe same schema works across models. Switching between Claude, GPT, and Llama is a change of one string.\n\n``` php\ndef test_model(model_name: str) -> str:\n    \"\"\"The same Pydantic schema works across ALL models.\"\"\"\n    agent = Agent(model_name, result_type=Gene)\n    result = agent.run_sync('FKBP5 ENSG00000096433 log2FC=3.8 padj=3.4e-15')\n    return result.data.symbol\n\n# Switch models by changing ONE string\nfor model in ['snowflake:claude-sonnet-5', 'snowflake:gpt-5.4', 'snowflake:llama3.3-70b']:\n    gene = test_model(model)\n    print(f'{model}: {gene}')\n```\n\nA long-running PubMed research task with polling\n\nReal work is rarely one call. This example polls PubMed's E-utilities in batches, then hands the abstracts to Cortex for synthesis. The `Field(description=...)`\n\nstrings are load-bearing: they become part of the JSON schema the model is asked to fill in.\n\nThe PubMed fetching is ordinary `aiohttp`\n\nand not the interesting part — the [complete runnable version is in this gist](https://gist.github.com/laisbsc/e4c4134d3448f4508bf654657686d4be). What matters here is the schema and the agent call:\n\n``` python\nimport asyncio\n\nclass ResearchSummary(BaseModel):\n    \"\"\"The schema the model is asked to fill in.\"\"\"\n\n    key_findings: List[str] = Field(description='3-5 major findings from the literature')\n    research_gaps: List[str] = Field(description='Identified gaps or controversies')\n    clinical_relevance: str = Field(description='Clinical and translational implications')\n    recommended_reading: List[str] = Field(description='Top 3 PMID references')\n\nasync def research(topic: str, model: str = 'snowflake:claude-opus-4-8') -> ResearchSummary:\n    articles = await fetch_pubmed_articles(topic)  # plain aiohttp; see the gist\n    literature = '\\n\\n'.join(\n        f'[PMID {a.pmid}] {a.title}\\n{a.abstract}' for a in articles[:5]\n    )\n\n    agent = Agent(\n        model,\n        output_type=ResearchSummary,\n        system_prompt=(\n            'You are a biomedical research analyst. '\n            'Focus on clinical relevance and research gaps.'\n        ),\n    )\n    result = await agent.run(f'Topic: {topic}\\n\\nRecent literature:\\n{literature}')\n    return result.output\n\nsummary = asyncio.run(research('CRISPR gene editing cancer therapy'))\nfor pmid in summary.recommended_reading:  # guaranteed List[str]\n    print(f'https://pubmed.ncbi.nlm.nih.gov/{pmid}/')\n```\n\nAdd `logfire.instrument_aiohttp_client()`\n\nnext to the earlier `instrument_pydantic_ai()`\n\ncall and the PubMed fetches show up on the same trace as the model call, so a slow run tells you which half was slow.\n\n`result.output`\n\nis a validated `ResearchSummary`\n\n, so `summary.recommended_reading`\n\nis a `List[str]`\n\n. No `isinstance`\n\nchecks, no defensive `.get()`\n\ncalls, and a clear `ValidationError`\n\nif the model returns something else.\n\nThe implementation\n\nTwo classes do the work: `SnowflakeProvider`\n\nhandles auth and routing, `SnowflakeModel`\n\nhandles the Cortex-specific quirks.\n\nSnowflakeProvider, auth and routing\n\n``` python\nimport os\n\nfrom pydantic_ai.providers.snowflake import SnowflakeProvider\n\nSNOWFLAKE_ACCOUNT = os.environ.get('SNOWFLAKE_ACCOUNT')\nSNOWFLAKE_TOKEN = os.environ.get('SNOWFLAKE_TOKEN')\n\nprovider = SnowflakeProvider(\n    account=SNOWFLAKE_ACCOUNT,\n    token=SNOWFLAKE_TOKEN,  # PAT, OAuth token, or key-pair JWT\n    # For private connectivity (PrivateLink):\n    # base_url='https://myorg-myaccount.privatelink.snowflakecomputing.com',\n)\n```\n\nAuth uses a plain `Authorization: Bearer <token>`\n\nheader. Snowflake auto-detects the token type (PAT vs. OAuth vs. JWT), so the provider doesn't need to inspect or route on it. The integration explicitly drops the `X-Snowflake-Authorization-Token-Type`\n\nheader that an earlier iteration included.\n\nSnowflakeModel, a thin subclass\n\n`SnowflakeModel`\n\nextends `OpenAIChatModel`\n\nrather than implementing a new base. The additions are Cortex-specific, based on live testing against a real Snowflake account:\n\n-\n**Reasoning and thinking support for Claude models.** Cortex returns reasoning in the`reasoning_details`\n\narray format (with signatures), not as a plain`reasoning`\n\nstring. The integration reuses the existing codec and replays thinking blocks with signatures on subsequent turns, which Claude's extended thinking requires to work across multi-turn conversations with caching applied automatically.\n\n```\nagent = Agent(\n    'snowflake:claude-opus-4-8',\n    model_settings={'thinking': {'type': 'enabled', 'budget_tokens': 5000}},\n)\n```\n\n-\n**Automatic**`temperature=1`\n\nfor reasoning.`SnowflakeModel`\n\nauto adjusts temperature for reasoning to ensure that extended thinking is successful. -\nCortex returns`finish_reason`\n\ncoercion.`finish_reason: \"\"`\n\n(an empty string) for Claude and Llama completions, where OpenAI-family models return proper values. The model normalizes this so the downstream Pydantic AI logic doesn't break. -\n**Per-family tool gating.** Cortex returns a hard 400 if you send`tools`\n\nor`response_format`\n\nto Llama, Mistral, or DeepSeek models. The integration adds per-family profiles that disable tool calling and fall back to prompted structured output for those families, rather than propagating the error to the user.\n\nWhat's covered by tests\n\nThe integration includes [VCR cassettes](https://pypi.org/project/vcrpy/1.5.2/) recorded against a live Snowflake account, not mocks.\n\nThis level of live-recorded coverage is uncommon in provider integrations, and gives the Pydantic maintainers something concrete to review against.\n\nWhat you get\n\nBy combining Pydantic AI agents with Snowflake Cortex Inference, you get:\n\n**Launch-day model access.** Snowflake ships new models from Anthropic and OpenAI on launch day as a launch partner. The integration inherits this automatically.**The full Cortex model catalog**, including`frontier`\n\nand optimized models like`snowflake-llama-3.3-70b`\n\n([up to 75% lower inference cost via SwiftKV](https://www.snowflake.com/en/blog/engineering/swiftkv-llm-compute-reduction/)).**Model portability.** Switch from`snowflake:llama3.3-70b`\n\nto`snowflake:claude-sonnet-5`\n\nby changing one string. Tool definitions, output schemas, and agent logic stay identical.**Secure Snowflake Perimeter.** Inference happens inside the Snowflake account, subject to existing RBAC and governance policies.\n\nWhere you can run it\n\nThe provider is a REST client, so it runs anywhere Python does. What changes between contexts is only where the credentials come from.\n\n| Context | Auth | Notes |\n|---|---|---|\n| External Python (laptop, CI/CD) | `SNOWFLAKE_ACCOUNT` and `SNOWFLAKE_TOKEN` env vars |\nNeeds a personal access token |\n| Snowflake Notebooks | Session token, auto-detected via `get_active_session()` |\nNo environment variables needed |\n| Streamlit-in-Snowflake | Session token | Reachable as `st.connection('snowflake').session` |\n| Snowpark Container Services | Session token, or a PAT in env vars | May need an external access integration for REST endpoints |\n| Python stored procedures | Session token | Requires an external access integration |\n\nStored procedures are the one context that needs setup, because egress is blocked by default:\n\n```\nCREATE OR REPLACE NETWORK RULE cortex_network_rule\n  MODE = EGRESS\n  TYPE = HOST_PORT\n  VALUE_LIST = ('*.snowflakecomputing.com:443');\n\nCREATE OR REPLACE EXTERNAL ACCESS INTEGRATION cortex_access\n  ALLOWED_NETWORK_RULES = (cortex_network_rule)\n  ENABLED = true;\n```\n\nGrant `USAGE`\n\non the integration to the procedure's role. The PubMed example above reaches a second host, so it also needs `'eutils.ncbi.nlm.nih.gov:443'`\n\nin `VALUE_LIST`\n\n.\n\nTry it\n\n```\npip install \"pydantic-ai-slim[snowflake]\"\nexport SNOWFLAKE_ACCOUNT='myorg-myaccount'\nexport SNOWFLAKE_TOKEN='<your-PAT>'\n```\n\nThe role the request runs as needs the `SNOWFLAKE.CORTEX_USER`\n\ndatabase role, which is granted to `PUBLIC`\n\nby default.\n\nIf you're building Pydantic AI agents and want native Snowflake Cortex support, [review the integration here](https://github.com/pydantic/pydantic-ai/pull/6150).", "url": "https://wpnews.pro/news/snowflake-pydantic-ai-governed-agents-on-your-data", "canonical_source": "https://pydantic.dev/articles/snowflake-cortex-pydantic-ai", "published_at": "2026-08-10 09:00:00+00:00", "updated_at": "2026-08-10 19:43:00.659134+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-infrastructure", "developer-tools", "generative-ai"], "entities": ["Snowflake", "Pydantic AI", "Snowflake Cortex Inference", "Priya Joseph", "Douwe Maan", "Claude", "GPT", "Llama"], "alternates": {"html": "https://wpnews.pro/news/snowflake-pydantic-ai-governed-agents-on-your-data", "markdown": "https://wpnews.pro/news/snowflake-pydantic-ai-governed-agents-on-your-data.md", "text": "https://wpnews.pro/news/snowflake-pydantic-ai-governed-agents-on-your-data.txt", "jsonld": "https://wpnews.pro/news/snowflake-pydantic-ai-governed-agents-on-your-data.jsonld"}}