Repository: dynaconf/dynaconf
Pull request #900 (2023-03-23 01:59:22)

Feature - Validate on call to update, set or load_file
This add support for choosing whether to validate or not data loaded by calling the `Settings` methods `update`, `set` and `load_file`. It also adds a global option called `VALIDATE_ON_UPDATE_FOR_DYNACONF` which sets the default behaviour for validating on updates. Related to #712

## Sample usage

```python
settings = Dynaconf() # default is validate_on_update=False, so it won't have compat issues
settings.validators.register(Validator("value_a", must_exist=True))
settings.update({"value_b": "foo"}) # will bypass validation
settings.update({"value_b": "foo"}, validate=False) # will raise ValidationError
```

```python
settings = Dynaconf(validate_on_update=True)
settings.validators.register(Validator("value_a", must_exist=True))
settings.update({"value_b": "foo"}, validate=False) # will bypass validation
settings.update({"value_b": "foo"}) # will raise ValidationError
```

## Notes

- `load_file` required adding `validate` to lots of places where `set` and `update` were used by loaders. Should there be tests to each one of the loaders?
- I shall update the docs on this PR later if the code is approved


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *dynaconf* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | bdaf8d054987d1849c5cbd0c45478c563227259f |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/dynaconf/deploys/641bb27da98d34000851f507 |
codecov-commenter: ## [Codecov](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf) Report
> Merging [#900](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf) (bdaf8d0) into [master](https://codecov.io/gh/dynaconf/dynaconf/commit/b31af67a65de19d465d5f084d7a5d8c6214420d8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf) (b31af67) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf)

```diff
@@           Coverage Diff           @@
##           master     #900   +/-   ##
=======================================
  Coverage   99.39%   99.39%           
=======================================
  Files          21       21           
  Lines        1816     1827   +11     
=======================================
+ Hits         1805     1816   +11     
  Misses         11       11           
```


| [Impacted Files](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf) | Coverage Δ | |
|---|---|---|
| [dynaconf/base.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvYmFzZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/base.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy9iYXNlLnB5) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/env\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy9lbnZfbG9hZGVyLnB5) | `90.00% <100.00%> (ø)` | |
| [dynaconf/loaders/ini\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy9pbmlfbG9hZGVyLnB5) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/json\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy9qc29uX2xvYWRlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/py\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy9weV9sb2FkZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/toml\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy90b21sX2xvYWRlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [dynaconf/loaders/yaml\_loader.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvbG9hZGVycy95YW1sX2xvYWRlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [dynaconf/validator.py](https://codecov.io/gh/dynaconf/dynaconf/pull/900?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf#diff-ZHluYWNvbmYvdmFsaWRhdG9yLnB5) | `100.00% <100.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dynaconf)

pedro-psb: Will this be shipped in the next release (3.1.13)? Just so I can add the right note about when it was introduced.
pedro-psb: I've noticed that the current implementation does not include support for using `validate_all()`. It will always use `validate()`.

What do you think of adding this option to the interface?

```python
settings.update({...}, validate=True) # runs `validate`
settings.update({...}, validate=False) # does not run
settings.update({...}, validate="all") # runs `validate_all()`  <<<
```
rochacbruno: I like the idea of adding validate_all just ensure to change the conditions to `if validate is True` instead of just `if validate` because `all` would also evaluate to True
pedro-psb: I've finished this, but I'm having some trouble pushing the up-to-date work. This last commit was not supposed to be there, it is some side-effect of me trying to push the actual work.

I'll try to figure this out without using `--force`.

Patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index 3ba0c12..e9df321 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -474,6 +474,38 @@ skip_files=["path/to/ignored.toml"]
 
 ---
 
+### **validate_on_update**
+
+> type=`False | True | "all"`, default=`False` </br>
+> env-var=`VALIDATE_ON_UPDATE_FOR_DYNACONF`
+
+Defines if validation will be triggered when a Dynaconf instance
+is updated with the methods `update()`, `set()` or `load_file()`.
+Also defines which raising strategy will be used (see more on [Validation](/validation/#trigger-on-data-update)).
+
+Valid options are:
+
+- `False` (default): auto-trigger off
+- `True`: trigger as `settings.validate()`, which raises on first errors
+- `"all"`: trigger as `settings.validate_all()`, which accumulates errors
+
+
+</br>
+
+This behaviour can also be defined on a per-call basis. Just provide
+the parameter `validate` to any of the data-update methods.
+
+Eg:
+
+```python
+settings = Dynaconf(validate_on_update="all")
+settings.validators.register(Validator("value_a", must_exist=True))
+
+settings.update({"value_b": "foo"}, validate=False) # will bypass validation
+settings.update({"value_b": "foo"}, validate=True) # will call .validate()
+settings.update({"value_b": "foo"}) # will call .validate_all()
+```
+
 ### **validators**
 
 > type=`list`, default=`[]` </br>
diff --git a/docs/validation.md b/docs/validation.md
index a5cb64e..ecbcff5 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -19,7 +19,10 @@ PROJECT = "This is not hello_world"
 
 ## Validating in Python programmatically
 
-At any point of your program you can do:
+### On instantiation
+
+When you instantiate your settings, Dynaconf will run all the validators
+you've defined agaisnt your initial data.
 
 ```python
 from pathlib import Path
@@ -63,6 +66,59 @@ settings = Dynaconf(
 
 The above will raise `dynaconf.validators.ValidationError("AGE must be lte=30 but it is 35 in env DEVELOPMENT")` and `dynaconf.validators.ValidationError("PROJECT must be eq='hello_world' but it is 'This is not hello_world' in env PRODUCTION")`
 
+### Lazy validation
+
+Instead of passing `validators=` argument to `Dynaconf` class you can register validators
+after the instance is created and trigger it manually.
+
+<h4>Register</h4>
+
+First, register some validators. This won't trigger the validation yet.
+
+```python
+settings = Dynaconf()
+
+settings.validators.register(
+    Validator("MYSQL_HOST", eq="development.com", env="DEVELOPMENT"),
+    Validator("MYSQL_HOST", ne="development.com", env="PRODUCTION"),
+)
+```
+
+<h4>Trigger manually</h4>
+
+You may choose two strategies for the validation:
+
+- `validate`: raises `ValidationError` on the first error found
+- `validate_all`: raises `ValidationError` at the end. Accumulative error data is stored at `details`
+
+```python
+# raises on first error found
+settings.validators.validate()
+
+# raises after all possible errors are evaluated
+try:
+    settings.validators.validate_all()
+except dynaconf.ValidationError as e:
+    accumulative_errors = e.details
+    print(accumulative_errors)
+```
+
+<h4>Trigger on data update</h4>
+
+By default, if the data of an instance is updated with `update`, `set` or `load_file` methods,
+no validation will be triggered.
+
+You can override this globally with the option [validate_on_update](/configuration/#validate_on_update) or
+set this on a per-call basis.
+
+```python
+# validate_on_update=False (default)
+settings.update({"NEW_VALUE": 123}, validate=True) # triggers validators.validate()
+settings.update({"NEW_VALUE": 123}, validate="all") # triggers validators.validate_all()
+
+# validate_on_update=True or "all"
+settings.update({"NEW_VALUE": 123}) # will trigger with the global strategy
+```
 
 ## Validator parameters 
 
@@ -248,40 +304,8 @@ Validator(
 )
 ```
 
-## Lazy validation
-
-Instead of passing `validators=` argument to `Dynaconf` class you can register validators after the instance is created.
-
-```python
-settings = Dynaconf(...)
-
-custom_msg = "You cannot set {name} to {value} in env {env}"
-settings.validators.register(
-    Validator("MYSQL_HOST", eq="development.com", env="DEVELOPMENT"),
-    Validator("MYSQL_HOST", ne="development.com", env="PRODUCTION"),
-    Validator("VERSION", ne=1, messages={"operations": custom_msg}),
-    Validator("BLABLABLA", must_exist=True),
-)
-```
-
-Having the list of validators registered you can call one of:
-
-### Validate and raise on the first error:
-
-```python
-settings.validators.validate()
-```
-
-### Validate and accumulate errors, raise only after all validators are evaluated.
-
-```python
-settings.validators.validate_all()
-```
-
-The raised `ValidationError` will have an attribute `details` holding information about each
-error raised.
 
-### Providing default or computed values
+## Providing default or computed values
 
 
 Validators can be used to provide default or computed values.
diff --git a/dynaconf/base.py b/dynaconf/base.py
index fa65cfe..2d143d2 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -35,6 +35,7 @@ from dynaconf.utils.parse_conf import converters
 from dynaconf.utils.parse_conf import get_converter
 from dynaconf.utils.parse_conf import parse_conf_data
 from dynaconf.utils.parse_conf import true_values
+from dynaconf.validator import ValidationError
 from dynaconf.validator import ValidatorList
 from dynaconf.vendor.box.box_list import BoxList
 
@@ -802,7 +803,9 @@ class Settings:
         for key in keys:
             self.unset(key, force=force)
 
-    def _dotted_set(self, dotted_key, value, tomlfy=False, **kwargs):
+    def _dotted_set(
+        self, dotted_key, value, tomlfy=False, validate=empty, **kwargs
+    ):
         """Sets dotted keys as nested dictionaries.
 
         Dotted set will always reassign the value, to merge use `@merge` token
@@ -813,7 +816,12 @@ class Settings:
 
         Keyword Arguments:
             tomlfy {bool} -- Perform toml parsing (default: {False})
+            validate {bool} --
         """
+        if validate is empty:
+            validate = self.get(
+                "VALIDATE_ON_UPDATE_FOR_DYNACONF"
+            )  # pragma: nocover
 
         split_keys = dotted_key.split(".")
         existing_data = self.get(split_keys[0], {})
@@ -831,7 +839,7 @@ class Settings:
                 new=new_data,
                 full_path=split_keys,
             )
-        self.update(data=new_data, tomlfy=tomlfy, **kwargs)
+        self.update(data=new_data, tomlfy=tomlfy, validate=validate, **kwargs)
 
     def set(
         self,
@@ -841,6 +849,7 @@ class Settings:
         tomlfy=False,
         dotted_lookup=empty,
         is_secret="DeprecatedArgument",  # noqa
+        validate=empty,
         merge=False,
     ):
         """Set a value storing references for the loader
@@ -850,7 +859,11 @@ class Settings:
         :param loader_identifier: Optional loader name e.g: toml, yaml etc.
         :param tomlfy: Bool define if value is parsed by toml (defaults False)
         :param merge: Bool define if existing nested data will be merged.
+        :param validate: Bool define if validation will be triggered
         """
+        if validate is empty:
+            validate = self.get("VALIDATE_ON_UPDATE_FOR_DYNACONF")
+
         if dotted_lookup is empty:
             dotted_lookup = self.get("DOTTED_LOOKUP_FOR_DYNACONF")
 
@@ -861,7 +874,11 @@ class Settings:
 
         if "." in key and dotted_lookup is True:
             return self._dotted_set(
-                key, value, loader_identifier=loader_identifier, tomlfy=tomlfy
+                key,
+                value,
+                loader_identifier=loader_identifier,
+                tomlfy=tomlfy,
+                validate=validate,
             )
 
         value = parse_conf_data(value, tomlfy=tomlfy, box_settings=self)
@@ -917,6 +934,9 @@ class Settings:
             # a default value and goes away only when explicitly unset
             self._defaults[key] = value
 
+        if validate is True:
+            self.validators.validate()
+
     def update(
         self,
         data=None,
@@ -925,6 +945,7 @@ class Settings:
         merge=False,
         is_secret="DeprecatedArgument",  # noqa
         dotted_lookup=empty,
+        validate=empty,
         **kwargs,
     ):
         """
@@ -943,20 +964,34 @@ class Settings:
         :param loader_identifier: Only to be used by custom loaders
         :param tomlfy: Bool define if value is parsed by toml (defaults False)
         :param merge: Bool define if existing nested data will be merged.
+        :param validate: Bool define if validators will trigger automatically
         :param kwargs: extra values to update
         :return: None
         """
+
+        if validate is empty:
+            validate = self.get("VALIDATE_ON_UPDATE_FOR_DYNACONF")
+
         data = data or {}
         data.update(kwargs)
         for key, value in data.items():
-            self.set(
-                key,
-                value,
-                loader_identifier=loader_identifier,
-                tomlfy=tomlfy,
-                merge=merge,
-                dotted_lookup=dotted_lookup,
-            )
+            # update() will handle validation later
+            with suppress(ValidationError):
+                self.set(
+                    key,
+                    value,
+                    loader_identifier=loader_identifier,
+                    tomlfy=tomlfy,
+                    merge=merge,
+                    dotted_lookup=dotted_lookup,
+                    validate=validate,
+                )
+
+        # handle param `validate`
+        if validate is True:
+            self.validators.validate()
+        elif validate == "all":
+            self.validators.validate_all()
 
     def _merge_before_set(self, existing, value):
         """Merge the new value being set with the existing value before set"""
@@ -1054,7 +1089,9 @@ class Settings:
             if last_loader and last_loader == env_loader:
                 last_loader.load(self, env, silent, key)
 
-    def load_file(self, path=None, env=None, silent=True, key=None):
+    def load_file(
+        self, path=None, env=None, silent=True, key=None, validate=empty
+    ):
         """Programmatically load files from ``path``.
 
         :param path: A single filename or a file list
@@ -1062,18 +1099,23 @@ class Settings:
         :param silent: Should raise errors?
         :param key: Load a single key?
         """
+        if validate is empty:
+            validate = self.get("VALIDATE_ON_UPDATE_FOR_DYNACONF")
+
         env = (env or self.current_env).upper()
         files = ensure_a_list(path)
         if files:
             already_loaded = set()
             for _filename in files:
 
-                if py_loader.try_to_load_from_py_module_name(
-                    obj=self, name=_filename, silent=True
-                ):
-                    # if it was possible to load from module name
-                    # continue the loop.
-                    continue
+                # load_file() will handle validation later
+                with suppress(ValidationError):
+                    if py_loader.try_to_load_from_py_module_name(
+                        obj=self, name=_filename, silent=True
+                    ):
+                        # if it was possible to load from module name
+                        # continue the loop.
+                        continue
 
                 root_dir = str(self._root_path or os.getcwd())
 
@@ -1099,14 +1141,24 @@ class Settings:
                 for path in paths + local_paths:
                     if path in already_loaded:  # pragma: no cover
                         continue
-                    settings_loader(
-                        obj=self,
-                        env=env,
-                        silent=silent,
-                        key=key,
-                        filename=path,
-                    )
-                    already_loaded.add(path)
+
+                    # load_file() will handle validation later
+                    with suppress(ValidationError):
+                        settings_loader(
+                            obj=self,
+                            env=env,
+                            silent=silent,
+                            key=key,
+                            filename=path,
+                            validate=validate,
+                        )
+                        already_loaded.add(path)
+
+        # handle param `validate`
+        if validate is True:
+            self.validators.validate()
+        elif validate == "all":
+            self.validators.validate_all()
 
     @property
     def _root_path(self):
diff --git a/dynaconf/default_settings.py b/dynaconf/default_settings.py
index 40c627b..5fd5692 100644
--- a/dynaconf/default_settings.py
+++ b/dynaconf/default_settings.py
@@ -246,6 +246,8 @@ APPLY_DEFAULT_ON_NONE_FOR_DYNACONF = get(
     "APPLY_DEFAULT_ON_NONE_FOR_DYNACONF", None
 )
 
+VALIDATE_ON_UPDATE_FOR_DYNACONF = get("VALIDATE_ON_UPDATE_FOR_DYNACONF", False)
+
 
 # Backwards compatibility with renamed variables
 for old, new in RENAMED_VARS.items():
diff --git a/dynaconf/loaders/__init__.py b/dynaconf/loaders/__init__.py
index d154d4d..fe5c05a 100644
--- a/dynaconf/loaders/__init__.py
+++ b/dynaconf/loaders/__init__.py
@@ -135,7 +135,13 @@ def execute_hooks(
 
 
 def settings_loader(
-    obj, settings_module=None, env=None, silent=True, key=None, filename=None
+    obj,
+    settings_module=None,
+    env=None,
+    silent=True,
+    key=None,
+    filename=None,
+    validate=False,
 ):
     """Loads from defined settings module
 
@@ -201,7 +207,12 @@ def settings_loader(
 
             if mod_file.endswith(loader["ext"]):
                 loader["loader"].load(
-                    obj, filename=mod_file, env=env, silent=silent, key=key
+                    obj,
+                    filename=mod_file,
+                    env=env,
+                    silent=silent,
+                    key=key,
+                    validate=validate,
                 )
                 continue
 
@@ -214,7 +225,7 @@ def settings_loader(
 
         # must be Python file or module
         # load from default defined module settings.py or .secrets.py if exists
-        py_loader.load(obj, mod_file, key=key)
+        py_loader.load(obj, mod_file, key=key, validate=validate)
 
         # load from the current env e.g: development_settings.py
         env = env or obj.current_env
@@ -241,11 +252,17 @@ def settings_loader(
             identifier=f"py_{env.upper()}",
             silent=True,
             key=key,
+            validate=validate,
         )
 
         # load from global_settings.py
         py_loader.load(
-            obj, global_mod_file, identifier="py_global", silent=True, key=key
+            obj,
+            global_mod_file,
+            identifier="py_global",
+            silent=True,
+            key=key,
+            validate=validate,
         )
 
 
diff --git a/dynaconf/loaders/base.py b/dynaconf/loaders/base.py
index dec5cb0..bd76432 100644
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -29,6 +29,7 @@ class BaseLoader:
         file_reader,
         string_reader,
         opener_params=None,
+        validate=False,
     ):
         """Instantiates a loader for different sources"""
         self.obj = obj
@@ -41,6 +42,7 @@ class BaseLoader:
             "mode": "r",
             "encoding": obj.get("ENCODING_FOR_DYNACONF", "utf-8"),
         }
+        self.validate = validate
 
     @staticmethod
     def warn_not_installed(obj, identifier):  # pragma: no cover
@@ -184,6 +186,7 @@ class BaseLoader:
                 loader_identifier=identifier,
                 merge=file_merge,
                 dotted_lookup=file_dotted_lookup,
+                validate=self.validate,
             )
         elif key in data:
             self.obj.set(
@@ -192,4 +195,5 @@ class BaseLoader:
                 loader_identifier=identifier,
                 merge=file_merge,
                 dotted_lookup=file_dotted_lookup,
+                validate=self.validate,
             )
diff --git a/dynaconf/loaders/env_loader.py b/dynaconf/loaders/env_loader.py
index a9210a7..1284753 100644
--- a/dynaconf/loaders/env_loader.py
+++ b/dynaconf/loaders/env_loader.py
@@ -16,17 +16,31 @@ with suppress(ImportError, FileNotFoundError):
 IDENTIFIER = "env"
 
 
-def load(obj, env=None, silent=True, key=None):
+def load(obj, env=None, silent=True, key=None, validate=False):
     """Loads envvars with prefixes:
 
     `DYNACONF_` (default global) or `$(ENVVAR_PREFIX_FOR_DYNACONF)_`
     """
     global_prefix = obj.get("ENVVAR_PREFIX_FOR_DYNACONF")
     if global_prefix is False or global_prefix.upper() != "DYNACONF":
-        load_from_env(obj, "DYNACONF", key, silent, IDENTIFIER + "_global")
+        load_from_env(
+            obj,
+            "DYNACONF",
+            key,
+            silent,
+            IDENTIFIER + "_global",
+            validate=validate,
+        )
 
     # Load the global env if exists and overwrite everything
-    load_from_env(obj, global_prefix, key, silent, IDENTIFIER + "_global")
+    load_from_env(
+        obj,
+        global_prefix,
+        key,
+        silent,
+        IDENTIFIER + "_global",
+        validate=validate,
+    )
 
 
 def load_from_env(
@@ -36,6 +50,7 @@ def load_from_env(
     silent=False,
     identifier=IDENTIFIER,
     env=False,  # backwards compatibility bc renamed param
+    validate=False,
 ):
     if prefix is False and env is not False:
         prefix = env
@@ -54,7 +69,13 @@ def load_from_env(
         value = environ.get(f"{env_}{key}")
         if value:
             try:  # obj is a Settings
-                obj.set(key, value, loader_identifier=identifier, tomlfy=True)
+                obj.set(
+                    key,
+                    value,
+                    loader_identifier=identifier,
+                    tomlfy=True,
+                    validate=validate,
+                )
             except AttributeError:  # obj is a dict
                 obj[key] = parse_conf_data(
                     value, tomlfy=True, box_settings=obj
@@ -84,7 +105,7 @@ def load_from_env(
             filter_strategy = obj.get("FILTER_STRATEGY")
             if filter_strategy:
                 data = filter_strategy(data)
-            obj.update(data, loader_identifier=identifier)
+            obj.update(data, loader_identifier=identifier, validate=validate)
 
 
 def write(settings_path, settings_data, **kwargs):
diff --git a/dynaconf/loaders/ini_loader.py b/dynaconf/loaders/ini_loader.py
index c3b56fd..0156fc6 100644
--- a/dynaconf/loaders/ini_loader.py
+++ b/dynaconf/loaders/ini_loader.py
@@ -14,7 +14,7 @@ except ImportError:  # pragma: no cover
     ConfigObj = None
 
 
-def load(obj, env=None, silent=True, key=None, filename=None):
+def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     Reads and loads in to "obj" a single key or all keys from source file.
 
@@ -36,6 +36,7 @@ def load(obj, env=None, silent=True, key=None, filename=None):
         extensions=INI_EXTENSIONS,
         file_reader=lambda fileobj: ConfigObj(fileobj).dict(),
         string_reader=lambda strobj: ConfigObj(strobj.split("\n")).dict(),
+        validate=validate,
     )
     loader.load(
         filename=filename,
diff --git a/dynaconf/loaders/json_loader.py b/dynaconf/loaders/json_loader.py
index 72c1e34..69b5a48 100644
--- a/dynaconf/loaders/json_loader.py
+++ b/dynaconf/loaders/json_loader.py
@@ -16,7 +16,7 @@ except ImportError:  # pragma: no cover
     commentjson = None
 
 
-def load(obj, env=None, silent=True, key=None, filename=None):
+def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     Reads and loads in to "obj" a single key or all keys from source file.
 
@@ -43,6 +43,7 @@ def load(obj, env=None, silent=True, key=None, filename=None):
         extensions=JSON_EXTENSIONS,
         file_reader=file_reader,
         string_reader=string_reader,
+        validate=validate,
     )
     loader.load(
         filename=filename,
diff --git a/dynaconf/loaders/py_loader.py b/dynaconf/loaders/py_loader.py
index f296459..d4705b7 100644
--- a/dynaconf/loaders/py_loader.py
+++ b/dynaconf/loaders/py_loader.py
@@ -15,16 +15,25 @@ from dynaconf.utils import upperfy
 from dynaconf.utils.files import find_file
 
 
-def load(obj, settings_module, identifier="py", silent=False, key=None):
+def load(
+    obj,
+    settings_module,
+    identifier="py",
+    silent=False,
+    key=None,
+    validate=False,
+):
     """Tries to import a python module"""
     mod, loaded_from = get_module(obj, settings_module, silent)
     if not (mod and loaded_from):
         return
-    load_from_python_object(obj, mod, settings_module, key, identifier)
+    load_from_python_object(
+        obj, mod, settings_module, key, identifier, validate=validate
+    )
 
 
 def load_from_python_object(
-    obj, mod, settings_module, key=None, identifier=None
+    obj, mod, settings_module, key=None, identifier=None, validate=False
 ):
     file_merge = getattr(mod, "dynaconf_merge", False) or getattr(
         mod, "DYNACONF_MERGE", False
@@ -42,6 +51,7 @@ def load_from_python_object(
                     setting_value,
                     loader_identifier=identifier,
                     merge=file_merge,
+                    validate=validate,
                 )
 
     obj._loaded_py_modules.append(mod.__name__)
@@ -49,7 +59,7 @@ def load_from_python_object(
 
 
 def try_to_load_from_py_module_name(
-    obj, name, key=None, identifier="py", silent=False
+    obj, name, key=None, identifier="py", silent=False, validate=False
 ):
     """Try to load module by its string name.
 
@@ -66,7 +76,9 @@ def try_to_load_from_py_module_name(
 
     with ctx:
         mod = importlib.import_module(str(name))
-        load_from_python_object(obj, mod, name, key, identifier)
+        load_from_python_object(
+            obj, mod, name, key, identifier, validate=validate
+        )
         return True  # loaded ok!
     # if it reaches this point that means exception occurred, module not found.
     return False
diff --git a/dynaconf/loaders/redis_loader.py b/dynaconf/loaders/redis_loader.py
index 1123cb0..106e00c 100644
--- a/dynaconf/loaders/redis_loader.py
+++ b/dynaconf/loaders/redis_loader.py
@@ -13,7 +13,7 @@ except ImportError:
 IDENTIFIER = "redis"
 
 
-def load(obj, env=None, silent=True, key=None):
+def load(obj, env=None, silent=True, key=None, validate=False):
     """Reads and loads in to "settings" a single key or all keys from redis
 
     :param obj: the settings instance
@@ -43,14 +43,16 @@ def load(obj, env=None, silent=True, key=None):
                         value, tomlfy=True, box_settings=obj
                     )
                     if parsed_value:
-                        obj.set(key, parsed_value)
+                        obj.set(key, parsed_value, validate=validate)
             else:
                 data = {
                     key: parse_conf_data(value, tomlfy=True, box_settings=obj)
                     for key, value in redis.hgetall(holder.upper()).items()
                 }
                 if data:
-                    obj.update(data, loader_identifier=IDENTIFIER)
+                    obj.update(
+                        data, loader_identifier=IDENTIFIER, validate=validate
+                    )
         except Exception:
             if silent:
                 return False
diff --git a/dynaconf/loaders/toml_loader.py b/dynaconf/loaders/toml_loader.py
index f4f6e17..42db7b3 100644
--- a/dynaconf/loaders/toml_loader.py
+++ b/dynaconf/loaders/toml_loader.py
@@ -11,7 +11,7 @@ from dynaconf.vendor import toml  # Backwards compatibility with uiri/toml
 from dynaconf.vendor import tomllib  # New tomllib stdlib on py3.11
 
 
-def load(obj, env=None, silent=True, key=None, filename=None):
+def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     Reads and loads in to "obj" a single key or all keys from source file.
 
@@ -32,6 +32,7 @@ def load(obj, env=None, silent=True, key=None, filename=None):
             file_reader=tomllib.load,
             string_reader=tomllib.loads,
             opener_params={"mode": "rb"},
+            validate=validate,
         )
         loader.load(
             filename=filename,
@@ -60,6 +61,7 @@ def load(obj, env=None, silent=True, key=None, filename=None):
             extensions=TOML_EXTENSIONS,
             file_reader=toml.load,
             string_reader=toml.loads,
+            validate=validate,
         )
         loader.load(
             filename=filename,
diff --git a/dynaconf/loaders/vault_loader.py b/dynaconf/loaders/vault_loader.py
index d816ffc..b2eaea8 100644
--- a/dynaconf/loaders/vault_loader.py
+++ b/dynaconf/loaders/vault_loader.py
@@ -62,7 +62,7 @@ def get_client(obj):
     return client
 
 
-def load(obj, env=None, silent=None, key=None):
+def load(obj, env=None, silent=None, key=None, validate=False):
     """Reads and loads in to "settings" a single key or all keys from vault
 
     :param obj: the settings instance
@@ -125,9 +125,14 @@ def load(obj, env=None, silent=None, key=None):
                     data.get(key), tomlfy=True, box_settings=obj
                 )
                 if value:
-                    obj.set(key, value)
+                    obj.set(key, value, validate=validate)
             elif data:
-                obj.update(data, loader_identifier=IDENTIFIER, tomlfy=True)
+                obj.update(
+                    data,
+                    loader_identifier=IDENTIFIER,
+                    tomlfy=True,
+                    validate=validate,
+                )
         except Exception:
             if silent:
                 return False
diff --git a/dynaconf/loaders/yaml_loader.py b/dynaconf/loaders/yaml_loader.py
index 37b0b6c..c97a792 100644
--- a/dynaconf/loaders/yaml_loader.py
+++ b/dynaconf/loaders/yaml_loader.py
@@ -19,7 +19,7 @@ yaml.SafeDumper.yaml_representers[
 )
 
 
-def load(obj, env=None, silent=True, key=None, filename=None):
+def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     Reads and loads in to "obj" a single key or all keys from source file.
 
@@ -50,6 +50,7 @@ def load(obj, env=None, silent=True, key=None, filename=None):
         extensions=YAML_EXTENSIONS,
         file_reader=yaml_reader,
         string_reader=yaml_reader,
+        validate=validate,
     )
     loader.load(
         filename=filename,
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index 0b04973..a93a625 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -287,7 +287,7 @@ class Validator:
             # however we need to cast it
             # so we call .set again
             value = self.cast(settings.get(name))
-            settings.set(name, value)
+            settings.set(name, value, validate=False)
 
             # is there a callable condition?
             if self.condition is not None:


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 18363ba..0c5adf5 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -6,6 +6,8 @@ import pytest
 
 from dynaconf import Dynaconf
 from dynaconf import LazySettings
+from dynaconf import ValidationError
+from dynaconf import Validator
 from dynaconf.loaders import toml_loader
 from dynaconf.loaders import yaml_loader
 from dynaconf.strategies.filtering import PrefixFilter
@@ -1074,3 +1076,286 @@ def test_list_entries_from_yaml_should_not_duplicate_when_merged(tmpdir):
 
     assert settings.from_env("default").SOME_LIST == expected_default_value
     assert settings.from_env("other").SOME_LIST == expected_other_value
+
+
+# #712
+# introduce update(validate), set(validate) and load_file(validate)
+# and global VALIDATE_ON_UPDATE option
+
+# parametrize data to tests
+validate_on_update_data = (
+    pytest.param(
+        {"value_a": "foo"},  # valid data
+        {"value_b": "bar"},  # invalid data
+        id="simple-value",
+    ),
+    pytest.param(
+        {"value_a__nested": "foo"},
+        {"value_b__nested": "bar"},
+        id="dunder-value",
+    ),
+)
+
+
+@pytest.mark.parametrize("valid_data,invalid_data", validate_on_update_data)
+def test_update__validate_on_update_is_false(valid_data, invalid_data):
+    """
+    When `Dynaconf(validate_on_update=False)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf()  # validate_on_update default is false
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass
+    settings.update(invalid_data)
+    settings.update(invalid_data, validate="random")
+    settings.update(invalid_data, validate=123)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.update(invalid_data, validate=True)
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.update(valid_data, validate=True)
+
+
+@pytest.mark.parametrize("valid_data,invalid_data", validate_on_update_data)
+def test_update__validate_on_update_is_true(valid_data, invalid_data):
+    """
+    When `Dynaconf(validate_on_update=True)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf(validate_on_update=True)
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass
+    settings.update(invalid_data, validate=False)
+    settings.update(invalid_data, validate="random")
+    settings.update(invalid_data, validate=123)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.update(invalid_data)
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.update(valid_data)
+
+
+def test_update__validate_on_update_is_str_all():
+    """
+    When `Dynaconf(validate_on_update="all")`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf(validate_on_update="all")
+    settings.validators.register(Validator("value_a", must_exist=True))
+    settings.validators.register(Validator("value_int", eq=42))
+
+    # should bypass
+    settings.update({"value_b": "foo"}, validate=False)
+    settings.update({"value_b": "foo"}, validate="random")
+    settings.update({"value_b": "foo"}, validate=123)
+
+    # should raise and accumulate errors
+    with pytest.raises(ValidationError) as e:
+        settings.update({"value_b": "not_a", "value_int": 41})
+
+    assert len(e.value.details) == 2
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.update({"value_a": "exists", "value_int": 42})
+
+
+def test_set__validate_on_update_is_false():
+    """
+    When `Dynaconf(validate_on_update=False)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf()  # validate = false
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass
+    settings.set("value_b", "foo")
+    settings.set("value_b", "foo", validate="random")
+    settings.set("value_b", "foo", validate=123)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.set("value_b", "foo", validate=True)
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.set("value_a", "foo", validate=True)
+
+
+def test_set__validate_on_update_is_true():
+    """
+    When `Dynaconf(validate_on_update=True)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf(validate_on_update=True)
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass
+    settings.set("value_b__nested", "foo", validate=False)
+    settings.set("value_b__nested", "foo", validate="random")
+    settings.set("value_b__nested", "foo", validate=123)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.set("value_b__nested", "foo")
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.set("value_a__nested", "foo")
+
+
+def test_set__validate_on_update_is_str_all():
+    """
+    When `Dynaconf(validate_on_update="all")`
+    Should behave correctly (bypass, pass or raise)
+    """
+    settings = Dynaconf(validate_on_update="all")
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass
+    settings.set("value_b__nested", "foo", validate=False)
+    settings.set("value_b__nested", "foo", validate="random")
+    settings.set("value_b__nested", "foo", validate=123)
+
+    # should raise. "all" doesn't make difference here
+    with pytest.raises(ValidationError):
+        settings.set("value_b__nested", "foo")
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.set("value_a__nested", "foo")
+
+
+def mkfile(tmp_dir, filename: str, data: str):
+    """
+    Test utility function to create tmp files
+    """
+    file = tmp_dir.join(filename)
+    with open(file.strpath, "w", encoding="utf-8") as f:
+        f.write(data)
+    return file
+
+
+params__load_file__data = (
+    pytest.param(
+        {"fname": "valid.toml", "data": "value_a='foo'\nvalue_int=42"},
+        {"fname": "invalid.toml", "data": "value_b='foo'\nvalue_int=41"},
+        id="load-toml",
+    ),
+    pytest.param(
+        {"fname": "valid.ini", "data": "value_a='foo'\nvalue_int='@int 42'"},
+        {"fname": "invalid.ini", "data": "value_b='foo'\nvalue_int='@int 41'"},
+        id="load-ini",
+    ),
+    pytest.param(
+        {"fname": "valid.yaml", "data": "value_a: 'foo'\nvalue_int: 42"},
+        {"fname": "invalid.yaml", "data": "value_b: 'foo'\nvalue_int: 41"},
+        id="load-yaml",
+    ),
+    pytest.param(
+        {"fname": "valid.json", "data": '{"value_a":"foo",\n"value_int":42}'},
+        {
+            "fname": "invalid.json",
+            "data": '{"value_b":"foo",\n"value_int":41}',
+        },
+        id="load-json",
+    ),
+    pytest.param(
+        {"fname": "valid.py", "data": 'VALUE_A="foo"\nVALUE_INT=42'},
+        {"fname": "invalid.py", "data": 'VALUE_B="foo"\nVALUE_INT=41'},
+        id="load-py",
+    ),
+)
+
+
+@pytest.mark.parametrize("valid,invalid", params__load_file__data)
+def test_load_file__validate_on_update_is_false(tmpdir, valid, invalid):
+    """
+    When `Dynaconf(validate_on_update=False)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    # setup files
+    file_with_valid = mkfile(tmpdir, valid["fname"], valid["data"])
+    file_with_invalid = mkfile(tmpdir, invalid["fname"], invalid["data"])
+
+    # setup dyna
+    settings = Dynaconf()  # validate = false
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass when
+    settings.load_file(file_with_invalid)
+    settings.load_file(file_with_invalid, validate="random")
+    settings.load_file(file_with_invalid, validate=123)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.load_file(file_with_invalid, validate=True)
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.load_file(file_with_valid, validate=True)
+
+
+@pytest.mark.parametrize("valid,invalid", params__load_file__data)
+def test_load_file__validate_on_update_is_true(tmpdir, valid, invalid):
+    """
+    When `Dynaconf(validate_on_update=True)`
+    Should behave correctly (bypass, pass or raise)
+    """
+    # setup files
+    file_with_valid = mkfile(tmpdir, valid["fname"], valid["data"])
+    file_with_invalid = mkfile(tmpdir, invalid["fname"], invalid["data"])
+
+    # setup dyna
+    settings = Dynaconf(validate_on_update=True)
+    settings.validators.register(Validator("value_a", must_exist=True))
+
+    # should bypass when
+    settings.load_file(file_with_invalid, validate=False)
+
+    # should raise
+    with pytest.raises(ValidationError):
+        settings.load_file(file_with_invalid)
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.load_file(file_with_valid)
+
+
+@pytest.mark.parametrize("valid,invalid", params__load_file__data)
+def test_load_file__validate_on_update_is_str_all(tmpdir, valid, invalid):
+    """
+    When `Dynaconf(validate_on_update="all")`
+    Should behave correctly (bypass, pass or raise accumulating errors)
+    """
+    # setup files
+    file_with_valid = mkfile(tmpdir, valid["fname"], valid["data"])
+    file_with_invalid = mkfile(tmpdir, invalid["fname"], invalid["data"])
+
+    # setup dyna
+    settings = Dynaconf(validate_on_update="all")
+    settings.validators.register(Validator("value_a", must_exist=True))
+    settings.validators.register(Validator("value_int", eq=42))
+
+    # should bypass
+    settings.load_file(file_with_invalid, validate=False)
+    settings.load_file(file_with_invalid, validate="random")
+    settings.load_file(file_with_invalid, validate=123)
+
+    # should raise and accumulate errors
+    with pytest.raises(ValidationError) as e:
+        settings.load_file(file_with_invalid)
+
+    assert len(e.value.details) == 2
+
+    # should pass
+    assert not settings.exists("value_a")
+    settings.load_file(file_with_valid)