Repository: strawberry-graphql/strawberry
Pull request #3859: Better implementation of GlobalID = ID
Author: login: patrick91
Created: 2025-05-16T22:46:32Z | Merged: 2025-05-17T09:25:01Z

WIP, this could actually become a new feature once implemented properly.

Closes #3854

## Summary by Sourcery

Enable seamless use of both GlobalID and ID scalars in the same schema by unifying their conversion and registration, and validate behavior through updated tests.

Enhancements:
- Allow mixing GlobalID and ID scalars in a single schema without conflicts
- Delegate GlobalID to the standard ID scalar when relay_use_legacy_global_id is disabled
- Add argument conversion for GlobalID inputs using GlobalID.from_id
- Remove special reserved_types handling for GlobalID in the scalar registry

Documentation:
- Add release notes documenting the patch release and support for using ID and GlobalID together

Tests:
- Update tests to verify correct parsing and serialization of GlobalID and ID inputs and returns
- Add new tests for returns of GlobalID and ID fields in both legacy and new modes

Linked issue #3854: unable to export graphql schema or run application when using both `strawberry.ID` and `strawberry.relay.GlobalID` for `strawberry-graphql==0.268.0`
I work on a [project](https://github.com/Arize-ai/phoenix) using both `strawberry.ID` and `strawberry.relay.GlobalID`. As of `strawberry-graphql==0.268.0`, I can no longer export the schema for the project. The code snippet below reproduces the issue:

```python
import strawberry
from strawberry.relay.types import GlobalID


@strawberry.type
class Query:
    @strawberry.field
    def hello(self, id: GlobalID) -> str:
        return "Hello World"

    @strawberry.field
    def hello2(self, id: strawberry.ID) -> str:
        return "Hello World"


schema = strawberry.Schema(
    Query,
)
```

Exporting the schema results in a verbose error message including:

```
ScalarAlreadyRegisteredError: Scalar `ID` has already been registered
```

This issue is blocking our adoption of the latest fix for the strawberry Relay integration.

Related:

- https://github.com/strawberry-graphql/strawberry/issues/3551, specifically https://github.com/strawberry-graphql/strawberry/issues/3551#issuecomment-2727691899
- https://github.com/strawberry-graphql/strawberry/pull/3853
Linked issue comments: user: login: axiomofjoy
created_at: 2025-05-11T13:22:02Z
body: @patrick91 @bellini666 Thanks so much for the Relay fix. I wanted to drop this one on your radar as an issue that is currently blocking our upgrade to adopt the fix.
author_association: NONE
user: login: axiomofjoy
created_at: 2025-05-11T16:37:09Z
body: This is also causing a similar error to be raised at runtime with the application.
author_association: NONE
user: login: bellini666
created_at: 2025-05-11T19:25:09Z
body: > @patrick91 @bellini666 Thanks so much for the Relay fix. I wanted to drop this one on your radar as an issue that is currently blocking our upgrade to adopt the fix.

Based on how we made the change, I think you can now expose both ID and GlobalID in the same schema, the only thing is that both will be exposed as ID in it, while they will still be what they are on python 

Can you check and let me know if that is what you were expecting? Or if we are actually still missing something?
author_association: MEMBER
user: login: axiomofjoy
created_at: 2025-05-14T02:51:25Z
body: Thanks so much for the quick turnaround @bellini666 @patrick91. It looks like there is still an issue on `strawberry-graphql==0.268.1` where `strawberry.ID` types are now expected to be Relay IDs. So in the example

```
import strawberry
from strawberry.relay.types import GlobalID


@strawberry.type
class Query:
    @strawberry.field
    def hello(self, id: GlobalID) -> str:
        return "Hello World"

    @strawberry.field
    def hello2(self, id: strawberry.ID) -> str:
        return "Hello World"


schema = strawberry.Schema(
    Query,
)
```

the query

```
{
  hello2(id:"123")
}
```

fails with error

```
"Expected value of type 'ID!', found \"123\"; Incorrect padding"
```

and stacktrace

```
Expected value of type 'ID!', found "123"; Incorrect padding

GraphQL request:2:13
1 | {
2 |   hello2(id:"123")
  |             ^
3 | }
Traceback (most recent call last):
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/strawberry/relay/utils.py", line 32, in from_base64
    res = base64.b64decode(value.encode()).decode().split(":", 1)
          ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
  File "/Users/xandersong/.local/share/uv/python/cpython-3.13.1-macos-aarch64-none/lib/python3.13/base64.py", line 88, in b64decode
    return binascii.a2b_base64(s, strict_mode=validate)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
binascii.Error: Incorrect padding

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/strawberry/relay/types.py", line 130, in from_id
    type_name, node_id = from_base64(value)
                         ~~~~~~~~~~~^^^^^^^
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/strawberry/relay/utils.py", line 34, in from_base64
    raise ValueError(str(e)) from e
ValueError: Incorrect padding

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/graphql/validation/rules/values_of_correct_type.py", line 141, in is_valid_value_node
    parse_result = type_.parse_literal(node)
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/strawberry/schema/schema_converter.py", line 273, in <lambda>
    parse_literal=lambda v, vars=None: GlobalID.from_id(  # noqa: A006
                                       ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
        GraphQLID.parse_literal(v, vars)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ),
    ^
  File "/Users/xandersong/Desktop/relay/.venv/lib/python3.13/site-packages/strawberry/relay/types.py", line 132, in from_id
    raise GlobalIDValueError(str(e)) from e
strawberry.relay.types.GlobalIDValueError: Incorrect padding
```
author_association: NONE
user: login: patrick91
created_at: 2025-05-14T07:57:42Z
body: @axiomofjoy 🤦‍♂️ we'll fix that, sorry!
author_association: MEMBER
user: login: axiomofjoy
created_at: 2025-05-14T15:45:51Z
body: > [@axiomofjoy](https://github.com/axiomofjoy) 🤦‍♂️ we'll fix that, sorry!

Thank you!
author_association: NONE
user: login: patrick91
created_at: 2025-05-15T21:52:24Z
body: I think this might be a bit more complex than I'd like, I'm a bit tired now but I think we need to figure our a way to customise how scalar work 🤔

Not sure if @bellini666 has an idea on how to fix this
author_association: MEMBER
user: login: patrick91
created_at: 2025-05-16T21:15:12Z
body: Had a chat with @bellini666 today and I think we might have found a nice solution :)
author_association: MEMBER
user: login: axiomofjoy
created_at: 2025-05-17T12:30:28Z
body: Thanks so much for the fix @bellini666 @patrick91!
author_association: NONE

Diff:
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000000..c7906abcc8
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,4 @@
+Release type: patch
+
+This release (finally) fixes support for using `ID` and `GlobalID`
+in the same schema.
diff --git a/strawberry/schema/schema_converter.py b/strawberry/schema/schema_converter.py
index 36c6407b45..a9840dd570 100644
--- a/strawberry/schema/schema_converter.py
+++ b/strawberry/schema/schema_converter.py
@@ -270,10 +270,7 @@ def _get_scalar_registry(
                 GlobalID,
                 name=global_id_name,
                 description=GraphQLID.description,
-                parse_literal=lambda v, vars=None: GlobalID.from_id(  # noqa: A006
-                    GraphQLID.parse_literal(v, vars)
-                ),
-                parse_value=GlobalID.from_id,
+                parse_value=lambda v: v,
                 serialize=str,
                 specified_by_url=("https://relay.dev/graphql/objectidentification.htm"),
             )
@@ -803,6 +800,13 @@ async def _async_resolver(
         return _resolver
 
     def from_scalar(self, scalar: type) -> GraphQLScalarType:
+        from strawberry.relay.types import GlobalID
+
+        if not self.config.relay_use_legacy_global_id and scalar is GlobalID:
+            from strawberry import ID
+
+            return self.from_scalar(ID)
+
         scalar_definition: ScalarDefinition
 
         if scalar in self.scalar_registry:
@@ -817,21 +821,13 @@ def from_scalar(self, scalar: type) -> GraphQLScalarType:
 
         scalar_name = self.config.name_converter.from_type(scalar_definition)
 
-        from strawberry.relay import GlobalID
-
         if scalar_name not in self.type_map:
-            if scalar is GlobalID and hasattr(GraphQLNamedType, "reserved_types"):
-                GraphQLNamedType.reserved_types.pop("ID")
-
             implementation = (
                 scalar_definition.implementation
                 if scalar_definition.implementation is not None
                 else _make_scalar_type(scalar_definition)
             )
 
-            if scalar is GlobalID and hasattr(GraphQLNamedType, "reserved_types"):
-                GraphQLNamedType.reserved_types["ID"] = implementation
-
             self.type_map[scalar_name] = ConcreteType(
                 definition=scalar_definition, implementation=implementation
             )
@@ -841,17 +837,7 @@ def from_scalar(self, scalar: type) -> GraphQLScalarType:
             # TODO: the other definition might not be a scalar, we should
             # handle this case better, since right now we assume it is a scalar
 
-            # special case to allow GlobalID to be used as an ID scalar
-            # TODO: we need to find a better way to handle this, might be
-            # worth reworking our scalar implementation.
-            if (
-                hasattr(other_definition, "origin")
-                and hasattr(scalar_definition, "origin")
-                and other_definition.origin == GlobalID
-                and scalar_definition.origin == GraphQLID
-            ):
-                pass
-            elif other_definition != scalar_definition:
+            if other_definition != scalar_definition:
                 other_definition = cast("ScalarDefinition", other_definition)
 
                 raise ScalarAlreadyRegisteredError(scalar_definition, other_definition)
diff --git a/strawberry/types/arguments.py b/strawberry/types/arguments.py
index f4d97ff474..24a6bf1125 100644
--- a/strawberry/types/arguments.py
+++ b/strawberry/types/arguments.py
@@ -170,6 +170,11 @@ def convert_argument(
         ]
 
     if is_scalar(type_, scalar_registry):
+        from strawberry.relay.types import GlobalID
+
+        if type_ is GlobalID:
+            return GlobalID.from_id(value)  # type: ignore
+
         return value
 
     if isinstance(type_, EnumDefinition):
diff --git a/tests/relay/test_id.py b/tests/relay/test_id.py
index c8341084b4..f3204bac28 100644
--- a/tests/relay/test_id.py
+++ b/tests/relay/test_id.py
@@ -95,11 +95,11 @@ def test_can_use_both_global_id_and_id() -> None:
     class Query:
         @strawberry.field
         def hello(self, id: GlobalID) -> str:
-            return "Hello World"
+            return id.node_id
 
         @strawberry.field
         def hello2(self, id: strawberry.ID) -> str:
-            return "Hello World"
+            return id
 
     schema = strawberry.Schema(Query)
 
@@ -110,17 +110,39 @@ def hello2(self, id: strawberry.ID) -> str:
 }\
 """)
 
+    result = schema.execute_sync(
+        """
+        query ($globalId: ID!, $id: ID!) {
+            a: hello(id: "Qm9vazox")
+            b: hello2(id: "1")
+            c: hello(id: $globalId)
+            d: hello2(id: $id)
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "1",
+            "b": "1",
+            "c": "1",
+            "d": "1",
+        }
+    )
+
 
 def test_can_use_both_global_id_and_id_legacy() -> None:
     @strawberry.type
     class Query:
         @strawberry.field
         def hello(self, id: GlobalID) -> str:
-            return "Hello World"
+            return id.node_id
 
         @strawberry.field
         def hello2(self, id: strawberry.ID) -> str:
-            return "Hello World"
+            return id
 
     schema = strawberry.Schema(
         query=Query, config=StrawberryConfig(relay_use_legacy_global_id=True)
@@ -137,3 +159,99 @@ def hello2(self, id: strawberry.ID) -> str:
   hello2(id: ID!): String!
 }\
 ''')
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: GlobalID!, $id: ID!) {
+            a: hello(id: $globalId)
+            b: hello2(id: $id)
+            c: hello(id: "Qm9vazox")
+            d: hello2(id: "1")
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "1",
+            "b": "1",
+            "c": "1",
+            "d": "1",
+        }
+    )
+
+
+def test_can_return_id() -> None:
+    @strawberry.type
+    class Query:
+        @strawberry.field
+        def hello(self, id: GlobalID) -> GlobalID:
+            return id
+
+        @strawberry.field
+        def hello2(self, id: strawberry.ID) -> strawberry.ID:
+            return id
+
+    schema = strawberry.Schema(Query)
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: ID!, $id: ID!) {
+            a: hello(id: "Qm9vazox")
+            b: hello2(id: "1")
+            c: hello(id: $globalId)
+            d: hello2(id: $id)
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "Qm9vazox",
+            "b": "1",
+            "c": "Qm9vazox",
+            "d": "1",
+        }
+    )
+
+
+def test_can_return_global_id_legacy() -> None:
+    @strawberry.type
+    class Query:
+        @strawberry.field
+        def hello(self, id: GlobalID) -> GlobalID:
+            return id
+
+        @strawberry.field
+        def hello2(self, id: strawberry.ID) -> strawberry.ID:
+            return id
+
+    schema = strawberry.Schema(
+        query=Query, config=StrawberryConfig(relay_use_legacy_global_id=True)
+    )
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: GlobalID!, $id: ID!) {
+            a: hello(id: $globalId)
+            b: hello2(id: $id)
+            c: hello(id: "Qm9vazox")
+            d: hello2(id: "1")
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "Qm9vazox",
+            "b": "1",
+            "c": "Qm9vazox",
+            "d": "1",
+        }
+    )


Reviews:
user: login: patrick91
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T22:51:22Z
user: login: patrick91
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T22:54:44Z
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR implements a unified approach for handling `GlobalID` and `ID` types in Strawberry GraphQL, fixing compatibility issues when using both types in the same schema.

- Introduces internal `_GlobalID` scalar type mapped to GraphQL `ID` scalar in `schema_converter.py`
- Updates `convert_argument` in `arguments.py` to properly handle `GlobalID` type conversion using `GlobalID.from_id`
- Adds test cases in `test_id.py` to validate interchangeable usage of `GlobalID` and `ID` types
- Maintains backward compatibility through `relay_use_legacy_global_id` config flag

<sub>4 file(s) reviewed, no comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=strawberry_3859)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T23:04:08Z
user: login: sourcery-ai[bot]
body: Hey @patrick91 - I've reviewed your changes and they look great!

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 1 issue found
- 🟢 **Testing**: all looks good
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T23:06:29Z

Review comments:
diff_hunk: @@ -0,0 +1,7 @@
+class _StrawberryMappedScalar:
+    def __init__(self, scalar_type: type) -> None:
+        self.scalar_type = scalar_type
+
+
+def _mapped_scalar(cls: type) -> type:
+    return _StrawberryMappedScalar(cls)
path: strawberry/types/_mapped_scalar.py
user: login: patrick91
body: The way I implemented this PR right doesn't really use this, so maybe we can just get rid of it? 🤔 

The idea was to do something like `GlobalID = Annotated[_GlobalID, _mapped_scalar(ID)]`, but we are not using that annotated value at all 😊
created_at: 2025-05-16T22:50:35Z
author_association: MEMBER
diff_hunk: @@ -170,6 +170,11 @@ def convert_argument(
         ]
 
     if is_scalar(type_, scalar_registry):
+        from strawberry.relay.types import _GlobalID
+
+        if type_ is _GlobalID:
+            return _GlobalID.from_id(value)
path: strawberry/types/arguments.py
user: login: patrick91
body: this would check if the type is a mapped type, but maybe this is fine for now.

I think we should remove the legacy global id support as soon as we can 😊
created_at: 2025-05-16T22:51:11Z
author_association: MEMBER
diff_hunk: @@ -137,3 +159,99 @@ def hello2(self, id: strawberry.ID) -> str:
   hello2(id: ID!): String!
 }\
 ''')
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: GlobalID!, $id: ID!) {
+            a: hello(id: $globalId)
+            b: hello2(id: $id)
+            c: hello(id: "Qm9vazox")
+            d: hello2(id: "1")
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "1",
+            "b": "1",
+            "c": "1",
+            "d": "1",
+        }
+    )
+
+
+def test_can_return_id() -> None:
+    @strawberry.type
+    class Query:
+        @strawberry.field
+        def hello(self, id: GlobalID) -> GlobalID:
+            return id
+
+        @strawberry.field
+        def hello2(self, id: strawberry.ID) -> strawberry.ID:
+            return id
+
+    schema = strawberry.Schema(Query)
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: ID!, $id: ID!) {
+            a: hello(id: "Qm9vazox")
+            b: hello2(id: "1")
+            c: hello(id: $globalId)
+            d: hello2(id: $id)
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "Qm9vazox",
+            "b": "1",
+            "c": "Qm9vazox",
+            "d": "1",
+        }
+    )
+
+
+def test_can_return_global_id_legacy() -> None:
+    @strawberry.type
+    class Query:
+        @strawberry.field
+        def hello(self, id: GlobalID) -> GlobalID:
+            return id
+
+        @strawberry.field
+        def hello2(self, id: strawberry.ID) -> strawberry.ID:
+            return id
+
+    schema = strawberry.Schema(
+        query=Query, config=StrawberryConfig(relay_use_legacy_global_id=True)
+    )
+
+    result = schema.execute_sync(
+        """
+        query ($globalId: GlobalID!, $id: ID!) {
+            a: hello(id: $globalId)
+            b: hello2(id: $id)
+            c: hello(id: "Qm9vazox")
+            d: hello2(id: "1")
+        }
+        """,
+        variable_values={"globalId": "Qm9vazox", "id": "1"},
+    )
+
+    assert result.errors is None
+    assert result.data == snapshot(
+        {
+            "a": "Qm9vazox",
+            "b": "1",
+            "c": "Qm9vazox",
+            "d": "1",
+        }
+    )
path: tests/relay/test_id.py
user: login: patrick91
body: I'm glad this just works!
created_at: 2025-05-16T22:54:44Z
author_association: MEMBER
diff_hunk: @@ -170,6 +170,11 @@ def convert_argument(
         ]
 
     if is_scalar(type_, scalar_registry):
+        from strawberry.relay.types import GlobalID
+
+        if type_ is GlobalID:
path: strawberry/types/arguments.py
user: login: sourcery-ai[bot]
body: **issue:** Handle None for nullable GlobalID arguments

Return None when value is None rather than calling GlobalID.from_id(None).
created_at: 2025-05-16T23:06:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -170,6 +170,11 @@
         ]
 
     if is_scalar(type_, scalar_registry):
+        from strawberry.relay.types import GlobalID
+
+        if type_ is GlobalID:
+            return GlobalID.from_id(value)  # type: ignore
+
         return value
 
path: strawberry/types/arguments.py
user: login: sourcery-ai[bot]
body: **suggestion (code-quality):** We've found these issues:

- Lift code into else after jump in control flow ([`reintroduce-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/reintroduce-else/))
- Replace if statement with if expression ([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/))

```suggestion
        return GlobalID.from_id(value) if type_ is GlobalID else value
```
created_at: 2025-05-16T23:06:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-05-16T22:46:36Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide

Unify `GlobalID` and `ID` by introducing an internal `_GlobalID` scalar mapped to the GraphQL `ID` type, streamlining schema conversion and argument handling, and extending tests to validate the combined behavior under both new and legacy configurations.

#### Sequence Diagram: `SchemaConverter.from_scalar` handling `GlobalID`

```mermaid
sequenceDiagram
    participant Caller
    participant SC as SchemaConverter
    participant Config as StrawberryConfig
    participant ID_Type as "strawberry.ID"
    participant GlobalID_Type as "strawberry.relay.GlobalID"

    Caller->>SC: from_scalar(GlobalID_Type)
    SC->>Config: read relay_use_legacy_global_id
    Config-->>SC: is_legacy_disabled
    alt is_legacy_disabled is true (non-legacy mode)
        SC->>SC: from_scalar(ID_Type)
        Note right of SC: Delegates to handling for strawberry.ID
        SC-->>Caller: GraphQLScalarType (for ID)
    else is_legacy_disabled is false (legacy mode)
        SC->>GlobalID_Type: Process GlobalID_Type with its own scalar definition
        Note right of SC: Uses GlobalID's simplified parsing/serialization
        GlobalID_Type-->>SC: GraphQLScalarType (for GlobalID)
        SC-->>Caller: GraphQLScalarType (for GlobalID)
    end
```

#### Sequence Diagram: `ArgumentConverter.convert_argument` handling `GlobalID`

```mermaid
sequenceDiagram
    participant Caller
    participant AC as ArgumentConverter
    participant GlobalID_Class as "GlobalID (class)"
    participant GlobalID_Type as "GlobalID (type)"

    Caller->>AC: convert_argument(inputValue, GlobalID_Type)
    AC->>AC: Check if type is GlobalID_Type
    alt Type is GlobalID_Type
        AC->>GlobalID_Class: from_id(inputValue)
        GlobalID_Class-->>AC: convertedValue
        AC-->>Caller: convertedValue
    else Type is not GlobalID_Type
        AC->>AC: Other conversion logic (omitted)
        AC-->>Caller: convertedValue
    end
```

#### Class Diagram: `GlobalID` and `ID` Handling Modifications

```mermaid
classDiagram
    class GlobalID {
        <<PythonType>>
        +from_id(value) : GlobalID
        # When treated as its own scalar (e.g. legacy mode):
        # parse_value: (v) -> v  (changed from GlobalID.from_id)
        # serialize: (v) -> str(v)
    }
    note for GlobalID "Strawberry's GlobalID type.\nIts schema representation and parsing logic are updated.\nIn non-legacy mode, it maps to the GraphQL ID scalar." 

    class ID {
        <<GraphQLScalar>>
    }
    note for ID "Standard GraphQL ID scalar."

    class SchemaConverter {
        - config: StrawberryConfig
        +from_scalar(scalar: type) : GraphQLScalarType
    }
    note for SchemaConverter "If config.relay_use_legacy_global_id is false:\n  from_scalar(GlobalID) now delegates to from_scalar(ID)."

    class ArgumentConverter {
        +convert_argument(value, type_): any
    }
    note for ArgumentConverter "If argument type is GlobalID, now calls GlobalID.from_id(value) for conversion."

    GlobalID ..> ID : "Mapped to in GraphQL schema\n(if relay_use_legacy_global_id=false)"
    SchemaConverter ..> GlobalID : "Handles type conversion for"
    SchemaConverter ..> ID : "Handles type conversion for"
    ArgumentConverter ..> GlobalID : "Handles input conversion for"
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Simplify and centralize `_GlobalID` registration in schema conversion | <ul><li>Register `_GlobalID` directly under the `ID` name with a no-op `parse_value`</li><li>Map `GlobalID` to the `ID` scalar in `from_scalar` when legacy mode is disabled</li><li>Remove manual manipulation of `GraphQLNamedType.reserved_types` and special‐case logic</li></ul> | `strawberry/schema/schema_converter.py` |
| Add explicit argument conversion for `GlobalID` | <ul><li>Detect `GlobalID` in `convert_argument` and apply `GlobalID.from_id` to input values</li></ul> | `strawberry/types/arguments.py` |
| Expand and update tests for combined `GlobalID`/`ID` support | <ul><li>Change return values in existing tests to return actual `id.node_id` or `id`</li><li>Add queries using both literal and variable forms under new and legacy modes</li><li>Introduce tests for returning `GlobalID`/`ID` from fields and assert snapshots</li></ul> | `tests/relay/test_id.py` |
| Include release notes for patch update | <ul><li>Add `RELEASE.md` documenting the patch release and its focus</li></ul> | `RELEASE.md` |

### Assessment against linked issues

| Issue | Objective | Addressed | Explanation |
| ------ | ------- | ----- | ----- |
| #3854 | Fix the `ScalarAlreadyRegisteredError` that occurs when using both `strawberry.ID` and `strawberry.relay.GlobalID` in a schema. | ✅ |  |

### Possibly linked issues

- **#3854**: The PR implements a unified handling for `GlobalID` and `ID` by mapping `_GlobalID` to the `ID` scalar, resolving the scalar registration conflict.

---

<details>
<summary>Tips and commands</summary>

#### 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](https://app.sourcery.ai) 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

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: botberry
created_at: 2025-05-16T23:04:22Z
body: 
Thanks for adding the `RELEASE.md` file!

![](https://media.giphy.com/media/xq1FxHkABwW7m/giphy.gif)

Here's a preview of the changelog:

---

This release (finally) fixes support for using `ID` and `GlobalID`
in the same schema.



Here's the tweet text: 
```
🆕 Release (next) is out! Thanks to @patrick91 for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)
```
<!-- action-check: release-file -->
author_association: MEMBER
user: login: patrick91
created_at: 2025-05-16T23:05:15Z
body: We had this idea of implementing a mapper in order to do things like:

```python
GlobalID = Annotated[_GlobalID, _mapped_scalar(ID)]
```

or better

```python
GlobalID = Annotated[strawberry.ID, SomeNameSimilarToTransformer(_GlobalID)
```

but I think the current solution, scoped to global IDs is actually better and with less scope creep 😊

Also I think we'll remove GlobalID in schema in future, we can implement a better solution then, as we should also refactor scalars 😊
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-05-16T23:05:21Z
body: ## [Codecov](https://app.codecov.io/gh/strawberry-graphql/strawberry/pull/3859?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=strawberry-graphql) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.06%. Comparing base [(`6596b29`)](https://app.codecov.io/gh/strawberry-graphql/strawberry/commit/6596b29c168b58f81485ea10b1d57ac08ee97548?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=strawberry-graphql) to head [(`8649be8`)](https://app.codecov.io/gh/strawberry-graphql/strawberry/commit/8649be87e4d6e6edfa79f0139bbcadfb91088047?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=strawberry-graphql).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3859      +/-   ##
==========================================
+ Coverage   95.03%   95.06%   +0.02%     
==========================================
  Files         508      508              
  Lines       32986    33018      +32     
  Branches     1713     1712       -1     
==========================================
+ Hits        31348    31388      +40     
+ Misses       1362     1356       -6     
+ Partials      276      274       -2     
```

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: patrick91
created_at: 2025-05-16T23:06:35Z
body: @axiomofjoy can you try the pre-release, so I don't push another broken release? :D 
author_association: MEMBER
user: login: patrick91
created_at: 2025-05-16T23:06:40Z
body: /pre-release
author_association: MEMBER
user: login: botberry
created_at: 2025-05-16T23:07:23Z
body: # Pre-release
:wave:

Pre-release **0.268.2.dev.1747436835** [8649be87e4d6e6edfa79f0139bbcadfb91088047] has been released on PyPi! :rocket:
You can try it by doing:

```shell
poetry add strawberry-graphql==0.268.2.dev.1747436835
```
author_association: MEMBER
user: login: codspeed-hq[bot]
created_at: 2025-05-16T23:14:56Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/strawberry-graphql/strawberry/branches/global-id-mapper)

### Merging #3859 will **not alter performance**

<sub>Comparing <code>global-id-mapper</code> (8649be8) with <code>main</code> (6596b29)</sub>



### Summary

`✅ 21` untouched benchmarks  




author_association: NONE
user: login: axiomofjoy
created_at: 2025-05-16T23:55:07Z
body: > @axiomofjoy can you try the pre-release, so I don't push another broken release? :D

@patrick91 Looks like this is working! Ready to release.
author_association: NONE