Repository: pydantic/pydantic

Issue:
Allow custom json encoder / decoder
makes more sense, perhaps we could have `dump_json` and `load_json` attributes of `Config` which are then used in `parse_json()`, `json()` and `schema_json()` (and anywhere else we use encode/decode json that I've forgotten about).

_Originally posted by @samuelcolvin in https://github.com/samuelcolvin/pydantic/pull/599#issuecomment-516968833_


Comments:
as discussed in #599 @pablogamboa suggested allowing a custom JSON encoder / decoder library.

This would allow libraries like orjson to be used without making them explicit dependencies of pydantic.

The problem here is going to be how to deal with the `default` argument with standard lib `json` and `orjson` allow, but not `ujson`.
If this requires a breaking change it should be done with the bump to version 1.
Did you think about implementing something similar to @JrooTJunior done in https://github.com/aiogram/aiogram/blob/dev-2.x/aiogram/utils/json.py?

I would prefer more like the [`json_serialize`](https://docs.aiohttp.org/en/stable/client_quickstart.html#json-request) argument in aiohttp, so the end user is responsible for implementing a function that correct encodes or decodes JSON.
Yes. `json_serialize` is more good way.

I want to remove this code from aiogram.
> https://github.com/aiogram/aiogram/blob/dev-2.x/aiogram/utils/json.py


@samuelcolvin 
is anyone implementing this now?
If not, I want to implement this feature.

No one has started this. PR very welcome.

Patch:
diff --git a/docs/examples/json_orjson.py b/docs/examples/json_orjson.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/json_orjson.py
@@ -0,0 +1,21 @@
+from datetime import datetime
+import orjson
+from pydantic import BaseModel
+
+def orjson_dumps(v, *, default):
+    # orjson.dumps returns bytes, to match standard json.dumps we need to decode
+    return orjson.dumps(v, default=default).decode()
+
+class User(BaseModel):
+    id: int
+    name = 'John Doe'
+    signup_ts: datetime = None
+
+    class Config:
+        json_loads = orjson.loads
+        json_dumps = orjson_dumps
+
+
+user = User.parse_raw('{"id": 123, "signup_ts": 1234567890, "name": "John Doe"}')
+print(user.json())
+#> {"id":123,"signup_ts":"2009-02-13T23:31:30+00:00","name":"John Doe"}
diff --git a/docs/examples/json_ujson.py b/docs/examples/json_ujson.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/json_ujson.py
@@ -0,0 +1,15 @@
+from datetime import datetime
+import ujson
+from pydantic import BaseModel
+
+class User(BaseModel):
+    id: int
+    name = 'John Doe'
+    signup_ts: datetime = None
+
+    class Config:
+        json_loads = ujson.loads
+
+user = User.parse_raw('{"id": 123, "signup_ts": 1234567890, "name": "John Doe"}')
+print(user)
+#> User id=123 signup_ts=datetime.datetime(2009, 2, 13, 23, 31, 30, tzinfo=datetime.timezone.utc) name='John Doe'
diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py
--- a/pydantic/env_settings.py
+++ b/pydantic/env_settings.py
@@ -1,4 +1,3 @@
-import json
 import os
 from typing import Any, Dict, Optional, cast
 
@@ -47,7 +46,7 @@ def _build_environ(self) -> Dict[str, Optional[str]]:
             if env_val:
                 if field.is_complex():
                     try:
-                        env_val = json.loads(env_val)
+                        env_val = self.__config__.json_loads(env_val)  # type: ignore
                     except ValueError as e:
                         raise SettingsError(f'error parsing JSON for "{env_name}"') from e
                 d[field.alias] = env_val
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -63,11 +63,13 @@ class BaseConfig:
     validate_assignment = False
     error_msg_templates: Dict[str, str] = {}
     arbitrary_types_allowed = False
-    json_encoders: Dict[AnyType, AnyCallable] = {}
     orm_mode: bool = False
     alias_generator: Optional[Callable[[str], str]] = None
     keep_untouched: Tuple[type, ...] = ()
     schema_extra: Dict[str, Any] = {}
+    json_loads: Callable[[str], Any] = json.loads
+    json_dumps: Callable[..., str] = json.dumps
+    json_encoders: Dict[AnyType, AnyCallable] = {}
 
     @classmethod
     def get_field_schema(cls, name: str) -> Dict[str, str]:
@@ -307,7 +309,7 @@ def json(
         data = self.dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults)
         if self._custom_root_type:
             data = data['__root__']
-        return json.dumps(data, default=encoder, **dumps_kwargs)
+        return self.__config__.json_dumps(data, default=encoder, **dumps_kwargs)
 
     @classmethod
     def parse_obj(cls: Type['Model'], obj: Any) -> 'Model':
@@ -334,7 +336,12 @@ def parse_raw(
     ) -> 'Model':
         try:
             obj = load_str_bytes(
-                b, proto=proto, content_type=content_type, encoding=encoding, allow_pickle=allow_pickle
+                b,
+                proto=proto,
+                content_type=content_type,
+                encoding=encoding,
+                allow_pickle=allow_pickle,
+                json_loads=cls.__config__.json_loads,
             )
         except (ValueError, TypeError, UnicodeDecodeError) as e:
             raise ValidationError([ErrorWrapper(e, loc='__obj__')], cls)
@@ -437,7 +444,7 @@ def schema(cls, by_alias: bool = True) -> 'DictStrAny':
     def schema_json(cls, *, by_alias: bool = True, **dumps_kwargs: Any) -> str:
         from .json import pydantic_encoder
 
-        return json.dumps(cls.schema(by_alias=by_alias), default=pydantic_encoder, **dumps_kwargs)
+        return cls.__config__.json_dumps(cls.schema(by_alias=by_alias), default=pydantic_encoder, **dumps_kwargs)
 
     @classmethod
     def __get_validators__(cls) -> 'CallableGenerator':
diff --git a/pydantic/parse.py b/pydantic/parse.py
--- a/pydantic/parse.py
+++ b/pydantic/parse.py
@@ -1,15 +1,11 @@
+import json
 import pickle
 from enum import Enum
 from pathlib import Path
-from typing import Any, Union
+from typing import Any, Callable, Union
 
 from .types import StrBytes
 
-try:
-    import ujson as json
-except ImportError:
-    import json  # type: ignore
-
 
 class Protocol(str, Enum):
     json = 'json'
@@ -17,7 +13,13 @@ class Protocol(str, Enum):
 
 
 def load_str_bytes(
-    b: StrBytes, *, content_type: str = None, encoding: str = 'utf8', proto: Protocol = None, allow_pickle: bool = False
+    b: StrBytes,
+    *,
+    content_type: str = None,
+    encoding: str = 'utf8',
+    proto: Protocol = None,
+    allow_pickle: bool = False,
+    json_loads: Callable[[str], Any] = json.loads,
 ) -> Any:
     if proto is None and content_type:
         if content_type.endswith(('json', 'javascript')):
@@ -32,7 +34,7 @@ def load_str_bytes(
     if proto == Protocol.json:
         if isinstance(b, bytes):
             b = b.decode(encoding)
-        return json.loads(b)
+        return json_loads(b)
     elif proto == Protocol.pickle:
         if not allow_pickle:
             raise RuntimeError('Trying to decode with pickle with allow_pickle=False')
diff --git a/pydantic/validators.py b/pydantic/validators.py
--- a/pydantic/validators.py
+++ b/pydantic/validators.py
@@ -1,4 +1,3 @@
-import json
 import re
 import sys
 from collections import OrderedDict
@@ -424,9 +423,9 @@ def constr_strip_whitespace(v: 'StrBytes', field: 'Field', config: 'BaseConfig')
     return v
 
 
-def validate_json(v: Any) -> Any:
+def validate_json(v: Any, config: 'BaseConfig') -> Any:
     try:
-        return json.loads(v)
+        return config.json_loads(v)  # type: ignore
     except ValueError:
         raise errors.JsonError()
     except TypeError:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -99,7 +99,6 @@ def extra(self):
         'dataclasses>=0.6;python_version<"3.7"'
     ],
     extras_require={
-        'ujson': ['ujson>=1.35'],
         'email': ['email-validator>=1.0.3'],
         'typing_extensions': ['typing-extensions>=3.7.2']
     },


Test patch:
diff --git a/tests/test_json.py b/tests/test_json.py
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -167,3 +167,29 @@ class Model(BaseModel):
         __root__: List[str]
 
     assert Model(__root__=['a', 'b']).json() == '["a", "b"]'
+
+
+def test_custom_decode_encode():
+    load_calls, dump_calls = 0, 0
+
+    def custom_loads(s):
+        nonlocal load_calls
+        load_calls += 1
+        return json.loads(s.strip('$'))
+
+    def custom_dumps(s, default=None, **kwargs):
+        nonlocal dump_calls
+        dump_calls += 1
+        return json.dumps(s, default=default, indent=2)
+
+    class Model(BaseModel):
+        a: int
+        b: str
+
+        class Config:
+            json_loads = custom_loads
+            json_dumps = custom_dumps
+
+    m = Model.parse_raw('${"a": 1, "b": "foo"}$$')
+    assert m.dict() == {'a': 1, 'b': 'foo'}
+    assert m.json() == '{\n  "a": 1,\n  "b": "foo"\n}'