sloria: This opens up a path to something like:

```python
with Env.validated() as env:
    DEBUG= env.bool("DEBUG", False)
    TTL = env.int("TTL", validate=lambda x: x > 0)
```

Not sure if that's necessary or useful though. It becomes a little more awkward to do error handling that way.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5818c4c..740c221 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 6.1.0 (unreleased)
+
+Features:
+
+- Add deferred validation via the `eager` parameter and `env.seal()` ([#56](https://github.com/sloria/environs/issues/56)).
+  Thanks [robertlagrant](https://github.com/robertlagrant) for the suggestion.
+
 ## 6.0.0 (2019-09-22)
 
 Features:
diff --git a/README.md b/README.md
index 95b41eb..a5b7e11 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ It allows you to store configuration separate from your code, as per
 - [Handling prefixes](#handling-prefixes)
 - [Proxied variables](#proxied-variables)
 - [Validation](#validation)
+- [Deferred validation](#deferred-validation)
 - [Serialization](#serialization)
 - [Defining custom parser behavior](#defining-custom-parser-behavior)
 - [Usage with Flask](#usage-with-flask)
@@ -183,14 +184,17 @@ smtp_login = env("SMTP_LOGIN")  # =>'sloria'
 # export NODE_ENV='invalid'
 # export EMAIL='^_^'
 
+from environs import Env
+from marshmallow.validate import OneOf, Length, Email
+
+env = Env()
 
 # simple validator
 env.int("TTL", validate=lambda n: n > 0)
 # => Environment variable "TTL" invalid: ['Invalid value.']
 
-# using marshmallow validators
-from marshmallow.validate import OneOf
 
+# using marshmallow validators
 env.str(
     "NODE_ENV",
     validate=OneOf(
@@ -200,12 +204,41 @@ env.str(
 # => Environment variable "NODE_ENV" invalid: ['NODE_ENV must be one of: production, development']
 
 # multiple validators
-from marshmallow.validate import Length, Email
-
 env.str("EMAIL", validate=[Length(min=4), Email()])
 # => Environment variable "EMAIL" invalid: ['Shorter than minimum length 4.', 'Not a valid email address.']
 ```
 
+## Deferred validation
+
+By default, a validation error is raised immediately upon calling a parser method for an invalid environment variable.
+To defer validation and raise an exception with the combined error messages for all invalid variables, pass `eager=False` to `Env`.
+Call `env.seal()` after all variables have been parsed.
+
+```python
+# export TTL=-2
+# export NODE_ENV='invalid'
+# export EMAIL='^_^'
+
+from environs import Env
+from marshmallow.validate import OneOf, Email, Length, Range
+
+env = Env(eager=False)
+
+TTL = env.int("TTL", validate=Range(min=0, max=100))
+NODE_ENV = env.str(
+    "NODE_ENV",
+    validate=OneOf(
+        ["production", "development"], error="NODE_ENV must be one of: {choices}"
+    ),
+)
+EMAIL = env.str("EMAIL", validate=[Length(min=4), Email()])
+
+env.seal()
+# environs.EnvValidationError: Environment variables invalid: {'TTL': ['Must be greater than or equal to 0 and less than or equal to 100.'], 'NODE_ENV': ['NODE_ENV must be one of: production, development'], 'EMAIL': ['Shorter than minimum length 4.', 'Not a valid email address.']}
+```
+
+`env.seal()` validates all parsed variables and prevents further parsing (calling a parser method will raise an error).
+
 ## Serialization
 
 ```python
diff --git a/environs.py b/environs.py
index d762b21..251aaae 100644
--- a/environs.py
+++ b/environs.py
@@ -1,3 +1,4 @@
+import collections
 import contextlib
 import inspect
 import functools
@@ -24,6 +25,8 @@ _StrType = str
 _BoolType = bool
 _IntType = int
 
+ErrorMapping = typing.Mapping[str, typing.List[str]]
+ErrorList = typing.List[str]
 FieldFactory = typing.Callable[..., ma.fields.Field]
 Subcast = typing.Union[typing.Type, typing.Callable[..., _T]]
 FieldType = typing.Type[ma.fields.Field]
@@ -35,6 +38,16 @@ class EnvError(ValueError):
     """Raised when an environment variable or if a required environment variable is unset."""
 
 
+class EnvValidationError(EnvError):
+    def __init__(self, message: str, error_messages: typing.Union[ErrorList, ErrorMapping]):
+        self.error_messages = error_messages
+        super().__init__(message)
+
+
+class EnvSealedError(TypeError, EnvError):
+    pass
+
+
 class ParserConflictError(ValueError):
     """Raised when adding a custom parser that conflicts with a built-in parser method."""
 
@@ -45,6 +58,8 @@ def _field2method(
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: Subcast = None, **kwargs
     ) -> _T:
+        if self._sealed:
+            raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         missing = kwargs.pop("missing", None) or default
         if isinstance(field_or_factory, type) and issubclass(field_or_factory, ma.fields.Field):
             field = typing.cast(typing.Type[ma.fields.Field], field_or_factory)(missing=missing, **kwargs)
@@ -54,7 +69,11 @@ def _field2method(
         self._fields[parsed_key] = field
         source_key = proxied_key or parsed_key
         if raw_value is ma.missing and field.missing is ma.missing:
-            raise EnvError('Environment variable "{}" not set'.format(source_key))
+            message = "Environment variable not set."
+            if self.eager:
+                raise EnvValidationError('Environment variable "{}" not set'.format(source_key), [message])
+            else:
+                self._errors[parsed_key].append(message)
         if raw_value or raw_value == "":
             value = raw_value
         else:
@@ -64,12 +83,14 @@ def _field2method(
         try:
             value = field.deserialize(value)
         except ma.ValidationError as error:
-            raise EnvError(
-                'Environment variable "{}" invalid: {}'.format(source_key, error.args[0])
-            ) from error
+            if self.eager:
+                raise EnvValidationError(
+                    'Environment variable "{}" invalid: {}'.format(source_key, error.args[0]), error.messages
+                ) from error
+            self._errors[parsed_key].extend(error.messages)
         else:
             self._values[parsed_key] = value
-            return value
+        return value
 
     method.__name__ = method_name
     return method
@@ -79,6 +100,8 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: typing.Type = None, **kwargs
     ):
+        if self._sealed:
+            raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         parsed_key, raw_value, proxied_key = self._get_from_environ(name, default)
         if raw_value is ma.missing:
             raise EnvError('Environment variable "{}" not set'.format(proxied_key or parsed_key))
@@ -205,11 +228,14 @@ class Env:
     dj_db_url = _func2method(_dj_db_url_parser, "dj_db_url")
     dj_email_url = _func2method(_dj_email_url_parser, "dj_email_url")
 
-    def __init__(self):
+    def __init__(self, *, eager: _BoolType = True):
+        self.eager = eager
+        self._sealed = False  # type: bool
         self._fields = {}  # type: typing.Dict[_StrType, ma.fields.Field]
         self._values = {}  # type: typing.Dict[_StrType, typing.Any]
+        self._errors = collections.defaultdict(list)  # type: ErrorMapping
         self._prefix = None  # type: typing.Optional[_StrType]
-        self.__custom_parsers__ = {}
+        self.__custom_parsers__ = {}  # type: typing.Dict[_StrType, ParserMethod]
 
     def __repr__(self) -> _StrType:
         return "<{} {}>".format(self.__class__.__name__, self._values)
@@ -269,6 +295,19 @@ class Env:
             self._prefix = None
         self._prefix = old_prefix
 
+    def seal(self):
+        """Validate parsed values and prevent new values from being added.
+
+        :raises: environs.EnvValidationError
+        """
+        self._sealed = True
+        if self._errors:
+            error_messages = dict(self._errors)
+            self._errors = {}
+            raise EnvValidationError(
+                "Environment variables invalid: {}".format(error_messages), error_messages
+            )
+
     def __getattr__(self, name: _StrType, **kwargs):
         try:
             return functools.partial(self.__custom_parsers__[name], self)
diff --git a/examples/deferred_validation_example.py b/examples/deferred_validation_example.py
new file mode 100644
index 0000000..3042e03
--- /dev/null
+++ b/examples/deferred_validation_example.py
@@ -0,0 +1,24 @@
+import os
+from pprint import pprint
+
+from environs import Env, EnvValidationError
+from marshmallow.validate import OneOf, Email, Length, Range
+
+
+os.environ["TTL"] = "-2"
+os.environ["NODE_ENV"] = "invalid"
+os.environ["EMAIL"] = "^_^"
+
+
+env = Env(eager=False)
+TTL = env.int("TTL", validate=Range(min=0, max=100))
+NODE_ENV = env.str(
+    "NODE_ENV", validate=OneOf(["production", "development"], error="NODE_ENV must be one of: {choices}")
+)
+EMAIL = env.str("EMAIL", validate=[Length(min=4), Email()])
+
+# This will raise an error with the combined validation messages
+try:
+    env.seal()
+except EnvValidationError as error:
+    pprint(error.error_messages)
diff --git a/setup.py b/setup.py
index 6113a24..dbd695f 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ DJANGO_REQUIRES = ["dj-database-url", "dj-email-url"]
 EXTRAS_REQUIRE = {
     "django": DJANGO_REQUIRES,
     "tests": ["pytest"] + DJANGO_REQUIRES,
-    "lint": ["flake8==3.7.8", "flake8-bugbear==19.8.0", "pre-commit~=1.18"],
+    "lint": ["flake8==3.7.9", "flake8-bugbear==19.8.0", "pre-commit~=1.18"],
 }
 EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
 PYTHON_REQUIRES = ">=3.5"

Validation only happens one field at a time
Currently, with the following code:

```
from environs import Env
env = Env()
var1 = env("VAR1")
var2 = env("VAR2")
```

If both env vars are missing, environs will simply display:
```
environs.EnvError: Environment variable "VAR1" not set
```

And only when that's set will it display the second error.

It'd be great to have it validate as far as possible all the required environment variables, perhaps with a manual flagging of when to start and stop such a process:

```
from environs import Env
env = Env(eager=False)
var1 = env("VAR1")
var2 = env("VAR2")
var3 = env.int("VAR3")
env.read()

environs.EnvError: Environment variables invalid: {"VAR1": "missing", "VAR2": "missing", "VAR3": "wrong type"]. 
```
diff --git a/tests/test_environs.py b/tests/test_environs.py
index bc5d284..29c864b 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -56,9 +56,10 @@ class TestCasting:
 
     def test_invalid_int(self, set_env, env):
         set_env({"INT": "invalid"})
-        with pytest.raises(environs.EnvError) as excinfo:
+        with pytest.raises(environs.EnvError, match='Environment variable "INT" invalid') as excinfo:
             env.int("INT")
-        assert 'Environment variable "INT" invalid' in excinfo.value.args[0]
+        exc = excinfo.value
+        assert "Not a valid integer." in exc.error_messages
 
     def test_float_cast(self, set_env, env):
         set_env({"FLOAT": "33.3"})
@@ -535,3 +536,51 @@ class TestDjango:
         set_env({"EMAIL_URL": email_url})
         res = env.dj_email_url("EMAIL_URL")
         assert res == dj_email_url.parse(email_url)
+
+
+class TestDeferredValidation:
+    @pytest.fixture
+    def env(self):
+        return environs.Env(eager=False)
+
+    def test_valid(self, env, set_env):
+        set_env({"STR": "foo", "INT": "42"})
+        str_val = env.str("STR")
+        int_val = env.int("INT")
+        env.seal()
+        assert str_val == "foo"
+        assert int_val == 42
+
+    def test_validation(self, env, set_env):
+        set_env({"INT": "invalid", "DTIME": "notadatetime"})
+        env.int("INT")
+        env.datetime("DTIME")
+        env.str("REQUIRED")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        msg = exc.args[0]
+        assert "REQUIRED" in msg
+        assert "INT" in msg
+        assert "DTIME" in msg
+        assert "REQUIRED" in exc.error_messages
+        assert "INT" in exc.error_messages
+        assert "DTIME" in exc.error_messages
+
+    def test_cannot_add_after_seal(self, env, set_env):
+        set_env({"STR": "foo", "INT": "42"})
+        env.str("STR")
+        env.seal()
+        with pytest.raises(environs.EnvSealedError, match="Env has already been sealed"):
+            env.int("INT")
+
+    def test_custom_parser_not_called_after_seal(self, env, set_env):
+        set_env({"URL": "test.test/"})
+
+        @env.parser_for("https_url")
+        def https_url(value):
+            return "https://" + value
+
+        env.seal()
+        with pytest.raises(environs.EnvSealedError, match="Env has already been sealed"):
+            env.https_url("URL")

pip install -e .[dev]
parse_log_pytest
pytest
pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
asgiref==3.8.1
aspy.yaml==1.3.0
attrs==25.3.0
cachetools==5.5.2
cfgv==3.4.0
chardet==5.2.0
colorama==0.4.6
distlib==0.3.9
dj-database-url==2.3.0
dj-email-url==1.0.6
Django==4.2.20
entrypoints==0.3
-e git+https://github.com/sloria/environs.git@3ba208e4016461b45661d51da1cc02ed9726a6f6#egg=environs
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
filelock==3.18.0
flake8==3.7.8
flake8-bugbear==19.8.0
identify==2.6.9
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
marshmallow==3.26.1
mccabe==0.6.1
nodeenv==1.9.1
packaging @ file:///croot/packaging_1734472117206/work
platformdirs==4.3.7
pluggy @ file:///croot/pluggy_1733169602837/work
pre-commit==1.21.0
pycodestyle==2.5.0
pyflakes==2.1.1
pyproject-api==1.9.0
pytest @ file:///croot/pytest_1738938843180/work
python-dotenv==1.1.0
PyYAML==6.0.2
six==1.17.0
sqlparse==0.5.3
toml==0.10.2
tomli==2.2.1
tox==4.25.0
typing_extensions==4.13.0
virtualenv==20.29.3

name: environs
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - asgiref==3.8.1
      - aspy-yaml==1.3.0
      - attrs==25.3.0
      - cachetools==5.5.2
      - cfgv==3.4.0
      - chardet==5.2.0
      - colorama==0.4.6
      - distlib==0.3.9
      - dj-database-url==2.3.0
      - dj-email-url==1.0.6
      - django==4.2.20
      - entrypoints==0.3
      - environs==6.0.0
      - filelock==3.18.0
      - flake8==3.7.8
      - flake8-bugbear==19.8.0
      - identify==2.6.9
      - marshmallow==3.26.1
      - mccabe==0.6.1
      - nodeenv==1.9.1
      - platformdirs==4.3.7
      - pre-commit==1.21.0
      - pycodestyle==2.5.0
      - pyflakes==2.1.1
      - pyproject-api==1.9.0
      - python-dotenv==1.1.0
      - pyyaml==6.0.2
      - six==1.17.0
      - sqlparse==0.5.3
      - toml==0.10.2
      - tomli==2.2.1
      - tox==4.25.0
      - typing-extensions==4.13.0
      - virtualenv==20.29.3
prefix: /opt/conda/envs/environs

tests/test_environs.py::TestCasting::test_invalid_int tests/test_environs.py::TestDeferredValidation::test_valid tests/test_environs.py::TestDeferredValidation::test_validation tests/test_environs.py::TestDeferredValidation::test_cannot_add_after_seal tests/test_environs.py::TestDeferredValidation::test_custom_parser_not_called_after_seal
tests/test_environs.py::TestEnvFileReading::test_read_env_not_found_with_verbose_warns
tests/test_environs.py::TestCasting::test_call tests/test_environs.py::TestCasting::test_call_with_default tests/test_environs.py::TestCasting::test_basic tests/test_environs.py::TestCasting::test_empty_str tests/test_environs.py::TestCasting::test_int_cast tests/test_environs.py::TestCasting::test_float_cast tests/test_environs.py::TestCasting::test_list_cast tests/test_environs.py::TestCasting::test_list_with_default_from_string tests/test_environs.py::TestCasting::test_list_with_default_from_list tests/test_environs.py::TestCasting::test_list_with_subcast tests/test_environs.py::TestCasting::test_bool tests/test_environs.py::TestCasting::test_list_with_spaces tests/test_environs.py::TestCasting::test_dict tests/test_environs.py::TestCasting::test_dict_with_subcast tests/test_environs.py::TestCasting::test_dict_with_default_from_string tests/test_environs.py::TestCasting::test_dict_with_default_from_dict tests/test_environs.py::TestCasting::test_decimal_cast tests/test_environs.py::TestCasting::test_missing_raises_error tests/test_environs.py::TestCasting::test_default_set tests/test_environs.py::TestCasting::test_json_cast tests/test_environs.py::TestCasting::test_datetime_cast tests/test_environs.py::TestCasting::test_date_cast tests/test_environs.py::TestCasting::test_timedelta_cast tests/test_environs.py::TestCasting::test_uuid_cast tests/test_environs.py::TestCasting::test_url_cast tests/test_environs.py::TestCasting::test_path_cast tests/test_environs.py::TestCasting::test_log_level_cast tests/test_environs.py::TestCasting::test_invalid_log_level tests/test_environs.py::TestCasting::test_invalid_url[foo] tests/test_environs.py::TestCasting::test_invalid_url[42] tests/test_environs.py::TestCasting::test_invalid_url[foo@bar] tests/test_environs.py::TestProxiedVariables::test_reading_proxied_variable tests/test_environs.py::TestProxiedVariables::test_reading_missing_proxied_variable tests/test_environs.py::TestProxiedVariables::test_reading_proxied_variable_in_prefix_scope tests/test_environs.py::TestEnvFileReading::test_read_env tests/test_environs.py::TestEnvFileReading::test_read_env_recurse tests/test_environs.py::TestEnvFileReading::test_read_env_non_recurse tests/test_environs.py::TestValidation::test_can_add_validator tests/test_environs.py::TestValidation::test_can_add_marshmallow_validator tests/test_environs.py::TestValidation::test_validator_can_raise_enverror tests/test_environs.py::TestValidation::test_failed_vars_are_not_serialized tests/test_environs.py::TestCustomTypes::test_add_parser tests/test_environs.py::TestCustomTypes::test_cannot_override_built_in_parser tests/test_environs.py::TestCustomTypes::test_parser_for tests/test_environs.py::TestCustomTypes::test_parser_function_can_take_extra_arguments tests/test_environs.py::TestCustomTypes::test_add_parser_from_field tests/test_environs.py::TestDumping::test_dump tests/test_environs.py::TestDumping::test_env_with_custom_parser tests/test_environs.py::test_repr tests/test_environs.py::test_str tests/test_environs.py::test_env_isolation tests/test_environs.py::TestPrefix::test_prefixed tests/test_environs.py::TestPrefix::test_dump_with_prefixed tests/test_environs.py::TestPrefix::test_error_message_for_prefixed_var tests/test_environs.py::TestNestedPrefix::test_nested_prefixed tests/test_environs.py::TestNestedPrefix::test_dump_with_nested_prefixed tests/test_environs.py::TestFailedNestedPrefix::test_failed_nested_prefixed tests/test_environs.py::TestFailedNestedPrefix::test_failed_dump_with_nested_prefixed tests/test_environs.py::TestDjango::test_dj_db_url tests/test_environs.py::TestDjango::test_dj_db_url_passes_kwargs tests/test_environs.py::TestDjango::test_dj_email_url
setup.py examples/deferred_validation_example.py environs.py CHANGELOG.md README.md
setup.py examples/deferred_validation_example.py environs.py CHANGELOG.md README.md