Skip to content

Conversation

@thorroed
Copy link

@thorroed thorroed commented Nov 12, 2025

pydantic inputs (strawberry.auto): honor UNSET / allow opt-out of Pydantic defaults.
Pydantic defaults can mask omitted input fields in GraphQL. This opt-out preserves strict input semantics while keeping current behavior as default.

Fixes #4042

Example code:

from __future__ import annotations

import pydantic
import strawberry
from strawberry import UNSET

class UserModel(pydantic.BaseModel):
    name: str
    interests: list[str] | None = pydantic.Field(default_factory=list)

@strawberry.experimental.pydantic.input(model=UserModel, use_pydantic_default=False)
class UpdateUserInput:
    name: strawberry.auto
    interests: strawberry.auto

@strawberry.type
class Mutation:
    @strawberry.mutation
    async def update_user(self, user_data: UpdateUserInput) -> str:
        changes: dict[str, object] = {}
        if user_data.name is not UNSET:
            changes["name"] = user_data.name
        if user_data.interests is not UNSET:        # omitted vs provided
            changes["interests"] = user_data.interests  # None or list[str]

        print("==strawberry.asdict==")
        print(strawberry.asdict(user_data))
        print("==strawberry.to_pydantic==")
        print(user_data.to_pydantic())

        current = UserModel(name="Alice", interests=["games"])
        updated = current.model_copy(update=changes)
        return f"changes={changes} before={current.model_dump()} after={updated.model_dump()}"

@strawberry.type
class Query:
    @strawberry.field
    def hello(self) -> str:
        return "Hi"

schema = strawberry.Schema(query=Query, mutation=Mutation)

if __name__ == "__main__":
    from starlette.testclient import TestClient
    from strawberry.asgi import GraphQL

    client = TestClient(GraphQL(schema))

    # We provide only name to update
    response = client.post("/graphql", json={
        "query": 'mutation { updateUser(userData: {name: "John"}) }'
    })

    # Should be no interests here in UpdateUserInput
    print("==RESULT==")
    result = response.json()["data"]
    print(result)

Output of example code above:

==strawberry.asdict==
{'name': 'John', 'interests': UNSET}
==strawberry.to_pydantic==
name='John' interests=[]
==RESULT==
{'updateUser': "changes={'name': 'John'} before={'name': 'Alice', 'interests': ['games']} after={'name': 'John', 'interests': ['games']}"}

Summary by Sourcery

Introduce use_pydantic_default flag to preserve true tri-state input semantics for Pydantic-backed GraphQL inputs.

New Features:

  • Add use_pydantic_default option to input and type decorators to control materialization of Pydantic defaults.

Bug Fixes:

  • Honor UNSET for omitted input fields and fix masking of omitted fields by Pydantic defaults.

Enhancements:

  • Default to preserving existing behavior while enabling opt-out for strict input semantics.

Documentation:

  • Document use_pydantic_default parameter in the input decorator docstring.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Nov 12, 2025

Reviewer's Guide

Introduce a flag to control whether Pydantic defaults are materialized for GraphQL inputs, enabling true tri-state semantics by preserving UNSET values when opted out.

File-Level Changes

Change Details Files
Add and propagate use_pydantic_default parameter
  • Define use_pydantic_default (default=True) in type and input signatures
  • Pass use_pydantic_default through wrap and _build_dataclass_creation_fields
  • Document new parameter in input() docstring
strawberry/experimental/pydantic/object_type.py
Implement UNSET default handling for input fields
  • Import UNSET and define local _default_unset helper
  • Use _default_unset as default_factory when is_input and use_pydantic_default=False
  • Set default to dataclasses.MISSING while controlling default_factory dynamically
strawberry/experimental/pydantic/object_type.py
Filter out UNSET fields when converting to Pydantic model
  • Rewrite to_pydantic_default to build instance_kwargs in a loop
  • Skip fields with UNSET when use_pydantic_default is False
  • Retain compatibility by still merging override kwargs into instance_kwargs
strawberry/experimental/pydantic/object_type.py

Assessment against linked issues

Issue Objective Addressed Explanation
#4042 Enable omission of input fields in Strawberry Pydantic input types so that omitted fields are distinguishable from fields set to their default values, allowing partial updates.
#4042 Provide a mechanism (such as a flag or option) to opt out of Pydantic default values for input fields, so that omitted fields are represented as UNSET.

Possibly linked issues

  • Pydantic Input Types behavior is strange #4042: PR's use_pydantic_default=False makes omitted Pydantic input fields UNSET, addressing the issue's goal of UNSET handling and type checking.
  • #Provided Issue: The PR introduces use_pydantic_default=False to prevent UNSET from being converted to None in Pydantic inputs, directly addressing the issue's problem.

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

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@botberry
Copy link
Member

botberry commented Nov 12, 2025

Hi, thanks for contributing to Strawberry 🍓!

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, so make sure to pick the appropriate type. If in doubt feel free to ask :)

Here's the tweet text:

🆕 Release (next) is out! Thanks to thorroed for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Nov 12, 2025

Greptile Overview

Greptile Summary

This PR adds a use_pydantic_default parameter to the Pydantic integration decorators to support tristate semantics (omitted vs. null vs. value) for input types. When set to False, fields omitted by the GraphQL client are represented as UNSET instead of materializing Pydantic defaults.

Key Changes:

  • Added use_pydantic_default parameter (defaults to True for backward compatibility) to type() and input() decorators
  • Modified _build_dataclass_creation_fields() to use _default_unset() factory when use_pydantic_default=False for input types
  • Updated to_pydantic_default() to skip UNSET fields when converting back to Pydantic models with use_pydantic_default=False
  • Added docstring to input() decorator explaining the new parameter

Behavior:

  • With use_pydantic_default=False, omitted fields have value UNSET and can be checked with field is UNSET
  • When calling .to_pydantic(), UNSET fields are excluded from the kwargs, allowing Pydantic to apply its defaults
  • Addresses the issue where partial updates were impossible due to Pydantic defaults masking omitted fields

Confidence Score: 4/5

  • This PR is safe to merge with minor considerations
  • The implementation is sound and solves a real problem with Pydantic input types. The changes are well-contained, maintain backward compatibility (defaults to True), and follow existing patterns in the codebase. Score is 4 instead of 5 due to: (1) lack of automated tests for the new feature, and (2) the interface decorator not receiving the same parameter (though this may be intentional since interfaces are typically output types)
  • No files require special attention - the single file changed has clear logic and proper backward compatibility

Important Files Changed

File Analysis

Filename Score Overview
strawberry/experimental/pydantic/object_type.py 4/5 Adds use_pydantic_default parameter to enable UNSET tristate semantics for input types, allowing distinction between omitted fields and fields with Pydantic defaults

Sequence Diagram

sequenceDiagram
    participant Client as GraphQL Client
    participant Schema as Strawberry Schema
    participant Input as UpdateUserInput
    participant Pydantic as UserModel (Pydantic)
    
    Note over Client,Pydantic: use_pydantic_default=False flow
    
    Client->>Schema: mutation { updateUser(userData: {name: "John"}) }
    Schema->>Input: Create UpdateUserInput(name="John")
    
    alt Field Construction (use_pydantic_default=False)
        Note over Input: interests field gets UNSET<br/>via _default_unset() factory
        Input->>Input: name = "John"
        Input->>Input: interests = UNSET
    end
    
    Input->>Input: Check field with `is UNSET`
    
    alt Converting to Pydantic (to_pydantic)
        Input->>Pydantic: to_pydantic()
        Note over Input,Pydantic: Skip UNSET fields when<br/>use_pydantic_default=False
        Input->>Pydantic: model(name="John")
        Note over Pydantic: Pydantic applies default_factory<br/>interests=[]
    end
    
    Pydantic-->>Client: Updated user data
    
    Note over Client,Pydantic: use_pydantic_default=True (default) flow
    
    Client->>Schema: mutation { updateUser(userData: {name: "Alice"}) }
    Schema->>Input: Create UpdateUserInput(name="Alice")
    
    alt Field Construction (use_pydantic_default=True)
        Note over Input: interests field gets Pydantic default<br/>via get_default_factory_for_field()
        Input->>Input: name = "Alice"
        Input->>Input: interests = []
    end
    
    Input->>Pydantic: to_pydantic()
    Input->>Pydantic: model(name="Alice", interests=[])
    
    Pydantic-->>Client: Updated user data
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link
Member

@bellini666 bellini666 left a comment

Choose a reason for hiding this comment

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

Thanks :)

Can we have a test for this?

Comment on lines 89 to 95
# local helper to satisfy E731 (no lambda assignment)
def _default_unset() -> object:
return UNSET

# for inputs with use_pydantic_default, default_factory should be used
if is_input and not use_pydantic_default:
default_factory = _default_unset
Copy link
Member

Choose a reason for hiding this comment

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

thought: We have UnsetType, which is UNSET's class, and it overrides __new__ to make sure it is always a singleton. Maybe we can also override __call__ to return itself, so we could just do default_factory = UNSET?

/cc @patrick91

@thorroed thorroed requested a review from bellini666 November 27, 2025 09:19
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.

Pydantic Input Types behavior is strange

3 participants