Repository: pydantic/pydantic
Pull request: Expose public `sort` method for JSON schema generation
Closes https://github.com/pydantic/pydantic/issues/7580

At first, thought we might have to add a flag to fix this. The way I feel about adding flags at this point is about how [edna (from The Incredibles) feels about capes](https://www.youtube.com/watch?v=YL3w73MAuIM&ab_channel=EntertainmentAccess).

Luckily, was able to sidestep the need for a flag by exposing the sort method as a public one in the `GenerateJsonSchema` class.

Did a little bit of refactoring to make things more type checking friendly re the outer sort vs recursive sorts. I did the move -> public and refactoring in different commits, so should be easy to review. Also added a docs example to expose the customizability here. Yay for empowered users!



Problem statement:
Order of keys differs in schema output
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

When a model is specified as a Field and the values are instantiated , the keys are output in sorted order. I would expect that the order of the keys be maintained.

See sample code below. The output of the class `Stepping` when executed standalone is 

```py 
print(Stepping(type="const", step=0.15, multi_step=True))
# type='const' step=0.15 multi_step=True
```

But when the class `Stepping` is used as a  field in another Model and the Field is instantiated then the output of the schema has the values sorted.  **_'default': {'multi_step': True, 'step': 0.15, 'type': 'const'}}},_**

```py
print(Movement.model_json_schema())

{'$defs': {'Stepping': {'properties': {'type': {'const': 'const',
     'title': 'Type'},
    'step': {'title': 'Step', 'type': 'number'},
    'multi_step': {'default': False,
     'title': 'Multi Step',
     'type': 'boolean'}},
   'required': ['type', 'step'],
   'title': 'Stepping',
   'type': 'object'}},
 'properties': {'type': {'const': 'movement',
   'default': 'movement',
   'title': 'Type'},
  'step_settings': {'allOf': [{'$ref': '#/$defs/Stepping'}],
   'default': {'multi_step': True, 'step': 0.15, 'type': 'const'}}},
 'title': 'Movement',
 'type': 'object'}
```

Is there a setting that would allow the retaining of the field order in the output? Or am i doing something wrong?

Thanks in advance.

### Example Code

```Python
from pydantic import BaseModel, Field
from typing import Literal

class Stepping(BaseModel):
    type: Literal["const"] = Field(
        ...,
    )
    step: float = Field(
        ...,
    )
    multi_step: bool = Field(
        default=False,
    )

print(Stepping(type="const", step=0.15, multi_step=True))

class Movement(BaseModel):
    type: Literal["movement"] = Field(
        default="movement"
    )
    step_settings: Stepping = Field(
        default=Stepping(type="const", step=0.15, multi_step=True)
    )

print(Movement.model_json_schema())
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.3.0
pydantic-core version: 2.6.3
pydantic-core build: profile=release pgo=false
install path: /scratch/myprograms/miniconda3/envs/pydantic-v2/lib/python3.11/site-packages/pydantic
python version: 3.11.5 | packaged by conda-forge | (main, Aug 27 2023, 03:34:09) [GCC 12.3.0]
                     platform: Linux-3.10.0-1160.45.1.el7.x86_64-x86_64-with-glibc2.17
optional deps. installed: ['typing-extensions']
```

Hints from issue discussion:
update , i checked the code and the source seems to be

https://github.com/pydantic/pydantic/blob/8f3ac4e30845957d79a12e8963fa37bfa0c9b811/pydantic/json_schema.py#L439

it would be nice to have a flag that also allows non sorted schema output. 

https://github.com/pydantic/pydantic/blob/8f3ac4e30845957d79a12e8963fa37bfa0c9b811/pydantic/json_schema.py#L389

### Some reasons:
- We also generate documentation from the schema and it is better to have the documentation output mirror the input
- Order of fields in output is same as input
- In pydantic v1 the order was not sorted

Update:

[6043](https://github.com/pydantic/pydantic/pull/6043) is where the change to sorting was added. Please consider adding a flag so that the output can be unsorted too.
Hi @vigneshmanick,

Thanks for looking into this! Seems like a reasonable feature request to me. Also related is #7424, enabling sorting for `model_dump` and friends.

Are you interested in opening a PR to help with this feature request? 😄
@sydney-runkle  sure, let me give it a try.
Maybe I’m misunderstanding. The goal is to make json schemas determined to stick in the face of re-factoring. We should be sorting anything that does not have a natural order and keep the natural order on the things that do. Model fields have a natural order so they should never be sorted, if they are that’s a bug.
@adriangb are you suggesting that the alphabetical sorting of the fields `z` and `a` in [this test](https://github.com/pydantic/pydantic/pull/7767/files#diff-cbcfeba88238560f75e880d530f4cd82ab6bf90247012e8f664281941f8da1f7R5711) when the sorting logic is implemented is a bug, given that `z` is defined first in the model definition?
Well that's a new test being added right? The goal of sorting is that schema generation is deterministic, e.g. when you migrate from v2 to v3 your tests don't break because the JSON schema output is different. Or if we refactor `pydantic/json_schema.py`. Without sorting we might accidentally change the order from e.g. `{"type": "string", "enum": ["one", "two", "three"]}` to `{"enum": ["one", "two", "three"], "type": "string"}` or `{"type": "string", "enum": ["three", "two", "one"]}`. Some of this is up for debate (e.g. should we sort enum values?) but I think the model fields have a natural order (the order they are defined in code) that we try to preserve elsewhere and should try to preserve in the json shcema.
yes, the order of model fields is sorted in the output which is the main issue i have with the schema output. 

e.g from the issue,  ` 'default': {'multi_step': True, 'step': 0.15, 'type': 'const'}}},` the fields are sorted.
@sydney-runkle seems like a bug around https://github.com/pydantic/pydantic/blob/8f3ac4e30845957d79a12e8963fa37bfa0c9b811/pydantic/json_schema.py#L2227 then. Can you investigate and make a PR to fix? We may need to handle `default` as a special case...
@adriangb @vigneshmanick,

Yep, I'll look into this bug and open a fix. @vigneshmanick, if we don't sort the model fields in the schema, does that eliminate the need for your sorting flag?
Yes that works for me.

Commit message: typo

Patch to review:
diff --git a/docs/concepts/json_schema.md b/docs/concepts/json_schema.md
index e889ccb0fd6..63cfb3297af 100644
--- a/docs/concepts/json_schema.md
+++ b/docs/concepts/json_schema.md
@@ -1414,6 +1414,68 @@ print(validation_schema)
 """
 ```
 
+### JSON schema sorting
+
+By default, Pydantic recursively sorts JSON schemas by alphabetically sorting keys. Notably, Pydantic skips sorting the values of the `properties` key,
+to preserve the order of the fields as they were defined in the model.
+
+If you would like to customize this behavior, you can override the `sort` method in your custom `GenerateJsonSchema` subclass. The below example
+uses a no-op `sort` method to disable sorting entirely, which is reflected in the preserved order of the model fields and `json_schema_extra` keys:
+
+```py
+import json
+from typing import Optional
+
+from pydantic import BaseModel, Field
+from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
+
+
+class MyGenerateJsonSchema(GenerateJsonSchema):
+    def sort(
+        self, value: JsonSchemaValue, parent_key: Optional[str] = None
+    ) -> JsonSchemaValue:
+        """No-op, we don't want to sort schema values at all."""
+        return value
+
+
+class Bar(BaseModel):
+    c: str
+    b: str
+    a: str = Field(json_schema_extra={'c': 'hi', 'b': 'hello', 'a': 'world'})
+
+
+json_schema = Bar.model_json_schema(schema_generator=MyGenerateJsonSchema)
+print(json.dumps(json_schema, indent=2))
+"""
+{
+  "type": "object",
+  "properties": {
+    "c": {
+      "type": "string",
+      "title": "C"
+    },
+    "b": {
+      "type": "string",
+      "title": "B"
+    },
+    "a": {
+      "type": "string",
+      "c": "hi",
+      "b": "hello",
+      "a": "world",
+      "title": "A"
+    }
+  },
+  "required": [
+    "c",
+    "b",
+    "a"
+  ],
+  "title": "Bar"
+}
+"""
+```
+
 ## Customizing the `$ref`s in JSON Schema
 
 The format of `$ref`s can be altered by calling [`model_json_schema()`][pydantic.main.BaseModel.model_json_schema]
diff --git a/pydantic/json_schema.py b/pydantic/json_schema.py
index 35721068716..5e74cb3f5e1 100644
--- a/pydantic/json_schema.py
+++ b/pydantic/json_schema.py
@@ -392,7 +392,7 @@ def generate_definitions(
         json_schema = {'$defs': self.definitions}
         json_schema = definitions_remapping.remap_json_schema(json_schema)
         self._used = True
-        return json_schemas_map, _sort_json_schema(json_schema['$defs'])  # type: ignore
+        return json_schemas_map, self.sort(json_schema['$defs'])  # type: ignore
 
     def generate(self, schema: CoreSchema, mode: JsonSchemaMode = 'validation') -> JsonSchemaValue:
         """Generates a JSON schema for a specified schema in a specified mode.
@@ -441,7 +441,7 @@ def generate(self, schema: CoreSchema, mode: JsonSchemaMode = 'validation') -> J
         # json_schema['$schema'] = self.schema_dialect
 
         self._used = True
-        return _sort_json_schema(json_schema)
+        return self.sort(json_schema)
 
     def generate_inner(self, schema: CoreSchemaOrField) -> JsonSchemaValue:  # noqa: C901
         """Generates a JSON schema for a given core schema.
@@ -556,6 +556,38 @@ def new_handler_func(
             json_schema = populate_defs(schema, json_schema)
         return json_schema
 
+    def sort(self, value: JsonSchemaValue, parent_key: str | None = None) -> JsonSchemaValue:
+        """Override this method to customize the sorting of the JSON schema (e.g., don't sort at all, sort all keys unconditionally, etc.)
+
+        By default, alphabetically sort the keys in the JSON schema, skipping the 'properties' and 'default' keys to preserve field definition order.
+        This sort is recursive, so it will sort all nested dictionaries as well.
+        """
+        sorted_dict: dict[str, JsonSchemaValue] = {}
+        keys = value.keys()
+        if (parent_key != 'properties') and (parent_key != 'default'):
+            keys = sorted(keys)
+        for key in keys:
+            sorted_dict[key] = self._sort_recursive(value[key], parent_key=key)
+        return sorted_dict
+
+    def _sort_recursive(self, value: Any, parent_key: str | None = None) -> Any:
+        """Recursively sort a JSON schema value."""
+        if isinstance(value, dict):
+            sorted_dict: dict[str, JsonSchemaValue] = {}
+            keys = value.keys()
+            if (parent_key != 'properties') and (parent_key != 'default'):
+                keys = sorted(keys)
+            for key in keys:
+                sorted_dict[key] = self._sort_recursive(value[key], parent_key=key)
+            return sorted_dict
+        elif isinstance(value, list):
+            sorted_list: list[JsonSchemaValue] = []
+            for item in value:
+                sorted_list.append(self._sort_recursive(item, parent_key))
+            return sorted_list
+        else:
+            return value
+
     # ### Schema generation methods
     def any_schema(self, schema: core_schema.AnySchema) -> JsonSchemaValue:
         """Generates a JSON schema that matches any value.
@@ -2367,24 +2399,6 @@ def _make_json_hashable(value: JsonValue) -> _HashableJsonValue:
         return value
 
 
-def _sort_json_schema(value: JsonSchemaValue, parent_key: str | None = None) -> JsonSchemaValue:
-    if isinstance(value, dict):
-        sorted_dict: dict[str, JsonSchemaValue] = {}
-        keys = value.keys()
-        if (parent_key != 'properties') and (parent_key != 'default'):
-            keys = sorted(keys)
-        for key in keys:
-            sorted_dict[key] = _sort_json_schema(value[key], parent_key=key)
-        return sorted_dict
-    elif isinstance(value, list):
-        sorted_list: list[JsonSchemaValue] = []
-        for item in value:  # type: ignore
-            sorted_list.append(_sort_json_schema(item, parent_key))
-        return sorted_list  # type: ignore
-    else:
-        return value
-
-
 @dataclasses.dataclass(**_internal_dataclass.slots_true)
 class WithJsonSchema:
     """Usage docs: https://docs.pydantic.dev/2.10/concepts/json_schema/#withjsonschema-annotation


Code review comments:
diff_hunk: @@ -556,6 +556,38 @@ def new_handler_func(
             json_schema = populate_defs(schema, json_schema)
         return json_schema
 
+    def sort(self, value: JsonSchemaValue, parent_key: str | None = None) -> JsonSchemaValue:
+        """Override this method to customize the sorting of the JSON schema (e.g., don't sort at all, sort all keys unconditionally, etc.)
+
+        By default, alphabetically sort the keys in the JSON schema, skipping the 'properties' and 'default' keys to preserve field definition order.
+        This sort is recursive, so it will sort all nested dictionaries as well.
+        """
+        sorted_dict: dict[str, JsonSchemaValue] = {}
+        keys = value.keys()
+        if (parent_key != 'properties') and (parent_key != 'default'):
path: pydantic/json_schema.py
text: @user1:
```suggestion
        if parent_key not in ('properties', 'default'):
```

same as below

diff_hunk: @@ -556,6 +556,38 @@ def new_handler_func(
             json_schema = populate_defs(schema, json_schema)
         return json_schema
 
+    def sort(self, value: JsonSchemaValue, parent_key: str | None = None) -> JsonSchemaValue:
+        """Override this method to customize the sorting of the JSON schema (e.g., don't sort at all, sort all keys unconditionally, etc.)
+
+        By default, alphabetically sort the keys in the JSON schema, skipping the 'properties' and 'default' keys to preserve field definition order.
+        This sort is recursive, so it will sort all nested dictionaries as well.
+        """
+        sorted_dict: dict[str, JsonSchemaValue] = {}
+        keys = value.keys()
+        if (parent_key != 'properties') and (parent_key != 'default'):
+            keys = sorted(keys)
+        for key in keys:
+            sorted_dict[key] = self._sort_recursive(value[key], parent_key=key)
+        return sorted_dict
+
+    def _sort_recursive(self, value: Any, parent_key: str | None = None) -> Any:
+        """Recursively sort a JSON schema value."""
+        if isinstance(value, dict):
+            sorted_dict: dict[str, JsonSchemaValue] = {}
+            keys = value.keys()
+            if (parent_key != 'properties') and (parent_key != 'default'):
path: pydantic/json_schema.py
text: @user1:
```suggestion
            if parent_key not in ('properties', 'default'):
```

Easier to extend in the future

@author:
Good call