diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 138d5f1..26589ca 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,10 @@ Changelog
 
 Features:
 
+* Add `Parser.pre_load` as a method for allowing users to modify data before
+  schema loading, but without redefining location loaders. See advanced docs on
+  `Parser pre_load` for usage information
+
 * ``unknown`` defaults to `None` for body locations (`json`, `form` and
   `json_or_form`) (:issue:`580`).
 
diff --git a/docs/advanced.rst b/docs/advanced.rst
index 50ad01d..853fd64 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -435,6 +435,50 @@ To add your own parser, extend :class:`Parser <webargs.core.Parser>` and impleme
             structure_dict_pair(r, k, v)
         return r
 
+Parser pre_load
+---------------
+
+Similar to ``@pre_load`` decorated hooks on marshmallow Schemas,
+:class:`Parser <webargs.core.Parser>` classes define a method,
+`pre_load <webargs.core.Parser.pre_load>` which can
+be overridden to provide per-parser transformations of data.
+The only way to make use of `pre_load <webargs.core.Parser.pre_load>` is to
+subclass a :class:`Parser <webargs.core.Parser>` and provide an
+implementation.
+
+`pre_load <webargs.core.Parser.pre_load>` is given the data fetched from a
+location, the schema which will be used, the request object, and the location
+name which was requested. For example, to define a ``FlaskParser`` which strips
+whitespace from ``form`` and ``query`` data, one could write the following:
+
+.. code-block:: python
+
+    from webargs.flaskparser import FlaskParser
+    import typing
+
+
+    def _strip_whitespace(value):
+        if isinstance(value, str):
+            value = value.strip()
+        elif isinstance(value, typing.Mapping):
+            return {k: _strip_whitespace(value[k]) for k in value}
+        elif isinstance(value, (list, tuple)):
+            return type(value)(map(_strip_whitespace, value))
+        return value
+
+
+    class WhitspaceStrippingFlaskParser(FlaskParser):
+        def pre_load(self, location_data, *, schema, req, location):
+            if location in ("query", "form"):
+                return _strip_whitespace(location_data)
+            return location_data
+
+Note that `Parser.pre_load <webargs.core.Parser.pre_load>` is run after location
+loading but before ``Schema.load`` is called. It can therefore be called on
+multiple types of mapping objects, including
+:class:`MultiDictProxy <webargs.MultiDictProxy>`, depending on what the
+location loader returns.
+
 Returning HTTP 400 Responses
 ----------------------------
 
diff --git a/src/webargs/core.py b/src/webargs/core.py
index 3752479..e675d77 100644
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -322,7 +322,10 @@ class Parser:
             location_data = self._load_location_data(
                 schema=schema, req=req, location=location
             )
-            data = schema.load(location_data, **load_kwargs)
+            preprocessed_data = self.pre_load(
+                location_data, schema=schema, req=req, location=location
+            )
+            data = schema.load(preprocessed_data, **load_kwargs)
             self._validate_arguments(data, validators)
         except ma.exceptions.ValidationError as error:
             self._on_validation_error(
@@ -523,6 +526,15 @@ class Parser:
         self.error_callback = func
         return func
 
+    def pre_load(
+        self, location_data: Mapping, *, schema: ma.Schema, req: Request, location: str
+    ) -> Mapping:
+        """A method of the parser which can transform data after location
+        loading is done. By default it does nothing, but users can subclass
+        parsers and override this method.
+        """
+        return location_data
+
     def _handle_invalid_json_error(
         self,
         error: typing.Union[json.JSONDecodeError, UnicodeDecodeError],

diff --git a/tests/test_core.py b/tests/test_core.py
index 1219659..48b8ca6 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,4 +1,5 @@
 import datetime
+import typing
 from unittest import mock
 
 import pytest
@@ -37,6 +38,9 @@ class MockRequestParser(Parser):
     def load_querystring(self, req, schema):
         return self._makeproxy(req.query, schema)
 
+    def load_form(self, req, schema):
+        return MultiDictProxy(req.form, schema)
+
     def load_json(self, req, schema):
         return req.json
 
@@ -1224,3 +1228,84 @@ def test_custom_default_schema_class(load_json, web_request):
     p = CustomParser()
     ret = p.parse(argmap, web_request)
     assert ret == {"value": "hello world"}
+
+
+def test_parser_pre_load(web_request):
+    class CustomParser(MockRequestParser):
+        # pre-load hook to strip whitespace from query params
+        def pre_load(self, data, *, schema, req, location):
+            if location == "query":
+                return {k: v.strip() for k, v in data.items()}
+            return data
+
+    parser = CustomParser()
+
+    # mock data for both query and json
+    web_request.query = web_request.json = {"value": " hello "}
+    argmap = {"value": fields.Str()}
+
+    # data gets through for 'json' just fine
+    ret = parser.parse(argmap, web_request)
+    assert ret == {"value": " hello "}
+
+    # but for 'query', the pre_load hook changes things
+    ret = parser.parse(argmap, web_request, location="query")
+    assert ret == {"value": "hello"}
+
+
+# this test is meant to be a run of the WhitspaceStrippingFlaskParser we give
+# in the docs/advanced.rst examples for how to use pre_load
+# this helps ensure that the example code is correct
+# rather than a FlaskParser, we're working with the mock parser, but it's
+# otherwise the same
+def test_whitespace_stripping_parser_example(web_request):
+    def _strip_whitespace(value):
+        if isinstance(value, str):
+            value = value.strip()
+        elif isinstance(value, typing.Mapping):
+            return {k: _strip_whitespace(value[k]) for k in value}
+        elif isinstance(value, (list, tuple)):
+            return type(value)(map(_strip_whitespace, value))
+        return value
+
+    class WhitspaceStrippingParser(MockRequestParser):
+        def pre_load(self, location_data, *, schema, req, location):
+            if location in ("query", "form"):
+                ret = _strip_whitespace(location_data)
+                return ret
+            return location_data
+
+    parser = WhitspaceStrippingParser()
+
+    # mock data for query, form, and json
+    web_request.form = web_request.query = web_request.json = {"value": " hello "}
+    argmap = {"value": fields.Str()}
+
+    # data gets through for 'json' just fine
+    ret = parser.parse(argmap, web_request)
+    assert ret == {"value": " hello "}
+
+    # but for 'query' and 'form', the pre_load hook changes things
+    for loc in ("query", "form"):
+        ret = parser.parse(argmap, web_request, location=loc)
+        assert ret == {"value": "hello"}
+
+    # check that it applies in the case where the field is a list type
+    # applied to an argument (logic for `tuple` is effectively the same)
+    web_request.form = web_request.query = web_request.json = {
+        "ids": [" 1", "3", " 4"],
+        "values": [" foo  ", " bar"],
+    }
+    schema = Schema.from_dict(
+        {"ids": fields.List(fields.Int), "values": fields.List(fields.Str)}
+    )
+    for loc in ("query", "form"):
+        ret = parser.parse(schema, web_request, location=loc)
+        assert ret == {"ids": [1, 3, 4], "values": ["foo", "bar"]}
+
+    # json loading should also work even though the pre_load hook above
+    # doesn't strip whitespace from JSON data
+    #   - values=[" foo  ", ...]  will have whitespace preserved
+    #   - ids=[" 1", ...]  will still parse okay because "  1" is valid for fields.Int
+    ret = parser.parse(schema, web_request, location="json")
+    assert ret == {"ids": [1, 3, 4], "values": [" foo  ", " bar"]}

Automatically trim leading/trailing whitespace from argument values
Does webargs provide any clean way to do this? I guess leading/trailing whitespace are almost never something you want (especially when having required fields that must not be empty)...
tests/test_core.py::test_parser_pre_load tests/test_core.py::test_whitespace_stripping_parser_example
tests/test_core.py::test_load_json_called_by_parse_default tests/test_core.py::test_load_nondefault_called_by_parse_with_location[querystring] tests/test_core.py::test_load_nondefault_called_by_parse_with_location[form] tests/test_core.py::test_load_nondefault_called_by_parse_with_location[headers] tests/test_core.py::test_load_nondefault_called_by_parse_with_location[cookies] tests/test_core.py::test_load_nondefault_called_by_parse_with_location[files] tests/test_core.py::test_parse tests/test_core.py::test_parse_with_unknown_behavior_specified[schema_instance] tests/test_core.py::test_parse_with_unknown_behavior_specified[parse_call] tests/test_core.py::test_parse_with_unknown_behavior_specified[parser_default] tests/test_core.py::test_parse_with_unknown_behavior_specified[parser_class_default] tests/test_core.py::test_parse_with_explicit_unknown_overrides_schema tests/test_core.py::test_parse_with_default_unknown_cleared_uses_schema_value[custom_class] tests/test_core.py::test_parse_with_default_unknown_cleared_uses_schema_value[instance_setting] tests/test_core.py::test_parse_with_default_unknown_cleared_uses_schema_value[both] tests/test_core.py::test_parse_required_arg_raises_validation_error tests/test_core.py::test_arg_not_required_excluded_in_parsed_output tests/test_core.py::test_arg_allow_none tests/test_core.py::test_parse_required_arg tests/test_core.py::test_parse_required_list tests/test_core.py::test_parse_list_allow_none tests/test_core.py::test_parse_list_dont_allow_none tests/test_core.py::test_parse_empty_list tests/test_core.py::test_parse_missing_list tests/test_core.py::test_default_location tests/test_core.py::test_missing_with_default tests/test_core.py::test_default_can_be_none tests/test_core.py::test_arg_with_default_and_location tests/test_core.py::test_value_error_raised_if_parse_called_with_invalid_location tests/test_core.py::test_handle_error_called_when_parsing_raises_error tests/test_core.py::test_handle_error_reraises_errors tests/test_core.py::test_location_as_init_argument tests/test_core.py::test_custom_error_handler tests/test_core.py::test_custom_error_handler_decorator tests/test_core.py::test_custom_error_handler_must_reraise tests/test_core.py::test_custom_location_loader tests/test_core.py::test_custom_location_loader_with_data_key tests/test_core.py::test_full_input_validation tests/test_core.py::test_full_input_validation_with_multiple_validators tests/test_core.py::test_required_with_custom_error tests/test_core.py::test_required_with_custom_error_and_validation_error tests/test_core.py::test_full_input_validator_receives_nonascii_input tests/test_core.py::test_invalid_argument_for_validate tests/test_core.py::test_multidict_proxy[input_dict0] tests/test_core.py::test_multidict_proxy[input_dict1] tests/test_core.py::test_multidict_proxy[input_dict2] tests/test_core.py::test_parse_with_data_key tests/test_core.py::test_parse_with_data_key_retains_field_name_in_error tests/test_core.py::test_parse_nested_with_data_key tests/test_core.py::test_parse_nested_with_missing_key_and_data_key tests/test_core.py::test_parse_nested_with_default tests/test_core.py::test_nested_many tests/test_core.py::test_use_args tests/test_core.py::test_use_args_stacked tests/test_core.py::test_use_kwargs_stacked tests/test_core.py::test_decorators_dont_change_docstring[use_args] tests/test_core.py::test_decorators_dont_change_docstring[use_kwargs] tests/test_core.py::test_list_allowed_missing tests/test_core.py::test_int_list_allowed_missing tests/test_core.py::test_multiple_arg_required_with_int_conversion tests/test_core.py::test_parse_with_callable tests/test_core.py::test_use_args_callable tests/test_core.py::TestPassingSchema::test_passing_schema_to_parse tests/test_core.py::TestPassingSchema::test_use_args_can_be_passed_a_schema tests/test_core.py::TestPassingSchema::test_passing_schema_factory_to_parse tests/test_core.py::TestPassingSchema::test_use_args_can_be_passed_a_schema_factory tests/test_core.py::TestPassingSchema::test_use_kwargs_can_be_passed_a_schema tests/test_core.py::TestPassingSchema::test_use_kwargs_can_be_passed_a_schema_factory tests/test_core.py::TestPassingSchema::test_use_kwargs_stacked tests/test_core.py::TestPassingSchema::test_parse_does_not_add_missing_values_to_schema_validator tests/test_core.py::test_use_args_with_custom_location_in_parser tests/test_core.py::test_use_kwargs tests/test_core.py::test_use_kwargs_with_arg_missing tests/test_core.py::test_delimited_list_default_delimiter tests/test_core.py::test_delimited_tuple_default_delimiter tests/test_core.py::test_delimited_tuple_incorrect_arity tests/test_core.py::test_delimited_list_with_datetime tests/test_core.py::test_delimited_list_custom_delimiter tests/test_core.py::test_delimited_tuple_custom_delimiter tests/test_core.py::test_delimited_list_load_list_errors tests/test_core.py::test_delimited_tuple_load_list_errors tests/test_core.py::test_delimited_list_passed_invalid_type tests/test_core.py::test_delimited_tuple_passed_invalid_type tests/test_core.py::test_missing_list_argument_not_in_parsed_result tests/test_core.py::test_type_conversion_with_multiple_required tests/test_core.py::test_is_multiple_detection[is_multiple_true-input_dict0] tests/test_core.py::test_is_multiple_detection[is_multiple_true-input_dict1] tests/test_core.py::test_is_multiple_detection[is_multiple_true-input_dict2] tests/test_core.py::test_is_multiple_detection[is_multiple_false-input_dict0] tests/test_core.py::test_is_multiple_detection[is_multiple_false-input_dict1] tests/test_core.py::test_is_multiple_detection[is_multiple_false-input_dict2] tests/test_core.py::test_is_multiple_detection[is_multiple_notset-input_dict0] tests/test_core.py::test_is_multiple_detection[is_multiple_notset-input_dict1] tests/test_core.py::test_is_multiple_detection[is_multiple_notset-input_dict2] tests/test_core.py::test_is_multiple_detection[list_field-input_dict0] tests/test_core.py::test_is_multiple_detection[list_field-input_dict1] tests/test_core.py::test_is_multiple_detection[list_field-input_dict2] tests/test_core.py::test_is_multiple_detection[tuple_field-input_dict0] tests/test_core.py::test_is_multiple_detection[tuple_field-input_dict1] tests/test_core.py::test_is_multiple_detection[tuple_field-input_dict2] tests/test_core.py::test_is_multiple_detection[added_to_known-input_dict0] tests/test_core.py::test_is_multiple_detection[added_to_known-input_dict1] tests/test_core.py::test_is_multiple_detection[added_to_known-input_dict2] tests/test_core.py::test_validation_errors_in_validator_are_passed_to_handle_error tests/test_core.py::test_parse_basic tests/test_core.py::test_parse_raises_validation_error_if_data_invalid tests/test_core.py::test_nested_field_from_dict tests/test_core.py::test_is_json tests/test_core.py::test_get_mimetype tests/test_core.py::test_parse_with_error_status_code_and_headers tests/test_core.py::test_custom_schema_class tests/test_core.py::test_custom_default_schema_class