Skip to content

First-class Pydantic v2+ support - #3965

Draft
patrick91 wants to merge 4 commits into
mainfrom
feature/pydantic-first-class
Draft

First-class Pydantic v2+ support#3965
patrick91 wants to merge 4 commits into
mainfrom
feature/pydantic-first-class

Conversation

@patrick91

@patrick91 patrick91 commented Aug 2, 2025

Copy link
Copy Markdown
Member

Stack 🍰

Summary

Add first-class support for Pydantic v2+ models in Strawberry GraphQL.

This PR introduces a new strawberry.pydantic module that allows you to directly decorate Pydantic BaseModel classes to create GraphQL types, inputs, and interfaces without requiring separate wrapper classes.

Basic Usage

import strawberry
from pydantic import BaseModel

@strawberry.pydantic.type
class User(BaseModel):
    name: str
    age: int

@strawberry.pydantic.input
class CreateUserInput(BaseModel):
    name: str
    age: int

@strawberry.pydantic.interface
class Node(BaseModel):
    id: str

Features Implemented

Core Features

  • @strawberry.pydantic.type - Convert Pydantic models to GraphQL types
  • @strawberry.pydantic.input - Convert Pydantic models to GraphQL input types
  • @strawberry.pydantic.interface - Convert Pydantic models to GraphQL interfaces
  • ✅ Automatic field extraction from Pydantic models
  • ✅ Pydantic field descriptions preserved in GraphQL schema
  • ✅ Pydantic field aliases used as GraphQL field names
  • ✅ Support for strawberry.Private to exclude fields from schema
  • ✅ Support for strawberry.field() with Annotated for directives, permissions, deprecation
  • ✅ Generic Pydantic model support
  • ✅ Nested Pydantic types
  • strawberry.pydantic.Error type for validation error handling
  • ✅ Computed fields via include_computed=True

Pydantic v2 Features (130 Tests Passing)

  • Functional Validators - BeforeValidator, AfterValidator, WrapValidator with Annotated types
  • @model_validator Support - Cross-field validation with mode='before'|'after'|'wrap'
  • Validation Context - Strawberry Info automatically passed to Pydantic validators
  • model_config Settings - strict=True, extra='forbid', from_attributes=True all respected
  • Strict Mode Per-Field - Field(strict=True) for individual field validation
  • Separate Aliases - validation_alias and serialization_alias supported via by_name=True
  • Discriminated Unions - Literal type support for type discriminators
  • TypeAdapter - Use TypeAdapter in resolvers for scalar/list validation
  • RootModel - Use RootModel for validated list/dict wrappers in resolvers

Migration from Experimental

# Before (experimental)
@strawberry.experimental.pydantic.type(model=UserModel, all_fields=True)
class User:
    pass

# After (first-class)
@strawberry.pydantic.type
class User(BaseModel):
    name: str
    age: int

Validation Context Example

Pydantic validators can access GraphQL context for permission-based validation:

from pydantic import field_validator, ValidationInfo

@strawberry.pydantic.input
class CreatePostInput(BaseModel):
    title: str

    @field_validator('title')
    @classmethod
    def check_permissions(cls, v: str, info: ValidationInfo) -> str:
        strawberry_info = info.context.get('info') if info.context else None
        if strawberry_info:
            user = strawberry_info.context.get('user')
            if user and not user.can_create_posts:
                raise ValueError('User cannot create posts')
        return v

Discriminated Union Example

from typing import Literal

@strawberry.pydantic.type
class Cat(BaseModel):
    pet_type: Literal["cat"]
    meow_volume: int

@strawberry.pydantic.type
class Dog(BaseModel):
    pet_type: Literal["dog"]
    bark_volume: int

@strawberry.type
class Query:
    @strawberry.field
    def pet(self) -> Cat | Dog:
        return Cat(pet_type="cat", meow_volume=10)

Test Results

  • 130 Pydantic-specific tests - All passing ✅
  • Full test suite (4675+ tests) - All passing ✅

🤖 Generated with Claude Code

@sourcery-ai

sourcery-ai Bot commented Aug 2, 2025

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR introduces first-class Pydantic support by adding a dedicated strawberry/pydantic module with type/input/interface decorators, overhauls the Pydantic integration documentation and migration guide, cleans up experimental code and unused flags, and restructures and extends the test suite to cover all new features.

File-Level Changes

Change Details Files
Add first-class Pydantic integration decorators
  • Create strawberry/pydantic/object_type.py with processing logic and decorators
  • Implement _get_pydantic_fields and conversion utilities in strawberry/pydantic/fields.py
  • Export new pydantic module in strawberry/pydantic/init.py
  • Update strawberry/init.py to include pydantic in the public API
strawberry/pydantic/object_type.py
strawberry/pydantic/fields.py
strawberry/pydantic/__init__.py
strawberry/__init__.py
Overhaul Pydantic documentation and remove experimental section
  • Expand docs/integrations/pydantic.md with installation, usage examples, decorator reference, advanced scenarios, and migration guide
  • Remove outdated experimental examples and flags
docs/integrations/pydantic.md
Restructure and extend test suite for Pydantic integration
  • Move experimental tests into a new tests/pydantic directory and split them into focused modules
  • Add comprehensive tests covering basic types, execution (queries/mutations/async), special features, nested types, and validation error handling
tests/pydantic/test_basic.py
tests/pydantic/test_execution.py
tests/pydantic/test_special_features.py
tests/pydantic/test_queries_mutations.py
tests/pydantic/test_nested_types.py
Cleanup unused experimental code and flags
  • Remove leftover experimental flags and references from strawberry.experimental.pydantic
  • Delete deprecated _strawberry_input_type usage
  • Tidy up compat module imports
strawberry/experimental/pydantic/_compat.py
Update CLAUDE.md and PLAN.md with new integration status
  • Detail completed implementation steps and migration path in PLAN.md
  • Provide guidance and commands for the repository in CLAUDE.md
PLAN.md
CLAUDE.md

Possibly linked issues

  • Add pytest action #1: The PR implements first-class Pydantic integration, including new decorators and updated documentation, directly addressing Pydantic V2 support and migration.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 2, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.78818% with 1375 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.27%. Comparing base (c03892c) to head (7067816).
⚠️ Report is 262 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3965      +/-   ##
==========================================
- Coverage   94.41%   91.27%   -3.14%     
==========================================
  Files         536      563      +27     
  Lines       35036    38186    +3150     
  Branches     1842     1913      +71     
==========================================
+ Hits        33079    34856    +1777     
- Misses       1659     3023    +1364     
- Partials      298      307       +9     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Aug 2, 2025

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 34 untouched benchmarks


Comparing feature/pydantic-first-class (950e900) with main (c9e269b)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (15adf59) during the generation of this report, so c9e269b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@patrick91

Copy link
Copy Markdown
Member Author

/pre-release

@botberry

botberry commented Aug 2, 2025

Copy link
Copy Markdown
Member

Pre-release

👋

Pre-release 0.279.0.dev.1754159379 [70130b4] has been released on PyPi! 🚀
You can try it by doing:

poetry add strawberry-graphql==0.279.0.dev.1754159379

@patrick91

Copy link
Copy Markdown
Member Author

/pre-release

@strawberry-graphql strawberry-graphql deleted a comment from botberry Aug 2, 2025
@patrick91

Copy link
Copy Markdown
Member Author

/pre-release

Comment thread tests/pydantic/test_error.py Outdated
Comment on lines +95 to +103
try:
# Validate the input using Pydantic
validated = CreateUserModel(name=input.name, age=input.age)
# Simulate successful creation
return CreateUserSuccess(
user_id=1, message=f"User {validated.name} created successfully"
)
except pydantic.ValidationError as e:
return Error.from_validation_error(e)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try:
# Validate the input using Pydantic
validated = CreateUserModel(name=input.name, age=input.age)
# Simulate successful creation
return CreateUserSuccess(
user_id=1, message=f"User {validated.name} created successfully"
)
except pydantic.ValidationError as e:
return Error.from_validation_error(e)
# Validate the input using Pydantic
validated = CreateUserModel(name=input.name, age=input.age)
return CreateUserSuccess(
user_id=1, message=f"User {validated.name} created successfully"
)

"""Represents a single validation error detail."""

type: str
loc: list[str]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might return something_else instead of somethingElse

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if there's no location? (for root errors)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.



@strawberry.pydantic.type
class User(BaseModel):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class User(BaseModel):
class User(Node):

Comment on lines +134 to +135
name: str = Field(alias="fullName")
age: int = Field(alias="yearsOld")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name: str = Field(alias="fullName")
age: int = Field(alias="yearsOld")
name: Annotated[str, Field(alias="fullName")]
age: Annotated[int, Field(alias="yearsOld")]

potentially also allow strawberry.pydantic.field?

Comment on lines +138 to +151
### Optional Fields

Pydantic optional fields are properly handled:

```python
from typing import Optional


@strawberry.pydantic.type
class User(BaseModel):
name: str
email: Optional[str] = None
age: Optional[int] = None
```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### Optional Fields
Pydantic optional fields are properly handled:
```python
from typing import Optional
@strawberry.pydantic.type
class User(BaseModel):
name: str
email: Optional[str] = None
age: Optional[int] = None
```

Comment on lines +190 to +192
if user.password:
return user
return None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a silly example, let's change it :D

Comment on lines +197 to +234
### Nested Types

Pydantic models can contain other Pydantic models:

```python
@strawberry.pydantic.type
class Address(BaseModel):
street: str
city: str
zipcode: str


@strawberry.pydantic.type
class User(BaseModel):
name: str
address: Address
```

### Lists and Collections

Lists of Pydantic models work seamlessly:

```python
from typing import List


@strawberry.pydantic.type
class User(BaseModel):
name: str
age: int


@strawberry.type
class Query:
@strawberry.field
def get_users(self) -> List[User]:
return [User(name="John", age=30), User(name="Jane", age=25)]
```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### Nested Types
Pydantic models can contain other Pydantic models:
```python
@strawberry.pydantic.type
class Address(BaseModel):
street: str
city: str
zipcode: str
@strawberry.pydantic.type
class User(BaseModel):
name: str
address: Address
```
### Lists and Collections
Lists of Pydantic models work seamlessly:
```python
from typing import List
@strawberry.pydantic.type
class User(BaseModel):
name: str
age: int
@strawberry.type
class Query:
@strawberry.field
def get_users(self) -> List[User]:
return [User(name="John", age=30), User(name="Jane", age=25)]
```

Comment thread docs/integrations/pydantic.md Outdated
name: str
age: int

@validator("age")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Pydantic v1, we should use field_validator

Let's also maybe use number: Annotated[int, AfterValidator(is_even)]

Comment thread docs/integrations/pydantic.md Outdated
Comment on lines +256 to +273
## Conversion Methods

Decorated models automatically get conversion methods:

```python
@strawberry.pydantic.type
class User(BaseModel):
name: str
age: int


# Create from existing Pydantic instance
pydantic_user = User(name="John", age=30)
strawberry_user = User.from_pydantic(pydantic_user)

# Convert back to Pydantic
converted_back = strawberry_user.to_pydantic()
```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Conversion Methods
Decorated models automatically get conversion methods:
```python
@strawberry.pydantic.type
class User(BaseModel):
name: str
age: int
# Create from existing Pydantic instance
pydantic_user = User(name="John", age=30)
strawberry_user = User.from_pydantic(pydantic_user)
# Convert back to Pydantic
converted_back = strawberry_user.to_pydantic()
```

we don't even need this

converted_back = strawberry_user.to_pydantic()
```

## Migration from Experimental

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this

Comment thread strawberry/pydantic/__init__.py Outdated
Comment on lines +13 to +20
from .error import Error
from .object_type import input as input_decorator
from .object_type import interface
from .object_type import type as type_decorator

# Re-export with proper names
input = input_decorator
type = type_decorator

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this good

"""Represents a single validation error detail."""

type: str
loc: list[str]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
loc: list[str]
location: list[str]


type: str
loc: list[str]
msg: str

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
msg: str
message: str

"""
return Error(
errors=[
ErrorDetail(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ErrorDetail(
ErrorDetail.from_error

Comment on lines +31 to +41
def _get_interfaces(cls: builtins.type[Any]) -> list[StrawberryObjectDefinition]:
"""Extract interfaces from a class's inheritance hierarchy."""
interfaces: list[StrawberryObjectDefinition] = []

for base in cls.__mro__[1:]: # Exclude current class
if hasattr(base, "__strawberry_definition__"):
type_definition = base.__strawberry_definition__
if type_definition.is_interface:
interfaces.append(type_definition)

return interfaces

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reuse the one from strawberry/types/object_type.py

Comment thread tests/pydantic/test_basic.py Outdated
age: int
password: Optional[str]

definition: StrawberryObjectDefinition = User.__strawberry_definition__

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use the util to get the definition

@patrick91 patrick91 changed the title WIP First-class Pydantic v2+ support Nov 25, 2025
@patrick91
patrick91 changed the base branch from main to 2026-06-28-add-generic-exception-handlers June 29, 2026 10:04
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 7067816 to 33ab45c Compare June 29, 2026 10:04
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Hi, thanks for contributing to this project!

We noticed that this PR is missing a RELEASE.md file. We use that to automatically do releases here on GitHub and, most importantly, to PyPI!

So as soon as this PR is merged, a release will be made 🚀.

Here's an example of RELEASE.md:

---
release type: patch
---

Description of the changes, ideally with some examples, if adding a new feature.

Release type can be one of patch, minor or major. We use [semver](https://semver.org/), so make sure to pick the appropriate type. If in doubt feel free to ask :)

@botberry

Copy link
Copy Markdown
Member

Apollo Federation Subgraph Compatibility Results

Federation 1 Support Federation 2 Support
_service🟢
@key (single)🟢
@key (multi)🟢
@key (composite)🟢
repeatable @key🟢
@requires🟢
@provides🟢
federated tracing🟢
@link🟢
@shareable🟢
@tag🟢
@override🟢
@inaccessible🟢
@composeDirective🟢
@interfaceObject🟢

Learn more:

@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 2501274 to aa3b94d Compare June 30, 2026 22:07
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 31bc6c3 to 222d5ec Compare June 30, 2026 22:50
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from f1d17fe to 32c8d2e Compare June 30, 2026 22:50
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from e6cf28e to 700155a Compare July 7, 2026 12:13
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 0f8436b to 88ccd0b Compare July 7, 2026 12:13
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 700155a to 2a857ac Compare July 7, 2026 12:17
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from a0c20b8 to a45e381 Compare July 7, 2026 12:44
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 2a857ac to 2ed8272 Compare July 7, 2026 19:43
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from af2fd79 to f052e20 Compare July 7, 2026 19:43
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 2ed8272 to d0c2d3b Compare July 7, 2026 22:56
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from f052e20 to ddaebe9 Compare July 7, 2026 22:56
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from d0c2d3b to 62d8f77 Compare July 8, 2026 09:15
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from ddaebe9 to 9bac2f2 Compare July 8, 2026 09:15
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 62d8f77 to a39a9fd Compare July 8, 2026 19:00
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 9bac2f2 to 6d38576 Compare July 8, 2026 19:00
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from a39a9fd to 4b478be Compare July 8, 2026 19:52
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 6d38576 to 5aa1c40 Compare July 8, 2026 19:52
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from 4b478be to fb74bb0 Compare July 8, 2026 21:33
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 5aa1c40 to 8ae4951 Compare July 8, 2026 21:33
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch from fb74bb0 to 799c41e Compare July 9, 2026 08:48
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 8ae4951 to 9ed2d87 Compare July 9, 2026 08:48
@patrick91
patrick91 force-pushed the 2026-06-28-add-generic-exception-handlers branch 2 times, most recently from 02240e4 to 028fa4d Compare July 13, 2026 20:30
Base automatically changed from 2026-06-28-add-generic-exception-handlers to main July 13, 2026 20:54
@patrick91
patrick91 force-pushed the feature/pydantic-first-class branch from 9ed2d87 to 70b518c Compare July 13, 2026 22:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants