diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3eb6b9c..310c851 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,21 +1,26 @@
 repos:
-- repo: https://github.com/python/black
-  rev: 19.3b0
+- repo: https://github.com/asottile/pyupgrade
+  rev: v2.7.3
+  hooks:
+  - id: pyupgrade
+    args: ["--py36-plus"]
+- repo: https://github.com/psf/black
+  rev: 20.8b1
   hooks:
   - id: black
-    language_version: python3
 - repo: https://gitlab.com/pycqa/flake8
-  rev: 3.7.8
+  rev: 3.8.4
   hooks:
   - id: flake8
-    additional_dependencies: ['flake8-bugbear==19.8.0; python_version >= "3.5"']
+    additional_dependencies: [flake8-bugbear==20.1.0]
 - repo: https://github.com/asottile/blacken-docs
-  rev: v1.3.0
+  rev: v1.8.0
   hooks:
   - id: blacken-docs
-    additional_dependencies: [black==19.3b0]
+    additional_dependencies: [black==20.8b1]
+    args: ["--target-version", "py35"]
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v0.730
+  rev: v0.790
   hooks:
   - id: mypy
     language_version: python3
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 75bf21b..67e1c85 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -3,7 +3,7 @@ sphinx:
   configuration: docs/conf.py
 formats: all
 python:
-  version: 3.7
+  version: 3.8
   install:
     - method: pip
       path: .
diff --git a/AUTHORS.rst b/AUTHORS.rst
index b1c8efd..2e15b92 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -5,39 +5,49 @@ Authors
 Lead
 ----
 
-* Steven Loria <sloria1@gmail.com>
-* Jérôme Lafréchoux <https://github.com/lafrech>
+* Steven Loria `@sloria <https://github.com/sloria>`_
+* Jérôme Lafréchoux `@lafrech <https://github.com/lafrech>`_
 
 Contributors (chronological)
 ----------------------------
 
-* @venuatu <https://github.com/venuatu>
-* Javier Santacruz @jvrsantacruz <javier.santacruz.lc@gmail.com>
-* Josh Carp <https://github.com/jmcarp>
-* @philtay <https://github.com/philtay>
-* Andriy Yurchuk <https://github.com/Ch00k>
-* Stas Sușcov <https://github.com/stas>
-* Josh Johnston <https://github.com/Trii>
-* Rory Hart <https://github.com/hartror>
-* Jace Browning <https://github.com/jacebrowning>
-* @marcellarius <https://github.com/marcellarius>
-* Damian Heard <https://github.com/DamianHeard>
-* Daniel Imhoff <https://github.com/dwieeb>
-* immerrr <https://github.com/immerrr>
-* Brett Higgins <https://github.com/brettdh>
-* Vlad Frolov <https://github.com/frol>
-* Tuukka Mustonen <https://github.com/tuukkamustonen>
-* Francois-Xavier Darveau <https://github.com/EFF>
-* Jérôme Lafréchoux <https://github.com/lafrech>
-* @DmitriyS <https://github.com/DmitriyS>
-* Svetlozar Argirov <https://github.com/zaro>
-* Florian S. <https://github.com/nebularazer>
-* @daniel98321 <https://github.com/daniel98321>
-* @Itayazolay <https://github.com/Itayazolay>
-* @Reskov <https://github.com/Reskov>
-* @cedzz <https://github.com/cedzz>
-* F. Moukayed (כוכב) <https://github.com/kochab>
-* Xiaoyu Lee <https://github.com/lee3164>
-* Jonathan Angelo <https://github.com/jangelo>
-* @zhenhua32 <https://github.com/zhenhua32>
-* Martin Roy <https://github.com/lindycoder>
+* Steven Manuatu `@venuatu <https://github.com/venuatu>`_
+* Javier Santacruz `@jvrsantacruz <https://github.com/jvrsantacruz>`_
+* Josh Carp `@jmcarp <https://github.com/jmcarp>`_
+* `@philtay <https://github.com/philtay>`_
+* Andriy Yurchuk `@Ch00k <https://github.com/Ch00k>`_
+* Stas Sușcov `@stas <https://github.com/stas>`_
+* Josh Johnston `@Trii <https://github.com/Trii>`_
+* Rory Hart `@hartror <https://github.com/hartror>`_
+* Jace Browning `@jacebrowning <https://github.com/jacebrowning>`_
+* marcellarius `@marcellarius <https://github.com/marcellarius>`_
+* Damian Heard `@DamianHeard <https://github.com/DamianHeard>`_
+* Daniel Imhoff `@dwieeb <https://github.com/dwieeb>`_
+* `@immerrr <https://github.com/immerrr>`_
+* Brett Higgins `@brettdh <https://github.com/brettdh>`_
+* Vlad Frolov `@frol <https://github.com/frol>`_
+* Tuukka Mustonen `@tuukkamustonen <https://github.com/tuukkamustonen>`_
+* Francois-Xavier Darveau `@EFF <https://github.com/EFF>`_
+* Jérôme Lafréchoux `@lafrech <https://github.com/lafrech>`_
+* `@DmitriyS <https://github.com/DmitriyS>`_
+* Svetlozar Argirov `@zaro <https://github.com/zaro>`_
+* Florian S. `@nebularazer <https://github.com/nebularazer>`_
+* `@daniel98321 <https://github.com/daniel98321>`_
+* `@Itayazolay <https://github.com/Itayazolay>`_
+* `@Reskov <https://github.com/Reskov>`_
+* `@cedzz <https://github.com/cedzz>`_
+* F. Moukayed (כוכב) `@kochab <https://github.com/kochab>`_
+* Xiaoyu Lee `@lee3164 <https://github.com/lee3164>`_
+* Jonathan Angelo `@jangelo <https://github.com/jangelo>`_
+* `@zhenhua32 <https://github.com/zhenhua32>`_
+* Martin Roy `@lindycoder <https://github.com/lindycoder>`_
+* Kubilay Kocak `@koobs <https://github.com/koobs>`_
+* Stephen Rosen `@sirosen <https://github.com/sirosen>`_
+* `@dodumosu <https://github.com/dodumosu>`_
+* Nate Dellinger `@Nateyo <https://github.com/Nateyo>`_
+* Karthikeyan Singaravelan `@tirkarthi <https://github.com/tirkarthi>`_
+* Sami Salonen `@suola <https://github.com/suola>`_
+* Tim Gates `@timgates42 <https://github.com/timgates42>`_
+* Lefteris Karapetsas `@lefterisjp <https://github.com/lefterisjp>`_
+* Utku Gultopu `@ugultopu <https://github.com/ugultopu>`_
+* Jason Williams `@jaswilli <https://github.com/jaswilli>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e33d23d..4549beb 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,12 +1,299 @@
 Changelog
 ---------
 
+7.0.1 (2020-12-14)
+******************
+
+Bug fixes:
+
+* Fix `DelimitedList` and `DelimitedTuple` to pass additional keyword arguments
+  through their `_serialize` methods to the child fields and fix type checking
+  on these classes. (:issue:`569`)
+  Thanks to :user:`decaz` for reporting.
+
+7.0.0 (2020-12-10)
+******************
+
+Changes:
+
+* *Backwards-incompatible*: Drop support for webapp2 (:pr:`565`).
+
+* Add type annotations to `Parser` class, `DelimitedList`, and
+  `DelimitedTuple`. (:issue:`566`)
+
+7.0.0b2 (2020-12-01)
+********************
+
+Features:
+
+* `DjangoParser` now supports the `headers` location. (:issue:`540`)
+
+* `FalconParser` now supports a new `media` location, which uses
+  Falcon's `media` decoding. (:issue:`253`)
+
+`media` behaves very similarly to the `json` location but also supports any
+registered media handler. See the
+`Falcon documentation on media types
+<https://falcon.readthedocs.io/en/stable/api/media.html>`_ for more details.
+
+Changes:
+
+* `FalconParser` defaults to the `media` location instead of `json`. (:issue:`253`)
+* Test against Python 3.9 (:pr:`552`).
+* *Backwards-incompatible*: Drop support for Python 3.5 (:pr:`553`).
+
+7.0.0b1 (2020-09-11)
+********************
+
+Refactoring:
+
+* *Backwards-incompatible*: Remove support for marshmallow2 (:issue:`539`)
+
+* *Backwards-incompatible*: Remove `dict2schema`
+
+  Users desiring the `dict2schema` functionality may now rely upon
+  `marshmallow.Schema.from_dict`. Rewrite any code using `dict2schema` like so:
+
+.. code-block:: python
+
+    import marshmallow as ma
+
+    # webargs 6.x and older
+    from webargs import dict2schema
+
+    myschema = dict2schema({"q1", ma.fields.Int()})
+
+    # webargs 7.x
+    myschema = ma.Schema.from_dict({"q1", ma.fields.Int()})
+
+Features:
+
+* Add ``unknown`` as a parameter to ``Parser.parse``, ``Parser.use_args``,
+  ``Parser.use_kwargs``, and parser instantiation. When set, it will be passed
+  to ``Schema.load``. When not set, the value passed will depend on the parser's
+  settings. If set to ``None``, the schema's default behavior will be used (i.e.
+  no value is passed to ``Schema.load``) and parser settings will be ignored.
+
+This allows usages like
+
+.. code-block:: python
+
+    import marshmallow as ma
+
+
+    @parser.use_kwargs(
+        {"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query", unknown=ma.EXCLUDE
+    )
+    def foo(q1, q2):
+        ...
+
+* Defaults for ``unknown`` may be customized on parser classes via
+  ``Parser.DEFAULT_UNKNOWN_BY_LOCATION``, which maps location names to values
+  to use.
+
+Usages are varied, but include
+
+.. code-block:: python
+
+    import marshmallow as ma
+    from webargs.flaskparser import FlaskParser
+
+    # as well as...
+    class MyParser(FlaskParser):
+        DEFAULT_UNKNOWN_BY_LOCATION = {"query": ma.INCLUDE}
+
+
+    parser = MyParser()
+
+Setting the ``unknown`` value for a Parser instance has higher precedence. So
+
+.. code-block:: python
+
+    parser = MyParser(unknown=ma.RAISE)
+
+will always pass ``RAISE``, even when the location is ``query``.
+
+* By default, webargs will pass ``unknown=EXCLUDE`` for all locations except
+  for request bodies (``json``, ``form``, and ``json_or_form``) and path
+  parameters. Request bodies and path parameters will pass ``unknown=RAISE``.
+  This behavior is defined by the default value for
+  ``DEFAULT_UNKNOWN_BY_LOCATION``.
+
+Changes:
+
+* Registered `error_handler` callbacks are required to raise an exception.
+  If a handler is invoked and no exception is raised, `webargs` will raise
+  a `ValueError` (:issue:`527`)
+
+6.1.1 (2020-09-08)
+******************
+
+Bug fixes:
+
+* Failure to validate flask headers would produce error data which contained
+  tuples as keys, and was therefore not JSON-serializable. (:issue:`500`)
+  These errors will now extract the headername as the key correctly.
+  Thanks to :user:`shughes-uk` for reporting.
+
+6.1.0 (2020-04-05)
+******************
+
+Features:
+
+* Add ``fields.DelimitedTuple`` when using marshmallow 3. This behaves as a
+  combination of ``fields.DelimitedList`` and ``marshmallow.fields.Tuple``. It
+  takes an iterable of fields, plus a delimiter (defaults to ``,``), and parses
+  delimiter-separated strings into tuples. (:pr:`509`)
+
+* Add ``__str__`` and ``__repr__`` to MultiDictProxy to make it easier to work
+  with (:pr:`488`)
+
+Support:
+
+* Various docs updates (:pr:`482`, :pr:`486`, :pr:`489`, :pr:`498`, :pr:`508`).
+  Thanks :user:`lefterisjp`, :user:`timgates42`, and :user:`ugultopu` for the PRs.
+
+
+6.0.0 (2020-02-27)
+******************
+
+Features:
+
+* ``FalconParser``: Pass request content length to ``req.stream.read`` to
+  provide compatibility with ``falcon.testing`` (:pr:`477`).
+  Thanks :user:`suola` for the PR.
+
+* *Backwards-incompatible*: Factorize the ``use_args`` / ``use_kwargs`` branch
+  in all parsers. When ``as_kwargs`` is ``False``, arguments are now
+  consistently appended to the arguments list by the ``use_args`` decorator.
+  Before this change, the ``PyramidParser`` would prepend the argument list on
+  each call to ``use_args``. Pyramid view functions must reverse the order of
+  their arguments. (:pr:`478`)
+
+6.0.0b8 (2020-02-16)
+********************
+
+Refactoring:
+
+* *Backwards-incompatible*: Use keyword-only arguments (:pr:`472`).
+
+6.0.0b7 (2020-02-14)
+********************
+
+Features:
+
+* *Backwards-incompatible*: webargs will rewrite the error messages in
+  ValidationErrors to be namespaced under the location which raised the error.
+  The `messages` field on errors will therefore be one layer deeper with a
+  single top-level key.
+
+6.0.0b6 (2020-01-31)
+********************
+
+Refactoring:
+
+* Remove the cache attached to webargs parsers. Due to changes between webargs
+  v5 and v6, the cache is no longer considered useful.
+
+Other changes:
+
+* Import ``Mapping`` from ``collections.abc`` in pyramidparser.py (:pr:`471`).
+  Thanks :user:`tirkarthi` for the PR.
+
+6.0.0b5 (2020-01-30)
+********************
+
+Refactoring:
+
+* *Backwards-incompatible*: `DelimitedList` now requires that its input be a
+  string and always serializes as a string. It can still serialize and deserialize
+  using another field, e.g. `DelimitedList(Int())` is still valid and requires
+  that the values in the list parse as ints.
+
+6.0.0b4 (2020-01-28)
+********************
+
+Bug fixes:
+
+* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched
+  (bugfix from 5.5.3).
+
+6.0.0b3 (2020-01-21)
+********************
+
+Features:
+
+* *Backwards-incompatible*: Support Falcon 2.0. Drop support for Falcon 1.x
+  (:pr:`459`). Thanks :user:`dodumosu` and :user:`Nateyo` for the PR.
+
+6.0.0b2 (2020-01-07)
+********************
+
+Other changes:
+
+* *Backwards-incompatible*: Drop support for Python 2 (:issue:`440`).
+  Thanks :user:`hugovk` for the PR.
+
+6.0.0b1 (2020-01-06)
+********************
+
+Features:
+
+* *Backwards-incompatible*: Schemas will now load all data from a location, not
+  only data specified by fields. As a result, schemas with validators which
+  examine the full input data may change in behavior. The `unknown` parameter
+  on schemas may be used to alter this. For example,
+  `unknown=marshmallow.EXCLUDE` will produce a behavior similar to webargs v5.
+
+Bug fixes:
+
+* *Backwards-incompatible*: All parsers now require the Content-Type to be set
+  correctly when processing JSON request bodies. This impacts ``DjangoParser``,
+  ``FalconParser``, ``FlaskParser``, and ``PyramidParser``
+
+Refactoring:
+
+* *Backwards-incompatible*: Schema fields may not specify a location any
+  longer, and `Parser.use_args` and `Parser.use_kwargs` now accept `location`
+  (singular) instead of `locations` (plural). Instead of using a single field or
+  schema with multiple `locations`, users are recommended to make multiple
+  calls to `use_args` or `use_kwargs` with a distinct schema per location. For
+  example, code should be rewritten like this:
+
+.. code-block:: python
+
+    # webargs 5.x and older
+    @parser.use_args(
+        {
+            "q1": ma.fields.Int(location="query"),
+            "q2": ma.fields.Int(location="query"),
+            "h1": ma.fields.Int(location="headers"),
+        },
+        locations=("query", "headers"),
+    )
+    def foo(q1, q2, h1):
+        ...
+
+
+    # webargs 6.x
+    @parser.use_args({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query")
+    @parser.use_args({"h1": ma.fields.Int()}, location="headers")
+    def foo(q1, q2, h1):
+        ...
+
+* The `location_handler` decorator has been removed and replaced with
+  `location_loader`. `location_loader` serves the same purpose (letting you
+  write custom hooks for loading data) but its expected method signature is
+  different. See the docs on `location_loader` for proper usage.
+
+Thanks :user:`sirosen` for the PR!
+
 5.5.3 (2020-01-28)
 ******************
 
 Bug fixes:
 
-* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if the request's Content-Type is mismatched.
+* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched.
 
 5.5.2 (2019-10-06)
 ******************
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 1ab1a74..c867add 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -122,7 +122,7 @@ To run syntax checks: ::
 Documentation
 +++++++++++++
 
-Contributions to the documentation are welcome. Documentation is written in `reStructured Text`_ (rST). A quick rST reference can be found `here <http://docutils.sourceforge.net/docs/user/rst/quickref.html>`_. Builds are powered by Sphinx_.
+Contributions to the documentation are welcome. Documentation is written in `reStructuredText`_ (rST). A quick rST reference can be found `here <https://docutils.sourceforge.io/docs/user/rst/quickref.html>`_. Builds are powered by Sphinx_.
 
 To build the docs in "watch" mode: ::
 
@@ -137,5 +137,5 @@ Have a usage example you'd like to share? Feel free to add it to the `examples <
 
 
 .. _Sphinx: http://sphinx.pocoo.org/
-.. _`reStructured Text`: http://docutils.sourceforge.net/rst.html
+.. _`reStructuredText`: https://docutils.sourceforge.io/rst.html
 .. _webargs: https://github.com/marshmallow-code/webargs
diff --git a/LICENSE b/LICENSE
index 1fa8cbb..f79bc18 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2014-2019 Steven Loria and contributors
+Copyright 2014-2020 Steven Loria and contributors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/MANIFEST.in b/MANIFEST.in
index 7d61281..7d4e712 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,4 @@
+graft tests
 include LICENSE
 include *.rst
+include tox.ini
diff --git a/README.rst b/README.rst
index da409f5..1ddf34a 100644
--- a/README.rst
+++ b/README.rst
@@ -14,9 +14,9 @@ webargs
    :target: https://webargs.readthedocs.io/
    :alt: Documentation
 
-.. image:: https://badgen.net/badge/marshmallow/2,3?list=1
+.. image:: https://badgen.net/badge/marshmallow/3
     :target: https://marshmallow.readthedocs.io/en/latest/upgrading.html
-    :alt: marshmallow 2/3 compatible
+    :alt: marshmallow 3 compatible
 
 .. image:: https://badgen.net/badge/code%20style/black/000
     :target: https://github.com/ambv/black
@@ -24,7 +24,7 @@ webargs
 
 Homepage: https://webargs.readthedocs.io/
 
-webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp.
+webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, Falcon, and aiohttp.
 
 .. code-block:: python
 
@@ -36,7 +36,7 @@ webargs is a Python library for parsing and validating HTTP request objects, wit
 
 
     @app.route("/")
-    @use_args({"name": fields.Str(required=True)})
+    @use_args({"name": fields.Str(required=True)}, location="query")
     def index(args):
         return "Hello " + args["name"]
 
@@ -54,7 +54,7 @@ Install
 
     pip install -U webargs
 
-webargs supports Python >= 2.7 or >= 3.5.
+webargs supports Python >= 3.6.
 
 
 Documentation
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index c80853c..94688ba 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -26,33 +26,19 @@ jobs:
   parameters:
     toxenvs:
       - lint
-      - py27-marshmallow2
-
-      - py35-marshmallow2
-      - py35-marshmallow3
-
-      - py36-marshmallow2
-      - py36-marshmallow3
-
-      - py37-marshmallow2
-      - py37-marshmallow3
-
-      - py37-marshmallowdev
-
+      - mypy
+      - py36
+      - py36-mindeps
+      - py37
+      - py38
+      - py39
+      - py39-marshmallowdev
       - docs
     os: linux
-# Build separate wheels for python 2 and 3
+# Build wheels
 - template: job--pypi-release.yml@sloria
   parameters:
-    python: "3.7"
+    python: "3.9"
     distributions: "sdist bdist_wheel"
-    name_postfix: "_py3"
-    dependsOn:
-      - tox_linux
-- template: job--pypi-release.yml@sloria
-  parameters:
-    python: "2.7"
-    distributions: "bdist_wheel"
-    name_postfix: "_py2"
     dependsOn:
       - tox_linux
diff --git a/docs/advanced.rst b/docs/advanced.rst
index 01b0417..264f56a 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -6,7 +6,7 @@ This section includes guides for advanced usage patterns.
 Custom Location Handlers
 ------------------------
 
-To add your own custom location handler, write a function that receives a request, an argument name, and a :class:`Field <marshmallow.fields.Field>`, then decorate that function with :func:`Parser.location_handler <webargs.core.Parser.location_handler>`.
+To add your own custom location handler, write a function that receives a request, and a :class:`Schema <marshmallow.Schema>`, then decorate that function with :func:`Parser.location_loader <webargs.core.Parser.location_loader>`.
 
 
 .. code-block:: python
@@ -15,17 +15,78 @@ To add your own custom location handler, write a function that receives a reques
     from webargs.flaskparser import parser
 
 
-    @parser.location_handler("data")
-    def parse_data(request, name, field):
-        return request.data.get(name)
+    @parser.location_loader("data")
+    def load_data(request, schema):
+        return request.data
 
 
     # Now 'data' can be specified as a location
-    @parser.use_args({"per_page": fields.Int()}, locations=("data",))
+    @parser.use_args({"per_page": fields.Int()}, location="data")
     def posts(args):
         return "displaying {} posts".format(args["per_page"])
 
 
+.. NOTE::
+
+    The schema is passed so that it can be used to wrap multidict types and
+    unpack List fields correctly. If you are writing a loader for a multidict
+    type, consider looking at
+    :class:`MultiDictProxy <webargs.multidictproxy.MultiDictProxy>` for an
+    example of how to do this.
+
+"meta" Locations
+~~~~~~~~~~~~~~~~
+
+You can define your own locations which mix data from several existing
+locations.
+
+The `json_or_form` location does this -- first trying to load data as JSON and
+then falling back to a form body -- and its implementation is quite simple:
+
+
+.. code-block:: python
+
+    def load_json_or_form(self, req, schema):
+        """Load data from a request, accepting either JSON or form-encoded
+        data.
+
+        The data will first be loaded as JSON, and, if that fails, it will be
+        loaded as a form post.
+        """
+        data = self.load_json(req, schema)
+        if data is not missing:
+            return data
+        return self.load_form(req, schema)
+
+
+You can imagine your own locations with custom behaviors like this.
+For example, to mix query parameters and form body data, you might write the
+following:
+
+.. code-block:: python
+
+   from webargs import fields
+   from webargs.multidictproxy import MultiDictProxy
+   from webargs.flaskparser import parser
+
+
+   @parser.location_loader("query_and_form")
+   def load_data(request, schema):
+       # relies on the Flask (werkzeug) MultiDict type's implementation of
+       # these methods, but when you're extending webargs, you may know things
+       # about your framework of choice
+       newdata = request.args.copy()
+       newdata.update(request.form)
+       return MultiDictProxy(newdata, schema)
+
+
+   # Now 'query_and_form' means you can send these values in either location,
+   # and they will be *mixed* together into a new dict to pass to your schema
+   @parser.use_args({"favorite_food": fields.String()}, location="query_and_form")
+   def set_favorite_food(args):
+       ...  # do stuff
+       return "your favorite food is now set to {}".format(args["favorite_food"])
+
 marshmallow Integration
 -----------------------
 
@@ -46,10 +107,6 @@ When you need more flexibility in defining input schemas, you can pass a marshma
         last_name = fields.Str(missing="")
         date_registered = fields.DateTime(dump_only=True)
 
-        # NOTE: Uncomment below two lines if you're using marshmallow 2
-        # class Meta:
-        #    strict = True
-
 
     @use_args(UserSchema())
     def profile_view(args):
@@ -64,17 +121,165 @@ When you need more flexibility in defining input schemas, you can pass a marshma
 
 
     # You can add additional parameters
-    @use_kwargs({"posts_per_page": fields.Int(missing=10, location="query")})
+    @use_kwargs({"posts_per_page": fields.Int(missing=10)}, location="query")
     @use_args(UserSchema())
     def profile_posts(args, posts_per_page):
         username = args["username"]
         # ...
 
-.. warning::
-    If you're using marshmallow 2, you should always set ``strict=True`` (either as a ``class Meta`` option or in the Schema's constructor) when passing a schema to webargs. This will ensure that the parser's error handler is invoked when expected.
+.. _advanced_setting_unknown:
+
+Setting `unknown`
+-----------------
+
+webargs supports several ways of setting and passing the `unknown` parameter
+for `handling unknown fields <https://marshmallow.readthedocs.io/en/stable/quickstart.html#handling-unknown-fields>`_.
+
+You can pass `unknown=...` as a parameter to any of
+`Parser.parse <webargs.core.Parser.parse>`,
+`Parser.use_args <webargs.core.Parser.use_args>`, and
+`Parser.use_kwargs <webargs.core.Parser.use_kwargs>`.
+
 
-.. warning::
-    Any `Schema <marshmallow.Schema>` passed to `use_kwargs <webargs.core.Parser.use_kwargs>` MUST deserialize to a dictionary of data. Keep this in mind when writing `post_load <marshmallow.decorators.post_load>` methods.
+.. note::
+
+    The `unknown` value is passed to the schema's `load()` call. It therefore
+    only applies to the top layer when nesting is used. To control `unknown` at
+    multiple layers of a nested schema, you must use other mechanisms, like
+    the `unknown` argument to `fields.Nested`.
+
+Default `unknown`
++++++++++++++++++
+
+By default, webargs will pass `unknown=marshmallow.EXCLUDE` except when the
+location is `json`, `form`, `json_or_form`, `path`, or `path`. In those cases,
+it uses `unknown=marshmallow.RAISE` instead.
+
+You can change these defaults by overriding `DEFAULT_UNKNOWN_BY_LOCATION`.
+This is a mapping of locations to values to pass.
+
+For example,
+
+.. code-block:: python
+
+    from flask import Flask
+    from marshmallow import EXCLUDE, fields
+    from webargs.flaskparser import FlaskParser
+
+    app = Flask(__name__)
+
+
+    class Parser(FlaskParser):
+        DEFAULT_UNKNOWN_BY_LOCATION = {"query": EXCLUDE}
+
+
+    parser = Parser()
+
+
+    # location is "query", which is listed in DEFAULT_UNKNOWN_BY_LOCATION,
+    # so EXCLUDE will be used
+    @app.route("/", methods=["GET"])
+    @parser.use_args({"foo": fields.Int()}, location="query")
+    def get(self, args):
+        return f"foo x 2 = {args['foo'] * 2}"
+
+
+    # location is "json", which is not in DEFAULT_UNKNOWN_BY_LOCATION,
+    # so no value will be passed for `unknown`
+    @app.route("/", methods=["POST"])
+    @parser.use_args({"foo": fields.Int(), "bar": fields.Int()}, location="json")
+    def post(self, args):
+        return f"foo x bar = {args['foo'] * args['bar']}"
+
+
+You can also define a default at parser instantiation, which will take
+precedence over these defaults, as in
+
+.. code-block:: python
+
+    from marshmallow import INCLUDE
+
+    parser = Parser(unknown=INCLUDE)
+
+    # because `unknown` is set on the parser, `DEFAULT_UNKNOWN_BY_LOCATION` has
+    # effect and `INCLUDE` will always be used
+    @app.route("/", methods=["POST"])
+    @parser.use_args({"foo": fields.Int(), "bar": fields.Int()}, location="json")
+    def post(self, args):
+        unexpected_args = [k for k in args.keys() if k not in ("foo", "bar")]
+        return f"foo x bar = {args['foo'] * args['bar']}; unexpected args={unexpected_args}"
+
+Using Schema-Specfied `unknown`
++++++++++++++++++++++++++++++++
+
+If you wish to use the value of `unknown` specified by a schema, simply pass
+``unknown=None``. This will disable webargs' automatic passing of values for
+``unknown``. For example,
+
+.. code-block:: python
+
+    from flask import Flask
+    from marshmallow import Schema, fields, EXCLUDE, missing
+    from webargs.flaskparser import use_args
+
+
+    class RectangleSchema(Schema):
+        length = fields.Float()
+        width = fields.Float()
+
+        class Meta:
+            unknown = EXCLUDE
+
+
+    app = Flask(__name__)
+
+    # because unknown=None was passed, no value is passed during schema loading
+    # as a result, the schema's behavior (EXCLUDE) is used
+    @app.route("/", methods=["POST"])
+    @use_args(RectangleSchema(), location="json", unknown=None)
+    def get(self, args):
+        return f"area = {args['length'] * args['width']}"
+
+
+You can also set ``unknown=None`` when instantiating a parser to make this
+behavior the default for a parser.
+
+
+When to avoid `use_kwargs`
+--------------------------
+
+Any  `Schema <marshmallow.Schema>` passed to `use_kwargs <webargs.core.Parser.use_kwargs>` MUST deserialize to a dictionary of data.
+If your schema has a `post_load <marshmallow.decorators.post_load>` method 
+that returns a non-dictionary,
+you should use `use_args <webargs.core.Parser.use_args>` instead.
+
+.. code-block:: python
+
+    from marshmallow import Schema, fields, post_load
+    from webargs.flaskparser import use_args
+
+
+    class Rectangle:
+        def __init__(self, length, width):
+            self.length = length
+            self.width = width
+
+
+    class RectangleSchema(Schema):
+        length = fields.Float()
+        width = fields.Float()
+
+        @post_load
+        def make_object(self, data, **kwargs):
+            return Rectangle(**data)
+
+
+    @use_args(RectangleSchema)
+    def post(self, rect: Rectangle):
+        return f"Area: {rect.length * rect.width}"
+
+Packages such as  `marshmallow-sqlalchemy <https://github.com/marshmallow-code/marshmallow-sqlalchemy>`_ and `marshmallow-dataclass <https://github.com/lovasoa/marshmallow_dataclass>`_ generate schemas that deserialize to non-dictionary objects.
+Therefore, `use_args <webargs.core.Parser.use_args>` should be used with those schemas.
 
 
 Schema Factories
@@ -177,12 +382,12 @@ Using the :class:`Method <marshmallow.fields.Method>` and :class:`Function <mars
         cube = args["cube"]
         # ...
 
-.. _custom-parsers:
+.. _custom-loaders:
 
 Custom Parsers
 --------------
 
-To add your own parser, extend :class:`Parser <webargs.core.Parser>` and implement the `parse_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments.
+To add your own parser, extend :class:`Parser <webargs.core.Parser>` and implement the `load_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments.
 
 
 .. code-block:: python
@@ -211,8 +416,8 @@ To add your own parser, extend :class:`Parser <webargs.core.Parser>` and impleme
             }
         """
 
-        def parse_querystring(self, req, name, field):
-            return core.get_value(_structure_dict(req.args), name, field)
+        def load_querystring(self, req, schema):
+            return _structure_dict(req.args)
 
 
     def _structure_dict(dict_):
@@ -236,6 +441,7 @@ Returning HTTP 400 Responses
 If you'd prefer validation errors to return status code ``400`` instead
 of ``422``, you can override ``DEFAULT_VALIDATION_STATUS`` on a :class:`Parser <webargs.core.Parser>`.
 
+Sublcass the parser for your framework to do so. For example, using Falcon:
 
 .. code-block:: python
 
@@ -275,7 +481,7 @@ For example, you might implement JSON PATCH according to `RFC 6902 <https://tool
 
 
     @app.route("/profile/", methods=["patch"])
-    @use_args(PatchSchema(many=True), locations=("json",))
+    @use_args(PatchSchema(many=True))
     def patch_blog(args):
         """Implements JSON Patch for the user profile
 
@@ -290,48 +496,14 @@ For example, you might implement JSON PATCH according to `RFC 6902 <https://tool
 Mixing Locations
 ----------------
 
-Arguments for different locations can be specified by passing ``location`` to each field individually:
-
-.. code-block:: python
-
-    @app.route("/stacked", methods=["POST"])
-    @use_args(
-        {
-            "page": fields.Int(location="query"),
-            "q": fields.Str(location="query"),
-            "name": fields.Str(location="json"),
-        }
-    )
-    def viewfunc(args):
-        page = args["page"]
-        # ...
-
-Alternatively, you can pass multiple locations to `use_args <webargs.core.Parser.use_args>`:
-
-.. code-block:: python
-
-    @app.route("/stacked", methods=["POST"])
-    @use_args(
-        {"page": fields.Int(), "q": fields.Str(), "name": fields.Str()},
-        locations=("query", "json"),
-    )
-    def viewfunc(args):
-        page = args["page"]
-        # ...
-
-However, this allows ``page`` and ``q`` to be passed in the request body and ``name`` to be passed as a query parameter.
-
-To restrict the arguments to single locations without having to pass ``location`` to every field, you can call the `use_args <webargs.core.Parser.use_args>` multiple times:
+Arguments for different locations can be specified by passing ``location`` to each `use_args <webargs.core.Parser.use_args>` call:
 
 .. code-block:: python
 
-    query_args = {"page": fields.Int(), "q": fields.Int()}
-    json_args = {"name": fields.Str()}
-
-
+    # "json" is the default, used explicitly below
     @app.route("/stacked", methods=["POST"])
-    @use_args(query_args, locations=("query",))
-    @use_args(json_args, locations=("json",))
+    @use_args({"page": fields.Int(), "q": fields.Str()}, location="query")
+    @use_args({"name": fields.Str()}, location="json")
     def viewfunc(query_parsed, json_parsed):
         page = query_parsed["page"]
         name = json_parsed["name"]
@@ -343,12 +515,12 @@ To reduce boilerplate, you could create shortcuts, like so:
 
     import functools
 
-    query = functools.partial(use_args, locations=("query",))
-    body = functools.partial(use_args, locations=("json",))
+    query = functools.partial(use_args, location="query")
+    body = functools.partial(use_args, location="json")
 
 
-    @query(query_args)
-    @body(json_args)
+    @query({"page": fields.Int(), "q": fields.Int()})
+    @body({"name": fields.Str()})
     def viewfunc(query_parsed, json_parsed):
         page = query_parsed["page"]
         name = json_parsed["name"]
diff --git a/docs/api.rst b/docs/api.rst
index d9a4ad1..a74fc2d 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -16,6 +16,14 @@ webargs.fields
 .. automodule:: webargs.fields
     :members: Nested, DelimitedList
 
+
+webargs.multidictproxy
+----------------------
+
+.. automodule:: webargs.multidictproxy
+    :members:
+
+
 webargs.asyncparser
 -------------------
 
@@ -52,14 +60,6 @@ webargs.pyramidparser
 .. automodule:: webargs.pyramidparser
     :members:
 
-
-webargs.webapp2parser
----------------------
-
-.. automodule:: webargs.webapp2parser
-    :members:
-
-
 webargs.falconparser
 ---------------------
 
diff --git a/docs/conf.py b/docs/conf.py
index 880d799..3d195f1 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 import datetime as dt
 import sys
 import os
@@ -23,7 +22,7 @@ default_role = "py:obj"
 github_user = "marshmallow-code"
 github_repo = "webargs"
 
-issues_github_path = "{}/{}".format(github_user, github_repo)
+issues_github_path = f"{github_user}/{github_repo}"
 
 intersphinx_mapping = {
     "python": ("http://python.readthedocs.io/en/latest/", None),
@@ -37,8 +36,8 @@ language = "en"
 
 html_domain_indices = False
 source_suffix = ".rst"
-project = u"webargs"
-copyright = u"2014-{0:%Y}, Steven Loria and contributors".format(dt.datetime.utcnow())
+project = "webargs"
+copyright = f"2014-{dt.datetime.utcnow():%Y}, Steven Loria and contributors"
 version = release = webargs.__version__
 templates_path = ["_templates"]
 exclude_patterns = ["_build"]
diff --git a/docs/framework_support.rst b/docs/framework_support.rst
index c1b2f9a..0063901 100644
--- a/docs/framework_support.rst
+++ b/docs/framework_support.rst
@@ -22,9 +22,9 @@ When using the :meth:`use_args <webargs.flaskparser.FlaskParser.use_args>` decor
 
 
     @app.route("/user/<int:uid>")
-    @use_args({"per_page": fields.Int()})
+    @use_args({"per_page": fields.Int()}, location="query")
     def user_detail(args, uid):
-        return ("The user page for user {uid}, " "showing {per_page} posts.").format(
+        return ("The user page for user {uid}, showing {per_page} posts.").format(
             uid=uid, per_page=args["per_page"]
         )
 
@@ -64,7 +64,7 @@ The `FlaskParser` supports parsing values from a request's ``view_args``.
 
 
     @app.route("/greeting/<name>/")
-    @use_args({"name": fields.Str(location="view_args")})
+    @use_args({"name": fields.Str()}, location="view_args")
     def greeting(args, **kwargs):
         return "Hello {}".format(args["name"])
 
@@ -95,7 +95,7 @@ When using the :meth:`use_args <webargs.djangoparser.DjangoParser.use_args>` dec
   }
 
 
-  @use_args(account_args)
+  @use_args(account_args, location="form")
   def login_user(request, args):
       if request.method == "POST":
           login(args["username"], args["password"])
@@ -114,7 +114,7 @@ When using the :meth:`use_args <webargs.djangoparser.DjangoParser.use_args>` dec
 
 
     class BlogPostView(View):
-        @use_args(blog_args)
+        @use_args(blog_args, location="query")
         def get(self, request, args):
             blog_post = Post.objects.get(title__iexact=args["title"], author=args["author"])
             return render_to_response("post_template.html", {"post": blog_post})
@@ -239,7 +239,7 @@ When using the :meth:`use_args <webargs.pyramidparser.PyramidParser.use_args>` d
     from webargs.pyramidparser import use_args
 
 
-    @use_args({"uid": fields.Str(), "per_page": fields.Int()})
+    @use_args({"uid": fields.Str(), "per_page": fields.Int()}, location="query")
     def user_detail(request, args):
         uid = args["uid"]
         return Response(
@@ -261,7 +261,7 @@ The `PyramidParser` supports parsing values from a request's matchdict.
     from webargs.pyramidparser import use_args
 
 
-    @use_args({"mymatch": fields.Int()}, locations=("matchdict",))
+    @use_args({"mymatch": fields.Int()}, location="matchdict")
     def matched(request, args):
         return Response("The value for mymatch is {}".format(args["mymatch"]))
 
@@ -310,14 +310,14 @@ You can easily implement hooks by using `parser.parse <webargs.falconparser.Falc
 
 
     def add_args(argmap, **kwargs):
-        def hook(req, resp, params):
+        def hook(req, resp, resource, params):
             parsed_args = parser.parse(argmap, req=req, **kwargs)
             req.context["args"] = parsed_args
 
         return hook
 
 
-    @falcon.before(add_args({"page": fields.Int(location="query")}))
+    @falcon.before(add_args({"page": fields.Int()}, location="query"))
     class AuthorResource:
         def on_get(self, req, resp):
             args = req.context["args"]
@@ -414,7 +414,7 @@ The `AIOHTTPParser <webargs.aiohttpparser.AIOHTTPParser>` supports parsing value
     from webargs.aiohttpparser import use_args
 
 
-    @parser.use_args({"slug": fields.Str(location="match_info")})
+    @parser.use_args({"slug": fields.Str()}, location="match_info")
     def article_detail(request, args):
         return web.Response(body="Slug: {}".format(args["slug"]).encode("utf-8"))
 
@@ -443,6 +443,5 @@ The preferred way to apply decorators to Bottle routes is using the
 
   @route("/users/<_id:int>", method="GET", apply=use_args(user_args))
   def users(args, _id):
-      """A welcome page.
-      """
+      """A welcome page."""
       return {"message": "Welcome, {}!".format(args["name"]), "_id": _id}
diff --git a/docs/index.rst b/docs/index.rst
index e152b9f..cf47c8b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -4,8 +4,16 @@ webargs
 
 Release v\ |version|. (:doc:`Changelog <changelog>`)
 
-webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp.
+webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, Falcon, and aiohttp.
 
+Upgrading from an older version?
+--------------------------------
+
+See the :doc:`Upgrading to Newer Releases <upgrading>` page for notes on getting your code up-to-date with the latest version.
+
+
+Usage and Simple Examples
+-------------------------
 
 .. code-block:: python
 
@@ -17,7 +25,7 @@ webargs is a Python library for parsing and validating HTTP request objects, wit
 
 
     @app.route("/")
-    @use_args({"name": fields.Str(required=True)})
+    @use_args({"name": fields.Str(required=True)}, location="query")
     def index(args):
         return "Hello " + args["name"]
 
@@ -28,13 +36,15 @@ webargs is a Python library for parsing and validating HTTP request objects, wit
     # curl http://localhost:5000/\?name\='World'
     # Hello World
 
-Webargs will automatically parse:
+By default Webargs will automatically parse JSON request bodies. But it also
+has support for:
 
 **Query Parameters**
 ::
+  $ curl http://localhost:5000/\?name\='Freddie'
+  Hello Freddie
 
-    $ curl http://localhost:5000/\?name\='Freddie'
-    Hello Freddie
+  # pass location="query" to use_args
 
 **Form Data**
 ::
@@ -42,12 +52,16 @@ Webargs will automatically parse:
   $ curl -d 'name=Brian' http://localhost:5000/
   Hello Brian
 
+  # pass location="form" to use_args
+
 **JSON Data**
 ::
 
   $ curl -X POST -H "Content-Type: application/json" -d '{"name":"Roger"}' http://localhost:5000/
   Hello Roger
 
+  # pass location="json" (or omit location) to use_args
+
 and, optionally:
 
 - Headers
@@ -103,5 +117,6 @@ Project Info
 
    license
    changelog
+   upgrading
    authors
    contributing
diff --git a/docs/install.rst b/docs/install.rst
index 62f36ea..c225be2 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -1,7 +1,7 @@
 Install
 =======
 
-**webargs** requires Python >= 2.7 or >= 3.5. It depends on `marshmallow <https://marshmallow.readthedocs.io/en/latest/>`_ >= 2.7.0.
+**webargs** requires Python >= 3.6. It depends on `marshmallow <https://marshmallow.readthedocs.io/en/latest/>`_ >= 3.0.0.
 
 From the PyPI
 -------------
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index b43371d..a9e8f98 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -23,17 +23,9 @@ Arguments are specified as a dictionary of name -> :class:`Field <marshmallow.fi
         "nickname": fields.List(fields.Str()),
         # Delimited list, e.g. "/?languages=python,javascript"
         "languages": fields.DelimitedList(fields.Str()),
-        # When you know where an argument should be parsed from
-        "active": fields.Bool(location="query"),
         # When value is keyed on a variable-unsafe name
         # or you want to rename a key
-        "content_type": fields.Str(load_from="Content-Type", location="headers"),
-        # OR, on marshmallow 3
-        # "content_type": fields.Str(data_key="Content-Type", location="headers"),
-        # File uploads
-        "profile_image": fields.Field(
-            location="files", validate=lambda f: f.mimetype in ["image/jpeg", "image/png"]
-        ),
+        "user_type": fields.Str(data_key="user-type"),
     }
 
 .. note::
@@ -105,12 +97,12 @@ As an alternative to `Parser.parse`, you can decorate your view with :meth:`use_
 Request "Locations"
 -------------------
 
-By default, webargs will search for arguments from the URL query string (e.g. ``"/?name=foo"``), form data, and JSON data (in that order). You can explicitly specify which locations to search, like so:
+By default, webargs will search for arguments from the request body as JSON. You can specify a different location from which to load data like so:
 
 .. code-block:: python
 
     @app.route("/register")
-    @use_args(user_args, locations=("json", "form"))
+    @use_args(user_args, location="form")
     def register(args):
         return "registration page"
 
@@ -202,7 +194,7 @@ Then decorate that function with :func:`Parser.error_handler <webargs.core.Parse
 
 
     @parser.error_handler
-    def handle_error(error, req, schema, status_code, headers):
+    def handle_error(error, req, schema, *, error_status_code, error_headers):
         raise CustomError(error.messages)
 
 Parsing Lists in Query Strings
@@ -243,7 +235,7 @@ Nesting Fields
 
 .. note::
 
-    By default, webargs only parses nested fields using the ``json`` request location. You can, however, :ref:`implement your own parser <custom-parsers>` to add nested field functionality to the other locations.
+    Of the default supported locations in webargs, only the ``json`` request location supports nested datastructures. You can, however, :ref:`implement your own data loader <custom-loaders>` to add nested field functionality to the other locations.
 
 Next Steps
 ----------
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
new file mode 100644
index 0000000..a34b323
--- /dev/null
+++ b/docs/upgrading.rst
@@ -0,0 +1,544 @@
+Upgrading to Newer Releases
+===========================
+
+This section documents migration paths to new releases.
+
+Upgrading to 7.0
+++++++++++++++++
+
+`unknown` is Now Settable by the Parser
+---------------------------------------
+
+As of 7.0, `Parsers` have multiple settings for controlling the value for
+`unknown` which is passed to `schema.load` when parsing.
+
+To set unknown behavior on a parser, see the advanced doc on this topic:
+:ref:`advanced_setting_unknown`.
+
+Importantly, by default, any schema setting for `unknown` will be overridden by
+the `unknown` settings for the parser.
+
+In order to use a schema's `unknown` value, set `unknown=None` on the parser.
+In 6.x versions of webargs, schema values for `unknown` are used, so the
+`unknown=None` setting is the best way to emulate this.
+
+To get identical behavior:
+
+.. code-block:: python
+
+    # assuming you have a schema named MySchema
+
+    # webargs 6.x
+    @parser.use_args(MySchema)
+    def foo(args):
+        ...
+
+
+    # webargs 7.x
+    # as a parameter to use_args or parse
+    @parser.use_args(MySchema, unknown=None)
+    def foo(args):
+        ...
+
+
+    # webargs 7.x
+    # as a parser setting
+    # example with flaskparser, but any parser class works
+    parser = FlaskParser(unknown=None)
+
+
+    @parser.use_args(MySchema)
+    def foo(args):
+        ...
+
+Upgrading to 6.0
+++++++++++++++++
+
+Multiple Locations Are No Longer Supported In A Single Call
+-----------------------------------------------------------
+
+The default location is JSON/body.
+
+Under webargs 5.x, code often did not have to specify a location.
+
+Because webargs would parse data from multiple locations automatically, users
+did not need to specify where a parameter, call it `q`, was passed.
+`q` could be in a query parameter or in a JSON or form-post body.
+
+Now, webargs requires that users specify only one location for data loading per
+`use_args` call, and `"json"` is the default. If `q` is intended to be a query
+parameter, the developer must be explicit and rewrite like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    @parser.use_args({"q": ma.fields.String()})
+    def foo(args):
+        return some_function(user_query=args.get("q"))
+
+
+    # webargs 6.x
+    @parser.use_args({"q": ma.fields.String()}, location="query")
+    def foo(args):
+        return some_function(user_query=args.get("q"))
+
+This also means that another usage from 5.x is not supported. Code with
+multiple locations in a single `use_args`, `use_kwargs`, or `parse` call
+must be rewritten in multiple separate `use_args` or `use_kwargs` invocations,
+like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    @parser.use_kwargs(
+        {
+            "q1": ma.fields.Int(location="query"),
+            "q2": ma.fields.Int(location="query"),
+            "h1": ma.fields.Int(location="headers"),
+        },
+        locations=("query", "headers"),
+    )
+    def foo(q1, q2, h1):
+        ...
+
+
+    # webargs 6.x
+    @parser.use_kwargs({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query")
+    @parser.use_kwargs({"h1": ma.fields.Int()}, location="headers")
+    def foo(q1, q2, h1):
+        ...
+
+
+Fields No Longer Support location=...
+-------------------------------------
+
+Because a single `parser.use_args`, `parser.use_kwargs`, or `parser.parse` call
+cannot specify multiple locations, it is not necessary for a field to be able
+to specify its location. Rewrite code like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    @parser.use_args({"q": ma.fields.String(location="query")})
+    def foo(args):
+        return some_function(user_query=args.get("q"))
+
+
+    # webargs 6.x
+    @parser.use_args({"q": ma.fields.String()}, location="query")
+    def foo(args):
+        return some_function(user_query=args.get("q"))
+
+location_handler Has Been Replaced With location_loader
+-------------------------------------------------------
+
+This is not just a name change. The expected signature of a `location_loader`
+is slightly different from the signature for a `location_handler`.
+
+Where previously a `location_handler` code took the incoming request data and
+details of a single field being loaded, a `location_loader` takes the request
+and the schema as a pair. It does not return a specific field's data, but data
+for the whole location.
+
+Rewrite code like this:
+
+.. code-block:: python
+
+    # webargs 5.x
+    @parser.location_handler("data")
+    def load_data(request, name, field):
+        return request.data.get(name)
+
+
+    # webargs 6.x
+    @parser.location_loader("data")
+    def load_data(request, schema):
+        return request.data
+
+Data Is Not Filtered Before Being Passed To Schemas, And It May Be Proxified
+----------------------------------------------------------------------------
+
+In webargs 5.x, the deserialization schema was used to pull data out of the
+request object. That data was compiled into a dictionary which was then passed
+to the schema.
+
+One of the major changes in webargs 6.x allows the use of `unknown` parameter
+on schemas. This lets a schema decide what to do with fields not specified in
+the schema. In order to achieve this, webargs now passes the full data from
+the specified location to the schema.
+
+Therefore, users should specify `unknown=marshmallow.EXCLUDE` on their schemas in
+order to filter out unknown fields. Like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    # this can assume that "q" is the only parameter passed, and all other
+    # parameters will be ignored
+    @parser.use_kwargs({"q": ma.fields.String()}, locations=("query",))
+    def foo(q):
+        ...
+
+
+    # webargs 6.x, Solution 1: declare a schema with Meta.unknown set
+    class QuerySchema(ma.Schema):
+        q = ma.fields.String()
+
+        class Meta:
+            unknown = ma.EXCLUDE
+
+
+    @parser.use_kwargs(QuerySchema, location="query")
+    def foo(q):
+        ...
+
+
+    # webargs 6.x, Solution 2: instantiate a schema with unknown set
+    class QuerySchema(ma.Schema):
+        q = ma.fields.String()
+
+
+    @parser.use_kwargs(QuerySchema(unknown=ma.EXCLUDE), location="query")
+    def foo(q):
+        ...
+
+
+This also allows usage which passes the unknown parameters through, like so:
+
+.. code-block:: python
+
+    # webargs 6.x only! cannot be done in 5.x
+    class QuerySchema(ma.Schema):
+        q = ma.fields.String()
+
+
+    # will pass *all* query params through as "kwargs"
+    @parser.use_kwargs(QuerySchema(unknown=ma.INCLUDE), location="query")
+    def foo(q, **kwargs):
+        ...
+
+
+However, many types of request data are so-called "multidicts" -- dictionary-like
+types which can return one or multiple values. To handle `marshmallow.fields.List`
+and `webargs.fields.DelimitedList` fields correctly, passing list data, webargs
+must combine schema information with the raw request data. This is done in the
+:class:`MultiDictProxy <webargs.multidictproxy.MultiDictProxy>` type, which
+will often be passed to schemas.
+
+This means that if a schema has a `pre_load` hook which interacts with the data,
+it may need modifications. For example, a `flask` query string will be parsed
+into an `ImmutableMultiDict` type, which will break pre-load hooks which modify
+the data in-place. Such usages need rewrites like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    # flask query params is just an example -- applies to several types
+    from webargs.flaskparser import use_kwargs
+
+
+    class QuerySchema(ma.Schema):
+        q = ma.fields.String()
+
+        @ma.pre_load
+        def convert_nil_to_none(self, obj, **kwargs):
+            if obj.get("q") == "nil":
+                obj["q"] = None
+            return obj
+
+
+    @use_kwargs(QuerySchema, locations=("query",))
+    def foo(q):
+        ...
+
+
+    # webargs 6.x
+    class QuerySchema(ma.Schema):
+        q = ma.fields.String()
+
+        # unlike under 5.x, we cannot modify 'obj' in-place because writing
+        # to the MultiDictProxy will try to write to the underlying
+        # ImmutableMultiDict, which is not allowed
+        @ma.pre_load
+        def convert_nil_to_none(self, obj, **kwargs):
+            # creating a dict from a MultiDictProxy works well because it
+            # "unwraps" lists and delimited lists correctly
+            data = dict(obj)
+            if data.get("q") == "nil":
+                data["q"] = None
+            return data
+
+
+    @parser.use_kwargs(QuerySchema, location="query")
+    def foo(q):
+        ...
+
+
+DelimitedList Now Only Takes A String Input
+-------------------------------------------
+
+Combining `List` and string parsing functionality in a single type had some
+messy corner cases. For the most part, this should not require rewrites. But
+for APIs which need to allow both usages, rewrites are possible like so:
+
+.. code-block:: python
+
+    # webargs 5.x
+    # this allows ...?x=1&x=2&x=3
+    # as well as ...?x=1,2,3
+    @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, locations=("query",))
+    def foo(x):
+        ...
+
+
+    # webargs 6.x
+    # this accepts x=1,2,3 but NOT x=1&x=2&x=3
+    @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, location="query")
+    def foo(x):
+        ...
+
+
+    # webargs 6.x
+    # this accepts x=1,2,3 ; x=1&x=2&x=3 ; x=1,2&x=3
+    # to do this, it needs a post_load hook which will flatten out the list data
+    class UnpackingDelimitedListSchema(ma.Schema):
+        x = ma.fields.List(webargs.fields.DelimitedList(ma.fields.Int))
+
+        @ma.post_load
+        def flatten_lists(self, data, **kwargs):
+            new_x = []
+            for x in data["x"]:
+                new_x.extend(x)
+            data["x"] = new_x
+            return data
+
+
+    @parser.use_kwargs(UnpackingDelimitedListSchema, location="query")
+    def foo(x):
+        ...
+
+
+ValidationError Messages Are Namespaced Under The Location
+----------------------------------------------------------
+
+Code parsing ValidationError messages will notice a change in the messages
+produced by webargs.
+What would previously have come back with messages like `{"foo":["Not a valid integer."]}`
+will now have messages nested one layer deeper, like
+`{"json":{"foo":["Not a valid integer."]}}`.
+
+To rewrite code which was handling these errors, the handler will need to be
+prepared to traverse messages by one additional level. For example:
+
+.. code-block:: python
+
+    import logging
+
+    log = logging.getLogger(__name__)
+
+
+    # webargs 5.x
+    # logs debug messages like
+    #   bad value for 'foo': ["Not a valid integer."]
+    #   bad value for 'bar': ["Not a valid boolean."]
+    def log_invalid_parameters(validation_error):
+        for field, messages in validation_error.messages.items():
+            log.debug("bad value for '{}': {}".format(field, messages))
+
+
+    # webargs 6.x
+    # logs debug messages like
+    #   bad value for 'foo' [query]: ["Not a valid integer."]
+    #   bad value for 'bar' [json]: ["Not a valid boolean."]
+    def log_invalid_parameters(validation_error):
+        for location, fielddata in validation_error.messages.items():
+            for field, messages in fielddata.items():
+                log.debug("bad value for '{}' [{}]: {}".format(field, location, messages))
+
+
+Custom Error Handler Argument Names Changed
+-------------------------------------------
+
+If you define a custom error handler via `@parser.error_handler` the function
+arguments are now keyword-only and `status_code` and `headers` have been renamed
+`error_status_code` and `error_headers`.
+
+.. code-block:: python
+
+    # webargs 5.x
+    @parser.error_handler
+    def custom_handle_error(error, req, schema, status_code, headers):
+        ...
+
+
+    # webargs 6.x
+    @parser.error_handler
+    def custom_handle_error(error, req, schema, *, error_status_code, error_headers):
+        ...
+
+
+Some Functions Take Keyword-Only Arguments Now
+----------------------------------------------
+
+The signature of several methods has changed to have keyword-only arguments.
+For the most part, this should not require any changes, but here's a list of
+the changes.
+
+`parser.error_handler` methods:
+
+.. code-block:: python
+
+    # webargs 5.x
+    def handle_error(error, req, schema, status_code, headers):
+        ...
+
+
+    # webargs 6.x
+    def handle_error(error, req, schema, *, error_status_code, error_headers):
+        ...
+
+`parser.__init__` methods:
+
+.. code-block:: python
+
+    # webargs 5.x
+    def __init__(self, location=None, error_handler=None, schema_class=None):
+        ...
+
+
+    # webargs 6.x
+    def __init__(self, location=None, *, error_handler=None, schema_class=None):
+        ...
+
+`parser.parse`, `parser.use_args`, and `parser.use_kwargs` methods:
+
+
+.. code-block:: python
+
+    # webargs 5.x
+    def parse(
+        self,
+        argmap,
+        req=None,
+        location=None,
+        validate=None,
+        error_status_code=None,
+        error_headers=None,
+    ):
+        ...
+
+
+    # webargs 6.x
+    def parse(
+        self,
+        argmap,
+        req=None,
+        *,
+        location=None,
+        validate=None,
+        error_status_code=None,
+        error_headers=None
+    ):
+        ...
+
+
+    # webargs 5.x
+    def use_args(
+        self,
+        argmap,
+        req=None,
+        location=None,
+        as_kwargs=False,
+        validate=None,
+        error_status_code=None,
+        error_headers=None,
+    ):
+        ...
+
+
+    # webargs 6.x
+    def use_args(
+        self,
+        argmap,
+        req=None,
+        *,
+        location=None,
+        as_kwargs=False,
+        validate=None,
+        error_status_code=None,
+        error_headers=None
+    ):
+        ...
+
+
+    # use_kwargs is just an alias for use_args with as_kwargs=True
+
+and finally, the `dict2schema` function:
+
+.. code-block:: python
+
+    # webargs 5.x
+    def dict2schema(dct, schema_class=ma.Schema):
+        ...
+
+
+    # webargs 6.x
+    def dict2schema(dct, *, schema_class=ma.Schema):
+        ...
+
+
+PyramidParser Now Appends Arguments (Used To Prepend)
+-----------------------------------------------------
+
+`PyramidParser.use_args` was not conformant with the other parsers in webargs.
+While all other parsers added new arguments to the end of the argument list of
+a decorated view function, the Pyramid implementation added them to the front
+of the argument list.
+
+This has been corrected, but as a result pyramid views with `use_args` may need
+to be rewritten. The `request` object is always passed first in both versions,
+so the issue is only apparent with view functions taking other positional
+arguments.
+
+For example, imagine code with a decorator for passing user information,
+`pass_userinfo`, like so:
+
+.. code-block:: python
+
+    # a decorator which gets information about the authenticated user
+    def pass_userinfo(f):
+        def decorator(request, *args, **kwargs):
+            return f(request, get_userinfo(), *args, **kwargs)
+
+        return decorator
+
+You will see a behavioral change if `pass_userinfo` is called on a function
+decorated with `use_args`. The difference between the two versions will be like
+so:
+
+.. code-block:: python
+
+    from webargs.pyramidparser import use_args
+
+    # webargs 5.x
+    # pass_userinfo is called first, webargs sees positional arguments of
+    #   (userinfo,)
+    # and changes it to
+    #   (request, args, userinfo)
+    @pass_userinfo
+    @use_args({"q": ma.fields.String()}, locations=("query",))
+    def viewfunc(request, args, userinfo):
+        q = args.get("q")
+        ...
+
+
+    # webargs 6.x
+    # pass_userinfo is called first, webargs sees positional arguments of
+    #   (userinfo,)
+    # and changes it to
+    #   (request, userinfo, args)
+    @pass_userinfo
+    @use_args({"q": ma.fields.String()}, location="query")
+    def viewfunc(request, userinfo, args):
+        q = args.get("q")
+        ...
diff --git a/examples/aiohttp_example.py b/examples/aiohttp_example.py
index 76da321..d986467 100644
--- a/examples/aiohttp_example.py
+++ b/examples/aiohttp_example.py
@@ -25,8 +25,7 @@ hello_args = {"name": fields.Str(missing="Friend")}
 
 @use_args(hello_args)
 async def index(request, args):
-    """A welcome page.
-    """
+    """A welcome page."""
     return json_response({"message": "Welcome, {}!".format(args["name"])})
 
 
diff --git a/examples/annotations_example.py b/examples/annotations_example.py
index 10eb94f..134b29e 100644
--- a/examples/annotations_example.py
+++ b/examples/annotations_example.py
@@ -96,8 +96,8 @@ db = {"users": {}}
 
 
 @route("/", methods=["GET"])
-def index(name: fields.Str(missing="Friend")):
-    return {"message": "Hello, {}!".format(name)}
+def index(name: fields.Str(missing="Friend")):  # noqa: F821
+    return {"message": f"Hello, {name}!"}
 
 
 @route("/add", methods=["POST"])
diff --git a/examples/bottle_example.py b/examples/bottle_example.py
index 8037893..71c66a9 100644
--- a/examples/bottle_example.py
+++ b/examples/bottle_example.py
@@ -24,8 +24,7 @@ hello_args = {"name": fields.Str(missing="Friend")}
 
 @route("/", method="GET", apply=use_args(hello_args))
 def index(args):
-    """A welcome page.
-    """
+    """A welcome page."""
     return {"message": "Welcome, {}!".format(args["name"])}
 
 
diff --git a/examples/falcon_example.py b/examples/falcon_example.py
index c5fd596..1b5b973 100644
--- a/examples/falcon_example.py
+++ b/examples/falcon_example.py
@@ -27,7 +27,7 @@ from webargs.falconparser import use_args, use_kwargs, parser
 ### Middleware and hooks ###
 
 
-class JSONTranslator(object):
+class JSONTranslator:
     def process_response(self, req, resp, resource):
         if "result" not in req.context:
             return
@@ -44,7 +44,7 @@ def add_args(argmap, **kwargs):
 ### Resources ###
 
 
-class HelloResource(object):
+class HelloResource:
     """A welcome page."""
 
     hello_args = {"name": fields.Str(missing="Friend", location="query")}
@@ -54,7 +54,7 @@ class HelloResource(object):
         req.context["result"] = {"message": "Welcome, {}!".format(args["name"])}
 
 
-class AdderResource(object):
+class AdderResource:
     """An addition endpoint."""
 
     adder_args = {"x": fields.Float(required=True), "y": fields.Float(required=True)}
@@ -64,7 +64,7 @@ class AdderResource(object):
         req.context["result"] = {"result": x + y}
 
 
-class DateAddResource(object):
+class DateAddResource:
     """A datetime adder endpoint."""
 
     dateadd_args = {
diff --git a/examples/flask_example.py b/examples/flask_example.py
index 12c666e..80edb54 100644
--- a/examples/flask_example.py
+++ b/examples/flask_example.py
@@ -26,8 +26,7 @@ hello_args = {"name": fields.Str(missing="Friend")}
 @app.route("/", methods=["GET"])
 @use_args(hello_args)
 def index(args):
-    """A welcome page.
-    """
+    """A welcome page."""
     return jsonify({"message": "Welcome, {}!".format(args["name"])})
 
 
diff --git a/examples/flaskrestful_example.py b/examples/flaskrestful_example.py
index 0dfaa77..8e83d80 100644
--- a/examples/flaskrestful_example.py
+++ b/examples/flaskrestful_example.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """A simple number and datetime addition JSON API.
 Run the app:
 
@@ -70,7 +69,7 @@ class DateAddResource(Resource):
 
 # This error handler is necessary for usage with Flask-RESTful
 @parser.error_handler
-def handle_request_parsing_error(err, req, schema, error_status_code, error_headers):
+def handle_request_parsing_error(err, req, schema, *, error_status_code, error_headers):
     """webargs error handler that uses Flask-RESTful's abort function to return
     a JSON error response to the client.
     """
diff --git a/examples/pyramid_example.py b/examples/pyramid_example.py
index 91c23ce..4fcbebf 100644
--- a/examples/pyramid_example.py
+++ b/examples/pyramid_example.py
@@ -29,8 +29,7 @@ hello_args = {"name": fields.Str(missing="Friend")}
 @view_config(route_name="hello", request_method="GET", renderer="json")
 @use_args(hello_args)
 def index(request, args):
-    """A welcome page.
-    """
+    """A welcome page."""
     return {"message": "Welcome, {}!".format(args["name"])}
 
 
@@ -78,5 +77,5 @@ if __name__ == "__main__":
     app = config.make_wsgi_app()
     port = 5001
     server = make_server("0.0.0.0", port, app)
-    print("Serving on port {}".format(port))
+    print(f"Serving on port {port}")
     server.serve_forever()
diff --git a/examples/requirements.txt b/examples/requirements.txt
index 62d4d40..c74ca8a 100644
--- a/examples/requirements.txt
+++ b/examples/requirements.txt
@@ -1,7 +1,6 @@
-python-dateutil==2.8.0
+python-dateutil==2.8.1
 Flask
 bottle
 tornado
-webapp2
 flask-restful
 pyramid
diff --git a/examples/schema_example.py b/examples/schema_example.py
index a52d0f2..3e74d9a 100644
--- a/examples/schema_example.py
+++ b/examples/schema_example.py
@@ -61,8 +61,8 @@ db = {"users": {}}
 
 def use_schema(schema_cls, list_view=False, locations=None):
     """View decorator for using a marshmallow schema to
-        (1) parse a request's input and
-        (2) serializing the view's output to a JSON response.
+    (1) parse a request's input and
+    (2) serializing the view's output to a JSON response.
     """
 
     def decorator(func):
diff --git a/examples/tornado_example.py b/examples/tornado_example.py
index 840f84a..e748f77 100644
--- a/examples/tornado_example.py
+++ b/examples/tornado_example.py
@@ -85,5 +85,5 @@ if __name__ == "__main__":
     )
     port = 5001
     app.listen(port)
-    print("Serving on port {}".format(port))
+    print(f"Serving on port {port}")
     tornado.ioloop.IOLoop.instance().start()
diff --git a/examples/webapp2_example.py b/examples/webapp2_example.py
deleted file mode 100755
index 9d0869f..0000000
--- a/examples/webapp2_example.py
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""A Hello, World! example using Webapp2 in a Google App Engine environment
-
-Run the app:
-
-    $ python webapp2_example.py
-
-Try the following with httpie (a cURL-like utility, http://httpie.org):
-
-    $ pip install httpie
-    $ http GET :5001/hello
-    $ http GET :5001/hello name==Ada
-    $ http POST :5001/hello_dict name=awesome
-    $ http POST :5001/hello_dict
-"""
-
-import webapp2
-
-from webargs import fields
-from webargs.webapp2parser import use_args, use_kwargs
-
-hello_args = {"name": fields.Str(missing="World")}
-
-
-class MainPage(webapp2.RequestHandler):
-    @use_args(hello_args)
-    def get_args(self, args):
-        # args is a dict of parsed items from hello_args
-        self.response.write("Hello, {name}!".format(name=args["name"]))
-
-    @use_kwargs(hello_args)
-    def get_kwargs(self, name=None):
-        self.response.write("Hello, {name}!".format(name=name))
-
-
-app = webapp2.WSGIApplication(
-    [
-        webapp2.Route(r"/hello", MainPage, handler_method="get_args"),
-        webapp2.Route(r"/hello_dict", MainPage, handler_method="get_kwargs"),
-    ],
-    debug=True,
-)
-
-
-if __name__ == "__main__":
-    from wsgiref.simple_server import make_server
-
-    httpd = make_server("", 5001, app)
-    httpd.serve_forever()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..332d869
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[tool.black]
+line-length = 88
+target-version = ['py35', 'py36', 'py37', 'py38']
diff --git a/setup.cfg b/setup.cfg
index 6f10b32..7c247eb 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,10 +2,7 @@
 license_files = LICENSE
 
 [bdist_wheel]
-# We build separate wheels for
-# Python 2 and 3 because of the conditional
-# dependency on simplejson
-universal = 0
+universal = 1
 
 [flake8]
 ignore = E203, E266, E501, W503
diff --git a/setup.py b/setup.py
index 993ada1..101d3a4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,39 +1,31 @@
-# -*- coding: utf-8 -*-
-import sys
 import re
 from setuptools import setup, find_packages
 
-INSTALL_REQUIRES = ["marshmallow>=2.15.2"]
-if sys.version_info[0] < 3:
-    INSTALL_REQUIRES.append("simplejson>=2.1.0")
-
 FRAMEWORKS = [
-    "Flask>=0.12.2",
-    "Django>=1.11.16",
+    "Flask>=0.12.5",
+    "Django>=2.2.0",
     "bottle>=0.12.13",
     "tornado>=4.5.2",
     "pyramid>=1.9.1",
-    "webapp2>=3.0.0b1",
-    "falcon>=1.4.0,<2.0",
-    'aiohttp>=3.0.0; python_version >= "3.5"',
+    "falcon>=2.0.0",
+    "aiohttp>=3.0.8",
 ]
 EXTRAS_REQUIRE = {
     "frameworks": FRAMEWORKS,
     "tests": [
         "pytest",
-        "mock",
-        "webtest==2.0.33",
-        'webtest-aiohttp==2.0.0; python_version >= "3.5"',
-        'pytest-aiohttp>=0.3.0; python_version >= "3.5"',
+        "webtest==2.0.35",
+        "webtest-aiohttp==2.0.0",
+        "pytest-aiohttp>=0.3.0",
     ]
     + FRAMEWORKS,
     "lint": [
-        'mypy==0.730; python_version >= "3.5"',
-        "flake8==3.7.8",
-        'flake8-bugbear==19.8.0; python_version >= "3.5"',
-        "pre-commit~=1.17",
+        "mypy==0.790",
+        "flake8==3.8.4",
+        "flake8-bugbear==20.11.1",
+        "pre-commit~=2.4",
     ],
-    "docs": ["Sphinx==2.2.0", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.7.3"]
+    "docs": ["Sphinx==3.3.1", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.8.0"]
     + FRAMEWORKS,
 }
 EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
@@ -44,7 +36,7 @@ def find_version(fname):
     Raises RuntimeError if not found.
     """
     version = ""
-    with open(fname, "r") as fp:
+    with open(fname) as fp:
         reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]')
         for line in fp:
             m = reg.match(line)
@@ -68,7 +60,7 @@ setup(
     description=(
         "Declarative parsing and validation of HTTP request objects, "
         "with built-in support for popular web frameworks, including "
-        "Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp."
+        "Flask, Django, Bottle, Tornado, Pyramid, Falcon, and aiohttp."
     ),
     long_description=read("README.rst"),
     author="Steven Loria",
@@ -76,7 +68,8 @@ setup(
     url="https://github.com/marshmallow-code/webargs",
     packages=find_packages("src"),
     package_dir={"": "src"},
-    install_requires=INSTALL_REQUIRES,
+    package_data={"webargs": ["py.typed"]},
+    install_requires=["marshmallow>=3.0.0"],
     extras_require=EXTRAS_REQUIRE,
     license="MIT",
     zip_safe=False,
@@ -88,7 +81,6 @@ setup(
         "bottle",
         "tornado",
         "aiohttp",
-        "webapp2",
         "request",
         "arguments",
         "validation",
@@ -97,17 +89,18 @@ setup(
         "api",
         "marshmallow",
     ),
+    python_requires=">=3.6",
     classifiers=[
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Developers",
         "License :: OSI Approved :: MIT License",
         "Natural Language :: English",
-        "Programming Language :: Python :: 2",
-        "Programming Language :: Python :: 2.7",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3 :: Only",
         "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
         "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
     ],
diff --git a/src/webargs/__init__.py b/src/webargs/__init__.py
index b91dbc5..efadffc 100755
--- a/src/webargs/__init__.py
+++ b/src/webargs/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 from distutils.version import LooseVersion
 from marshmallow.utils import missing
 
@@ -6,9 +5,8 @@ from marshmallow.utils import missing
 from marshmallow import validate
 
 from webargs.core import ValidationError
-from webargs.dict2schema import dict2schema
 from webargs import fields
 
-__version__ = "5.5.3"
+__version__ = "7.0.1"
 __version_info__ = tuple(LooseVersion(__version__).version)
-__all__ = ("dict2schema", "ValidationError", "fields", "missing", "validate")
+__all__ = ("ValidationError", "fields", "missing", "validate")
diff --git a/src/webargs/aiohttpparser.py b/src/webargs/aiohttpparser.py
index 9e76e05..9478026 100644
--- a/src/webargs/aiohttpparser.py
+++ b/src/webargs/aiohttpparser.py
@@ -25,17 +25,16 @@ Example: ::
 import typing
 
 from aiohttp import web
-from aiohttp.web import Request
 from aiohttp import web_exceptions
-from marshmallow import Schema, ValidationError
-from marshmallow.fields import Field
+from marshmallow import Schema, ValidationError, RAISE
 
 from webargs import core
 from webargs.core import json
 from webargs.asyncparser import AsyncParser
+from webargs.multidictproxy import MultiDictProxy
 
 
-def is_json_request(req: Request) -> bool:
+def is_json_request(req) -> bool:
     content_type = req.content_type
     return core.is_json(content_type)
 
@@ -72,63 +71,68 @@ del _find_exceptions
 class AIOHTTPParser(AsyncParser):
     """aiohttp request argument parser."""
 
+    DEFAULT_UNKNOWN_BY_LOCATION = {
+        "match_info": RAISE,
+        "path": RAISE,
+        **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
+    }
     __location_map__ = dict(
-        match_info="parse_match_info",
-        path="parse_match_info",
-        **core.Parser.__location_map__
+        match_info="load_match_info",
+        path="load_match_info",
+        **core.Parser.__location_map__,
     )
 
-    def parse_querystring(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a querystring value from the request."""
-        return core.get_value(req.query, name, field)
-
-    async def parse_form(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a form value from the request."""
-        post_data = self._cache.get("post")
-        if post_data is None:
-            self._cache["post"] = await req.post()
-        return core.get_value(self._cache["post"], name, field)
-
-    async def parse_json(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            if not (req.body_exists and is_json_request(req)):
+    def load_querystring(self, req, schema: Schema) -> MultiDictProxy:
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.query, schema)
+
+    async def load_form(self, req, schema: Schema) -> MultiDictProxy:
+        """Return form values from the request as a MultiDictProxy."""
+        post_data = await req.post()
+        return MultiDictProxy(post_data, schema)
+
+    async def load_json_or_form(
+        self, req, schema: Schema
+    ) -> typing.Union[typing.Dict, MultiDictProxy]:
+        data = await self.load_json(req, schema)
+        if data is not core.missing:
+            return data
+        return await self.load_form(req, schema)
+
+    async def load_json(self, req, schema: Schema):
+        """Return a parsed json payload from the request."""
+        if not (req.body_exists and is_json_request(req)):
+            return core.missing
+        try:
+            return await req.json(loads=json.loads)
+        except json.JSONDecodeError as exc:
+            if exc.doc == "":
                 return core.missing
-            try:
-                json_data = await req.json(loads=json.loads)
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    return self.handle_invalid_json_error(e, req)
-            except UnicodeDecodeError as e:
-                return self.handle_invalid_json_error(e, req)
-
-            self._cache["json"] = json_data
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_headers(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a value from the header data."""
-        return core.get_value(req.headers, name, field)
-
-    def parse_cookies(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a value from the cookiejar."""
-        return core.get_value(req.cookies, name, field)
-
-    def parse_files(self, req: Request, name: str, field: Field) -> None:
+            return self._handle_invalid_json_error(exc, req)
+        except UnicodeDecodeError as exc:
+            return self._handle_invalid_json_error(exc, req)
+
+    def load_headers(self, req, schema: Schema) -> MultiDictProxy:
+        """Return headers from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.headers, schema)
+
+    def load_cookies(self, req, schema: Schema) -> MultiDictProxy:
+        """Return cookies from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.cookies, schema)
+
+    def load_files(self, req, schema: Schema) -> typing.NoReturn:
         raise NotImplementedError(
-            "parse_files is not implemented. You may be able to use parse_form for "
+            "load_files is not implemented. You may be able to use load_form for "
             "parsing upload data."
         )
 
-    def parse_match_info(self, req: Request, name: str, field: Field) -> typing.Any:
-        """Pull a value from the request's ``match_info``."""
-        return core.get_value(req.match_info, name, field)
+    def load_match_info(self, req, schema: Schema) -> typing.Mapping:
+        """Load the request's ``match_info``."""
+        return req.match_info
 
     def get_request_from_view_args(
         self, view: typing.Callable, args: typing.Iterable, kwargs: typing.Mapping
-    ) -> Request:
+    ):
         """Get request object from a handler function or method. Used internally by
         ``use_args`` and ``use_kwargs``.
         """
@@ -137,20 +141,22 @@ class AIOHTTPParser(AsyncParser):
             if isinstance(arg, web.Request):
                 req = arg
                 break
-            elif isinstance(arg, web.View):
+            if isinstance(arg, web.View):
                 req = arg.request
                 break
-        assert isinstance(req, web.Request), "Request argument not found for handler"
+        if not isinstance(req, web.Request):
+            raise ValueError("Request argument not found for handler")
         return req
 
     def handle_error(
         self,
         error: ValidationError,
-        req: Request,
+        req,
         schema: Schema,
-        error_status_code: typing.Union[int, None] = None,
-        error_headers: typing.Union[typing.Mapping[str, str], None] = None,
-    ) -> "typing.NoReturn":
+        *,
+        error_status_code: typing.Optional[int],
+        error_headers: typing.Optional[typing.Mapping[str, str]]
+    ) -> typing.NoReturn:
         """Handle ValidationErrors and return a JSON response of error messages
         to the client.
         """
@@ -158,7 +164,7 @@ class AIOHTTPParser(AsyncParser):
             error_status_code or self.DEFAULT_VALIDATION_STATUS
         )
         if not error_class:
-            raise LookupError("No exception for {0}".format(error_status_code))
+            raise LookupError(f"No exception for {error_status_code}")
         headers = error_headers
         raise error_class(
             body=json.dumps(error.messages).encode("utf-8"),
@@ -166,13 +172,13 @@ class AIOHTTPParser(AsyncParser):
             content_type="application/json",
         )
 
-    def handle_invalid_json_error(
+    def _handle_invalid_json_error(
         self,
         error: typing.Union[json.JSONDecodeError, UnicodeDecodeError],
-        req: Request,
+        req,
         *args,
         **kwargs
-    ) -> "typing.NoReturn":
+    ) -> typing.NoReturn:
         error_class = exception_map[400]
         messages = {"json": ["Invalid JSON body."]}
         raise error_class(
diff --git a/src/webargs/asyncparser.py b/src/webargs/asyncparser.py
index 8236901..a03373a 100644
--- a/src/webargs/asyncparser.py
+++ b/src/webargs/asyncparser.py
@@ -1,4 +1,4 @@
-"""Asynchronous request parser. Compatible with Python>=3.5."""
+"""Asynchronous request parser."""
 import asyncio
 import functools
 import inspect
@@ -6,15 +6,11 @@ import typing
 from collections.abc import Mapping
 
 from marshmallow import Schema, ValidationError
-from marshmallow.fields import Field
 import marshmallow as ma
-from marshmallow.utils import missing
 
 from webargs import core
 
-Request = typing.TypeVar("Request")
-ArgMap = typing.Union[Schema, typing.Mapping[str, Field]]
-Validate = typing.Union[typing.Callable, typing.Iterable[typing.Callable]]
+AsyncErrorHandler = typing.Callable[..., typing.Awaitable[typing.NoReturn]]
 
 
 class AsyncParser(core.Parser):
@@ -22,111 +18,140 @@ class AsyncParser(core.Parser):
     either coroutines or regular methods.
     """
 
-    async def _parse_request(
-        self, schema: Schema, req: Request, locations: typing.Iterable
-    ) -> typing.Union[dict, list]:
-        if schema.many:
-            assert (
-                "json" in locations
-            ), "schema.many=True is only supported for JSON location"
-            # The ad hoc Nested field is more like a workaround or a helper,
-            # and it servers its purpose fine. However, if somebody has a desire
-            # to re-design the support of bulk-type arguments, go ahead.
-            parsed = await self.parse_arg(
-                name="json",
-                field=ma.fields.Nested(schema, many=True),
-                req=req,
-                locations=locations,
-            )
-            if parsed is missing:
-                parsed = []
-        else:
-            argdict = schema.fields
-            parsed = {}
-            for argname, field_obj in argdict.items():
-                if core.MARSHMALLOW_VERSION_INFO[0] < 3:
-                    parsed_value = await self.parse_arg(
-                        argname, field_obj, req, locations
-                    )
-                    # If load_from is specified on the field, try to parse from that key
-                    if parsed_value is missing and field_obj.load_from:
-                        parsed_value = await self.parse_arg(
-                            field_obj.load_from, field_obj, req, locations
-                        )
-                        argname = field_obj.load_from
-                else:
-                    argname = field_obj.data_key or argname
-                    parsed_value = await self.parse_arg(
-                        argname, field_obj, req, locations
-                    )
-                if parsed_value is not missing:
-                    parsed[argname] = parsed_value
-        return parsed
-
     # TODO: Lots of duplication from core.Parser here. Rethink.
     async def parse(
         self,
-        argmap: ArgMap,
-        req: Request = None,
-        locations: typing.Iterable = None,
-        validate: Validate = None,
-        error_status_code: typing.Union[int, None] = None,
-        error_headers: typing.Union[typing.Mapping[str, str], None] = None,
-    ) -> typing.Union[typing.Mapping, None]:
+        argmap: core.ArgMap,
+        req: typing.Optional[core.Request] = None,
+        *,
+        location: typing.Optional[str] = None,
+        unknown: typing.Optional[str] = core._UNKNOWN_DEFAULT_PARAM,
+        validate: core.ValidateArg = None,
+        error_status_code: typing.Optional[int] = None,
+        error_headers: typing.Optional[typing.Mapping[str, str]] = None
+    ) -> typing.Optional[typing.Mapping]:
         """Coroutine variant of `webargs.core.Parser`.
 
         Receives the same arguments as `webargs.core.Parser.parse`.
         """
-        self.clear_cache()  # in case someone used `parse_*()`
         req = req if req is not None else self.get_default_request()
-        assert req is not None, "Must pass req object"
+        location = location or self.location
+        unknown = (
+            unknown
+            if unknown != core._UNKNOWN_DEFAULT_PARAM
+            else (
+                self.unknown
+                if self.unknown != core._UNKNOWN_DEFAULT_PARAM
+                else self.DEFAULT_UNKNOWN_BY_LOCATION.get(location)
+            )
+        )
+        load_kwargs: typing.Dict[str, typing.Any] = (
+            {"unknown": unknown} if unknown else {}
+        )
+        if req is None:
+            raise ValueError("Must pass req object")
         data = None
         validators = core._ensure_list_of_callables(validate)
         schema = self._get_schema(argmap, req)
         try:
-            parsed = await self._parse_request(
-                schema=schema, req=req, locations=locations or self.locations
+            location_data = await self._load_location_data(
+                schema=schema, req=req, location=location
             )
-            result = schema.load(parsed)
-            data = result.data if core.MARSHMALLOW_VERSION_INFO[0] < 3 else result
+            data = schema.load(location_data, **load_kwargs)
             self._validate_arguments(data, validators)
         except ma.exceptions.ValidationError as error:
-            await self._on_validation_error(
-                error, req, schema, error_status_code, error_headers
+            await self._async_on_validation_error(
+                error,
+                req,
+                schema,
+                location,
+                error_status_code=error_status_code,
+                error_headers=error_headers,
             )
         return data
 
-    async def _on_validation_error(
+    async def _load_location_data(self, schema, req, location):
+        """Return a dictionary-like object for the location on the given request.
+
+        Needs to have the schema in hand in order to correctly handle loading
+        lists from multidict objects and `many=True` schemas.
+        """
+        loader_func = self._get_loader(location)
+        if asyncio.iscoroutinefunction(loader_func):
+            data = await loader_func(req, schema)
+        else:
+            data = loader_func(req, schema)
+
+        # when the desired location is empty (no data), provide an empty
+        # dict as the default so that optional arguments in a location
+        # (e.g. optional JSON body) work smoothly
+        if data is core.missing:
+            data = {}
+        return data
+
+    async def _async_on_validation_error(
         self,
         error: ValidationError,
-        req: Request,
+        req: core.Request,
         schema: Schema,
-        error_status_code: typing.Union[int, None],
-        error_headers: typing.Union[typing.Mapping[str, str], None] = None,
-    ) -> None:
+        location: str,
+        *,
+        error_status_code: typing.Optional[int],
+        error_headers: typing.Optional[typing.Mapping[str, str]]
+    ) -> typing.NoReturn:
+        # rewrite messages to be namespaced under the location which created
+        # them
+        # e.g. {"json":{"foo":["Not a valid integer."]}}
+        #      instead of
+        #      {"foo":["Not a valid integer."]}
+        error.messages = {location: error.messages}
         error_handler = self.error_callback or self.handle_error
-        await error_handler(error, req, schema, error_status_code, error_headers)
+        # an async error handler was registered, await it
+        if inspect.iscoroutinefunction(error_handler):
+            async_error_handler = typing.cast(AsyncErrorHandler, error_handler)
+            await async_error_handler(
+                error,
+                req,
+                schema,
+                error_status_code=error_status_code,
+                error_headers=error_headers,
+            )
+            # workaround for mypy not understanding `await Awaitable[NoReturn]`
+            # see: https://github.com/python/mypy/issues/8974
+            raise NotImplementedError("unreachable")
+        # the error handler was synchronous (e.g. Parser.handle_error) so it
+        # will raise an error
+        else:
+            error_handler(
+                error,
+                req,
+                schema,
+                error_status_code=error_status_code,
+                error_headers=error_headers,
+            )
 
     def use_args(
         self,
-        argmap: ArgMap,
-        req: typing.Optional[Request] = None,
-        locations: typing.Iterable = None,
+        argmap: core.ArgMap,
+        req: typing.Optional[core.Request] = None,
+        *,
+        location: str = None,
+        unknown=core._UNKNOWN_DEFAULT_PARAM,
         as_kwargs: bool = False,
-        validate: Validate = None,
+        validate: core.ValidateArg = None,
         error_status_code: typing.Optional[int] = None,
-        error_headers: typing.Union[typing.Mapping[str, str], None] = None,
+        error_headers: typing.Optional[typing.Mapping[str, str]] = None
     ) -> typing.Callable[..., typing.Callable]:
         """Decorator that injects parsed arguments into a view function or method.
 
         Receives the same arguments as `webargs.core.Parser.use_args`.
         """
-        locations = locations or self.locations
+        location = location or self.location
         request_obj = req
         # Optimization: If argmap is passed as a dictionary, we only need
         # to generate a Schema once
         if isinstance(argmap, Mapping):
-            argmap = core.dict2schema(argmap, self.schema_class)()
+            argmap = self.schema_class.from_dict(argmap)()
 
         def decorator(func: typing.Callable) -> typing.Callable:
             req_ = request_obj
@@ -143,18 +168,16 @@ class AsyncParser(core.Parser):
                     parsed_args = await self.parse(
                         argmap,
                         req=req_obj,
-                        locations=locations,
+                        location=location,
+                        unknown=unknown,
                         validate=validate,
                         error_status_code=error_status_code,
                         error_headers=error_headers,
                     )
-                    if as_kwargs:
-                        kwargs.update(parsed_args or {})
-                        return await func(*args, **kwargs)
-                    else:
-                        # Add parsed_args after other positional arguments
-                        new_args = args + (parsed_args,)
-                        return await func(*new_args, **kwargs)
+                    args, kwargs = self._update_args_kwargs(
+                        args, kwargs, parsed_args, as_kwargs
+                    )
+                    return await func(*args, **kwargs)
 
             else:
 
@@ -168,53 +191,17 @@ class AsyncParser(core.Parser):
                     parsed_args = yield from self.parse(  # type: ignore
                         argmap,
                         req=req_obj,
-                        locations=locations,
+                        location=location,
+                        unknown=unknown,
                         validate=validate,
                         error_status_code=error_status_code,
                         error_headers=error_headers,
                     )
-                    if as_kwargs:
-                        kwargs.update(parsed_args)
-                        return func(*args, **kwargs)  # noqa: B901
-                    else:
-                        # Add parsed_args after other positional arguments
-                        new_args = args + (parsed_args,)
-                        return func(*new_args, **kwargs)
+                    args, kwargs = self._update_args_kwargs(
+                        args, kwargs, parsed_args, as_kwargs
+                    )
+                    return func(*args, **kwargs)
 
             return wrapper
 
         return decorator
-
-    def use_kwargs(self, *args, **kwargs) -> typing.Callable:
-        """Decorator that injects parsed arguments into a view function or method.
-
-        Receives the same arguments as `webargs.core.Parser.use_kwargs`.
-
-        """
-        return super().use_kwargs(*args, **kwargs)
-
-    async def parse_arg(
-        self, name: str, field: Field, req: Request, locations: typing.Iterable = None
-    ) -> typing.Any:
-        location = field.metadata.get("location")
-        if location:
-            locations_to_check = self._validated_locations([location])
-        else:
-            locations_to_check = self._validated_locations(locations or self.locations)
-
-        for location in locations_to_check:
-            value = await self._get_value(name, field, req=req, location=location)
-            # Found the value; validate and return it
-            if value is not core.missing:
-                return value
-        return core.missing
-
-    async def _get_value(
-        self, name: str, argobj: Field, req: Request, location: str
-    ) -> typing.Any:
-        function = self._get_handler(location)
-        if asyncio.iscoroutinefunction(function):
-            value = await function(req, name, argobj)
-        else:
-            value = function(req, name, argobj)
-        return value
diff --git a/src/webargs/bottleparser.py b/src/webargs/bottleparser.py
index 310aa38..3cfd299 100644
--- a/src/webargs/bottleparser.py
+++ b/src/webargs/bottleparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Bottle request argument parsing module.
 
 Example: ::
@@ -20,58 +19,60 @@ Example: ::
 import bottle
 
 from webargs import core
-from webargs.core import json
+from webargs.multidictproxy import MultiDictProxy
 
 
 class BottleParser(core.Parser):
     """Bottle.py request argument parser."""
 
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return core.get_value(req.query, name, field)
+    def _handle_invalid_json_error(self, error, req, *args, **kwargs):
+        raise bottle.HTTPError(
+            status=400, body={"json": ["Invalid JSON body."]}, exception=error
+        )
+
+    def _raw_load_json(self, req):
+        """Read a json payload from the request."""
+        try:
+            data = req.json
+        except AttributeError:
+            return core.missing
+
+        # unfortunately, bottle does not distinguish between an emtpy body, "",
+        # and a body containing the valid JSON value null, "null"
+        # so these can't be properly disambiguated
+        # as our best-effort solution, treat None as missing and ignore the
+        # (admittedly unusual) "null" case
+        # see: https://github.com/bottlepy/bottle/issues/1160
+        if data is None:
+            return core.missing
+        return data
+
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.query, schema)
 
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request."""
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy."""
         # For consistency with other parsers' behavior, don't attempt to
         #  parse if content-type is mismatched.
         #  TODO: Make this check more specific
         if core.is_json(req.content_type):
             return core.missing
-        return core.get_value(req.forms, name, field)
-
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            try:
-                self._cache["json"] = json_data = req.json
-            except AttributeError:
-                return core.missing
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    return self.handle_invalid_json_error(e, req)
-            except UnicodeDecodeError as e:
-                return self.handle_invalid_json_error(e, req)
-
-            if json_data is None:
-                return core.missing
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_headers(self, req, name, field):
-        """Pull a value from the header data."""
-        return core.get_value(req.headers, name, field)
-
-    def parse_cookies(self, req, name, field):
-        """Pull a value from the cookiejar."""
-        return req.get_cookie(name)
-
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
-        return core.get_value(req.files, name, field)
-
-    def handle_error(self, error, req, schema, error_status_code, error_headers):
+        return MultiDictProxy(req.forms, schema)
+
+    def load_headers(self, req, schema):
+        """Return headers from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.headers, schema)
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request."""
+        return req.cookies
+
+    def load_files(self, req, schema):
+        """Return files from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.files, schema)
+
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         """Handles errors during parsing. Aborts the current request with a
         400 error.
         """
@@ -83,11 +84,6 @@ class BottleParser(core.Parser):
             exception=error,
         )
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
-        raise bottle.HTTPError(
-            status=400, body={"json": ["Invalid JSON body."]}, exception=error
-        )
-
     def get_default_request(self):
         """Override to use bottle's thread-local request object by default."""
         return bottle.request
diff --git a/src/webargs/compat.py b/src/webargs/compat.py
deleted file mode 100644
index 5676a1e..0000000
--- a/src/webargs/compat.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# -*- coding: utf-8 -*-
-# flake8: noqa
-import sys
-from distutils.version import LooseVersion
-
-import marshmallow as ma
-
-MARSHMALLOW_VERSION_INFO = tuple(LooseVersion(ma.__version__).version)  # type: tuple
-PY2 = int(sys.version_info[0]) == 2
-
-if PY2:
-    from collections import Mapping
-
-    basestring = basestring
-    text_type = unicode
-    iteritems = lambda d: d.iteritems()
-else:
-    from collections.abc import Mapping
-
-    basestring = (str, bytes)
-    text_type = str
-    iteritems = lambda d: d.items()
diff --git a/src/webargs/core.py b/src/webargs/core.py
index fe2f39b..25080ee 100644
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -1,23 +1,13 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
 import functools
-import inspect
+import typing
 import logging
-import warnings
-from copy import copy
-
-try:
-    import simplejson as json
-except ImportError:
-    import json  # type: ignore
+from collections.abc import Mapping
+import json
 
 import marshmallow as ma
 from marshmallow import ValidationError
-from marshmallow.utils import missing, is_collection
+from marshmallow.utils import missing
 
-from webargs.compat import Mapping, iteritems, MARSHMALLOW_VERSION_INFO
-from webargs.dict2schema import dict2schema
 from webargs.fields import DelimitedList
 
 logger = logging.getLogger(__name__)
@@ -25,40 +15,60 @@ logger = logging.getLogger(__name__)
 
 __all__ = [
     "ValidationError",
-    "dict2schema",
     "is_multiple",
     "Parser",
-    "get_value",
     "missing",
     "parse_json",
 ]
 
 
-DEFAULT_VALIDATION_STATUS = 422  # type: int
+Request = typing.TypeVar("Request")
+ArgMap = typing.Union[
+    ma.Schema,
+    typing.Mapping[str, ma.fields.Field],
+    typing.Callable[[Request], ma.Schema],
+]
+ValidateArg = typing.Union[None, typing.Callable, typing.Iterable[typing.Callable]]
+CallableList = typing.List[typing.Callable]
+ErrorHandler = typing.Callable[..., typing.NoReturn]
+# generic type var with no particular meaning
+T = typing.TypeVar("T")
+
+
+# a value used as the default for arguments, so that when `None` is passed, it
+# can be distinguished from the default value
+_UNKNOWN_DEFAULT_PARAM = "_default"
 
+DEFAULT_VALIDATION_STATUS: int = 422
 
-def _callable_or_raise(obj):
+
+def _iscallable(x) -> bool:
+    # workaround for
+    #   https://github.com/python/mypy/issues/9778
+    return callable(x)
+
+
+def _callable_or_raise(obj: typing.Optional[T]) -> typing.Optional[T]:
     """Makes sure an object is callable if it is not ``None``. If not
     callable, a ValueError is raised.
     """
-    if obj and not callable(obj):
-        raise ValueError("{0!r} is not callable.".format(obj))
-    else:
-        return obj
+    if obj and not _iscallable(obj):
+        raise ValueError(f"{obj!r} is not callable.")
+    return obj
 
 
-def is_multiple(field):
+def is_multiple(field: ma.fields.Field) -> bool:
     """Return whether or not `field` handles repeated/multi-value arguments."""
     return isinstance(field, ma.fields.List) and not isinstance(field, DelimitedList)
 
 
-def get_mimetype(content_type):
-    return content_type.split(";")[0].strip() if content_type else None
+def get_mimetype(content_type: str) -> str:
+    return content_type.split(";")[0].strip()
 
 
 # Adapted from werkzeug:
 # https://github.com/mitsuhiko/werkzeug/blob/master/werkzeug/wrappers.py
-def is_json(mimetype):
+def is_json(mimetype: typing.Optional[str]) -> bool:
     """Indicates if this mimetype is JSON or not.  By default a request
     is considered to include JSON data if the mimetype is
     ``application/json`` or ``application/*+json``.
@@ -74,217 +84,165 @@ def is_json(mimetype):
     return False
 
 
-def get_value(data, name, field, allow_many_nested=False):
-    """Get a value from a dictionary. Handles ``MultiDict`` types when
-    ``field`` handles repeated/multi-value arguments.
-    If the value is not found, return `missing`.
-
-    :param object data: Mapping (e.g. `dict`) or list-like instance to
-        pull the value from.
-    :param str name: Name of the key.
-    :param bool allow_many_nested: Whether to allow a list of nested objects
-        (it is valid only for JSON format, so it is set to True in ``parse_json``
-        methods).
-    """
-    missing_value = missing
-    if allow_many_nested and isinstance(field, ma.fields.Nested) and field.many:
-        if is_collection(data):
-            return data
-
-    if not hasattr(data, "get"):
-        return missing_value
-
-    multiple = is_multiple(field)
-    val = data.get(name, missing_value)
-    if multiple and val is not missing:
-        if hasattr(data, "getlist"):
-            return data.getlist(name)
-        elif hasattr(data, "getall"):
-            return data.getall(name)
-        elif isinstance(val, (list, tuple)):
-            return val
-        if val is None:
-            return None
-        else:
-            return [val]
-    return val
-
-
-def parse_json(s, encoding="utf-8"):
-    if isinstance(s, bytes):
+def parse_json(s: typing.AnyStr, *, encoding: str = "utf-8") -> typing.Any:
+    if isinstance(s, str):
+        decoded = s
+    else:
         try:
-            s = s.decode(encoding)
-        except UnicodeDecodeError as e:
+            decoded = s.decode(encoding)
+        except UnicodeDecodeError as exc:
             raise json.JSONDecodeError(
-                "Bytes decoding error : {}".format(e.reason),
-                doc=str(e.object),
-                pos=e.start,
+                f"Bytes decoding error : {exc.reason}",
+                doc=str(exc.object),
+                pos=exc.start,
             )
-    return json.loads(s)
+    return json.loads(decoded)
 
 
-def _ensure_list_of_callables(obj):
+def _ensure_list_of_callables(obj: typing.Any) -> CallableList:
     if obj:
         if isinstance(obj, (list, tuple)):
-            validators = obj
+            validators = typing.cast(CallableList, list(obj))
         elif callable(obj):
             validators = [obj]
         else:
-            raise ValueError(
-                "{0!r} is not a callable or list of callables.".format(obj)
-            )
+            raise ValueError(f"{obj!r} is not a callable or list of callables.")
     else:
         validators = []
     return validators
 
 
-class Parser(object):
+class Parser:
     """Base parser class that provides high-level implementation for parsing
     a request.
 
-    Descendant classes must provide lower-level implementations for parsing
-    different locations, e.g. ``parse_json``, ``parse_querystring``, etc.
+    Descendant classes must provide lower-level implementations for reading
+    data from  different locations, e.g. ``load_json``, ``load_querystring``,
+    etc.
 
-    :param tuple locations: Default locations to parse.
+    :param str location: Default location to use for data
+    :param str unknown: A default value to pass for ``unknown`` when calling the
+        schema's ``load`` method. Defaults to EXCLUDE for non-body
+        locations and RAISE for request bodies. Pass ``None`` to use the
+        schema's setting instead.
     :param callable error_handler: Custom error handler function.
     """
 
-    #: Default locations to check for data
-    DEFAULT_LOCATIONS = ("querystring", "form", "json")
+    #: Default location to check for data
+    DEFAULT_LOCATION: str = "json"
+    #: Default value to use for 'unknown' on schema load
+    #  on a per-location basis
+    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, str] = {
+        "json": ma.RAISE,
+        "form": ma.RAISE,
+        "json_or_form": ma.RAISE,
+        "querystring": ma.EXCLUDE,
+        "query": ma.EXCLUDE,
+        "headers": ma.EXCLUDE,
+        "cookies": ma.EXCLUDE,
+        "files": ma.EXCLUDE,
+    }
     #: The marshmallow Schema class to use when creating new schemas
-    DEFAULT_SCHEMA_CLASS = ma.Schema
+    DEFAULT_SCHEMA_CLASS: typing.Type = ma.Schema
     #: Default status code to return for validation errors
-    DEFAULT_VALIDATION_STATUS = DEFAULT_VALIDATION_STATUS
+    DEFAULT_VALIDATION_STATUS: int = DEFAULT_VALIDATION_STATUS
     #: Default error message for validation errors
-    DEFAULT_VALIDATION_MESSAGE = "Invalid value."
+    DEFAULT_VALIDATION_MESSAGE: str = "Invalid value."
 
     #: Maps location => method name
-    __location_map__ = {
-        "json": "parse_json",
-        "querystring": "parse_querystring",
-        "query": "parse_querystring",
-        "form": "parse_form",
-        "headers": "parse_headers",
-        "cookies": "parse_cookies",
-        "files": "parse_files",
+    __location_map__: typing.Dict[str, typing.Union[str, typing.Callable]] = {
+        "json": "load_json",
+        "querystring": "load_querystring",
+        "query": "load_querystring",
+        "form": "load_form",
+        "headers": "load_headers",
+        "cookies": "load_cookies",
+        "files": "load_files",
+        "json_or_form": "load_json_or_form",
     }
 
-    def __init__(self, locations=None, error_handler=None, schema_class=None):
-        self.locations = locations or self.DEFAULT_LOCATIONS
-        self.error_callback = _callable_or_raise(error_handler)
+    def __init__(
+        self,
+        location: typing.Optional[str] = None,
+        *,
+        unknown: typing.Optional[str] = _UNKNOWN_DEFAULT_PARAM,
+        error_handler: typing.Optional[ErrorHandler] = None,
+        schema_class: typing.Optional[typing.Type] = None
+    ):
+        self.location = location or self.DEFAULT_LOCATION
+        self.error_callback: typing.Optional[ErrorHandler] = _callable_or_raise(
+            error_handler
+        )
         self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS
-        #: A short-lived cache to store results from processing request bodies.
-        self._cache = {}
+        self.unknown = unknown
 
-    def _validated_locations(self, locations):
-        """Ensure that the given locations argument is valid.
+    def _get_loader(self, location: str) -> typing.Callable:
+        """Get the loader function for the given location.
 
-        :raises: ValueError if a given locations includes an invalid location.
+        :raises: ValueError if a given location is invalid.
         """
-        # The set difference between the given locations and the available locations
-        # will be the set of invalid locations
         valid_locations = set(self.__location_map__.keys())
-        given = set(locations)
-        invalid_locations = given - valid_locations
-        if len(invalid_locations):
-            msg = "Invalid locations arguments: {0}".format(list(invalid_locations))
-            raise ValueError(msg)
-        return locations
-
-    def _get_handler(self, location):
+        if location not in valid_locations:
+            raise ValueError(f"Invalid location argument: {location}")
+
         # Parsing function to call
         # May be a method name (str) or a function
-        func = self.__location_map__.get(location)
-        if func:
-            if inspect.isfunction(func):
-                function = func
-            else:
-                function = getattr(self, func)
-        else:
-            raise ValueError('Invalid location: "{0}"'.format(location))
-        return function
-
-    def _get_value(self, name, argobj, req, location):
-        function = self._get_handler(location)
-        return function(req, name, argobj)
-
-    def parse_arg(self, name, field, req, locations=None):
-        """Parse a single argument from a request.
+        func = self.__location_map__[location]
+        if isinstance(func, str):
+            return getattr(self, func)
+        return func
 
-        .. note::
-            This method does not perform validation on the argument.
+    def _load_location_data(
+        self, *, schema: ma.Schema, req: Request, location: str
+    ) -> typing.Mapping:
+        """Return a dictionary-like object for the location on the given request.
 
-        :param str name: The name of the value.
-        :param marshmallow.fields.Field field: The marshmallow `Field` for the request
-            parameter.
-        :param req: The request object to parse.
-        :param tuple locations: The locations ('json', 'querystring', etc.) where
-            to search for the value.
-        :return: The unvalidated argument value or `missing` if the value cannot
-            be found on the request.
+        Needs to have the schema in hand in order to correctly handle loading
+        lists from multidict objects and `many=True` schemas.
         """
-        location = field.metadata.get("location")
-        if location:
-            locations_to_check = self._validated_locations([location])
-        else:
-            locations_to_check = self._validated_locations(locations or self.locations)
-
-        for location in locations_to_check:
-            value = self._get_value(name, field, req=req, location=location)
-            # Found the value; validate and return it
-            if value is not missing:
-                return value
-        return missing
-
-    def _parse_request(self, schema, req, locations):
-        """Return a parsed arguments dictionary for the current request."""
-        if schema.many:
-            assert (
-                "json" in locations
-            ), "schema.many=True is only supported for JSON location"
-            # The ad hoc Nested field is more like a workaround or a helper,
-            # and it servers its purpose fine. However, if somebody has a desire
-            # to re-design the support of bulk-type arguments, go ahead.
-            parsed = self.parse_arg(
-                name="json",
-                field=ma.fields.Nested(schema, many=True),
-                req=req,
-                locations=locations,
-            )
-            if parsed is missing:
-                parsed = []
-        else:
-            argdict = schema.fields
-            parsed = {}
-            for argname, field_obj in iteritems(argdict):
-                if MARSHMALLOW_VERSION_INFO[0] < 3:
-                    parsed_value = self.parse_arg(argname, field_obj, req, locations)
-                    # If load_from is specified on the field, try to parse from that key
-                    if parsed_value is missing and field_obj.load_from:
-                        parsed_value = self.parse_arg(
-                            field_obj.load_from, field_obj, req, locations
-                        )
-                        argname = field_obj.load_from
-                else:
-                    argname = field_obj.data_key or argname
-                    parsed_value = self.parse_arg(argname, field_obj, req, locations)
-                if parsed_value is not missing:
-                    parsed[argname] = parsed_value
-        return parsed
+        loader_func = self._get_loader(location)
+        data = loader_func(req, schema)
+        # when the desired location is empty (no data), provide an empty
+        # dict as the default so that optional arguments in a location
+        # (e.g. optional JSON body) work smoothly
+        if data is missing:
+            data = {}
+        return data
 
     def _on_validation_error(
-        self, error, req, schema, error_status_code, error_headers
-    ):
-        error_handler = self.error_callback or self.handle_error
-        error_handler(error, req, schema, error_status_code, error_headers)
-
-    def _validate_arguments(self, data, validators):
+        self,
+        error: ValidationError,
+        req: Request,
+        schema: ma.Schema,
+        location: str,
+        *,
+        error_status_code: typing.Optional[int],
+        error_headers: typing.Optional[typing.Mapping[str, str]]
+    ) -> typing.NoReturn:
+        # rewrite messages to be namespaced under the location which created
+        # them
+        # e.g. {"json":{"foo":["Not a valid integer."]}}
+        #      instead of
+        #      {"foo":["Not a valid integer."]}
+        error.messages = {location: error.messages}
+        error_handler: ErrorHandler = self.error_callback or self.handle_error
+        error_handler(
+            error,
+            req,
+            schema,
+            error_status_code=error_status_code,
+            error_headers=error_headers,
+        )
+
+    def _validate_arguments(self, data: typing.Any, validators: CallableList) -> None:
+        # although `data` is typically a Mapping, nothing forbids a `schema.load`
+        # from returning an arbitrary object subject to validators
         for validator in validators:
             if validator(data) is False:
                 msg = self.DEFAULT_VALIDATION_MESSAGE
                 raise ValidationError(msg, data=data)
 
-    def _get_schema(self, argmap, req):
+    def _get_schema(self, argmap: ArgMap, req: Request) -> ma.Schema:
         """Return a `marshmallow.Schema` for the given argmap and request.
 
         :param argmap: Either a `marshmallow.Schema`, `dict`
@@ -300,28 +258,19 @@ class Parser(object):
         elif callable(argmap):
             schema = argmap(req)
         else:
-            schema = dict2schema(argmap, self.schema_class)()
-        if MARSHMALLOW_VERSION_INFO[0] < 3 and not schema.strict:
-            warnings.warn(
-                "It is highly recommended that you set strict=True on your schema "
-                "so that the parser's error handler will be invoked when expected.",
-                UserWarning,
-            )
+            schema = self.schema_class.from_dict(argmap)()
         return schema
 
-    def _clone(self):
-        clone = copy(self)
-        clone.clear_cache()
-        return clone
-
     def parse(
         self,
-        argmap,
-        req=None,
-        locations=None,
-        validate=None,
-        error_status_code=None,
-        error_headers=None,
+        argmap: ArgMap,
+        req: typing.Optional[Request] = None,
+        *,
+        location: typing.Optional[str] = None,
+        unknown: typing.Optional[str] = _UNKNOWN_DEFAULT_PARAM,
+        validate: ValidateArg = None,
+        error_status_code: typing.Optional[int] = None,
+        error_headers: typing.Optional[typing.Mapping[str, str]] = None
     ):
         """Main request parsing method.
 
@@ -329,9 +278,14 @@ class Parser(object):
             of argname -> `marshmallow.fields.Field` pairs, or a callable
             which accepts a request and returns a `marshmallow.Schema`.
         :param req: The request object to parse.
-        :param tuple locations: Where on the request to search for values.
-            Can include one or more of ``('json', 'querystring', 'form',
-            'headers', 'cookies', 'files')``.
+        :param str location: Where on the request to load values.
+            Can be any of the values in :py:attr:`~__location_map__`. By
+            default, that means one of ``('json', 'query', 'querystring',
+            'form', 'headers', 'cookies', 'files', 'json_or_form')``.
+        :param str unknown: A value to pass for ``unknown`` when calling the
+            schema's ``load`` method. Defaults to EXCLUDE for non-body
+            locations and RAISE for request bodies. Pass ``None`` to use the
+            schema's setting instead.
         :param callable validate: Validation function or list of validation functions
             that receives the dictionary of parsed arguments. Validator either returns a
             boolean or raises a :exc:`ValidationError`.
@@ -342,44 +296,58 @@ class Parser(object):
 
          :return: A dictionary of parsed arguments
         """
-        self.clear_cache()  # in case someone used `parse_*()`
         req = req if req is not None else self.get_default_request()
-        assert req is not None, "Must pass req object"
+        location = location or self.location
+        # precedence order: explicit, instance setting, default per location
+        unknown = (
+            unknown
+            if unknown != _UNKNOWN_DEFAULT_PARAM
+            else (
+                self.unknown
+                if self.unknown != _UNKNOWN_DEFAULT_PARAM
+                else self.DEFAULT_UNKNOWN_BY_LOCATION.get(location)
+            )
+        )
+        load_kwargs: typing.Dict[str, typing.Any] = (
+            {"unknown": unknown} if unknown else {}
+        )
+        if req is None:
+            raise ValueError("Must pass req object")
         data = None
         validators = _ensure_list_of_callables(validate)
-        parser = self._clone()
         schema = self._get_schema(argmap, req)
         try:
-            parsed = parser._parse_request(
-                schema=schema, req=req, locations=locations or self.locations
+            location_data = self._load_location_data(
+                schema=schema, req=req, location=location
             )
-            result = schema.load(parsed)
-            data = result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else result
-            parser._validate_arguments(data, validators)
+            data = schema.load(location_data, **load_kwargs)
+            self._validate_arguments(data, validators)
         except ma.exceptions.ValidationError as error:
-            parser._on_validation_error(
-                error, req, schema, error_status_code, error_headers
+            self._on_validation_error(
+                error,
+                req,
+                schema,
+                location,
+                error_status_code=error_status_code,
+                error_headers=error_headers,
             )
+            raise ValueError(
+                "_on_validation_error hook did not raise an exception"
+            ) from error
         return data
 
-    def clear_cache(self):
-        """Invalidate the parser's cache.
-
-        This is usually a no-op now since the Parser clone used for parsing a
-        request is discarded afterwards.  It can still be used when manually
-        calling ``parse_*`` methods which would populate the cache on the main
-        Parser instance.
-        """
-        self._cache = {}
-        return None
-
-    def get_default_request(self):
+    def get_default_request(self) -> typing.Optional[Request]:
         """Optional override. Provides a hook for frameworks that use thread-local
         request objects.
         """
         return None
 
-    def get_request_from_view_args(self, view, args, kwargs):
+    def get_request_from_view_args(
+        self,
+        view: typing.Callable,
+        args: typing.Tuple,
+        kwargs: typing.Mapping[str, typing.Any],
+    ) -> typing.Optional[Request]:
         """Optional override. Returns the request object to be parsed, given a view
         function's args and kwargs.
 
@@ -393,29 +361,48 @@ class Parser(object):
         """
         return None
 
+    @staticmethod
+    def _update_args_kwargs(
+        args: typing.Tuple,
+        kwargs: typing.Dict[str, typing.Any],
+        parsed_args: typing.Tuple,
+        as_kwargs: bool,
+    ) -> typing.Tuple[typing.Tuple, typing.Mapping]:
+        """Update args or kwargs with parsed_args depending on as_kwargs"""
+        if as_kwargs:
+            kwargs.update(parsed_args)
+        else:
+            # Add parsed_args after other positional arguments
+            args += (parsed_args,)
+        return args, kwargs
+
     def use_args(
         self,
-        argmap,
-        req=None,
-        locations=None,
-        as_kwargs=False,
-        validate=None,
-        error_status_code=None,
-        error_headers=None,
-    ):
+        argmap: ArgMap,
+        req: typing.Optional[Request] = None,
+        *,
+        location: typing.Optional[str] = None,
+        unknown: typing.Optional[str] = _UNKNOWN_DEFAULT_PARAM,
+        as_kwargs: bool = False,
+        validate: ValidateArg = None,
+        error_status_code: typing.Optional[int] = None,
+        error_headers: typing.Optional[typing.Mapping[str, str]] = None
+    ) -> typing.Callable[..., typing.Callable]:
         """Decorator that injects parsed arguments into a view function or method.
 
         Example usage with Flask: ::
 
             @app.route('/echo', methods=['get', 'post'])
-            @parser.use_args({'name': fields.Str()})
+            @parser.use_args({'name': fields.Str()}, location="querystring")
             def greet(args):
                 return 'Hello ' + args['name']
 
         :param argmap: Either a `marshmallow.Schema`, a `dict`
             of argname -> `marshmallow.fields.Field` pairs, or a callable
             which accepts a request and returns a `marshmallow.Schema`.
-        :param tuple locations: Where on the request to search for values.
+        :param str location: Where on the request to load values.
+        :param str unknown: A value to pass for ``unknown`` when calling the
+            schema's ``load`` method.
         :param bool as_kwargs: Whether to insert arguments as keyword arguments.
         :param callable validate: Validation function that receives the dictionary
             of parsed arguments. If the function returns ``False``, the parser
@@ -425,12 +412,12 @@ class Parser(object):
         :param dict error_headers: Headers passed to error handler functions when a
             a `ValidationError` is raised.
         """
-        locations = locations or self.locations
+        location = location or self.location
         request_obj = req
         # Optimization: If argmap is passed as a dictionary, we only need
         # to generate a Schema once
         if isinstance(argmap, Mapping):
-            argmap = dict2schema(argmap, self.schema_class)()
+            argmap = self.schema_class.from_dict(argmap)()
 
         def decorator(func):
             req_ = request_obj
@@ -441,29 +428,28 @@ class Parser(object):
 
                 if not req_obj:
                     req_obj = self.get_request_from_view_args(func, args, kwargs)
+
                 # NOTE: At this point, argmap may be a Schema, or a callable
                 parsed_args = self.parse(
                     argmap,
                     req=req_obj,
-                    locations=locations,
+                    location=location,
+                    unknown=unknown,
                     validate=validate,
                     error_status_code=error_status_code,
                     error_headers=error_headers,
                 )
-                if as_kwargs:
-                    kwargs.update(parsed_args)
-                    return func(*args, **kwargs)
-                else:
-                    # Add parsed_args after other positional arguments
-                    new_args = args + (parsed_args,)
-                    return func(*new_args, **kwargs)
+                args, kwargs = self._update_args_kwargs(
+                    args, kwargs, parsed_args, as_kwargs
+                )
+                return func(*args, **kwargs)
 
             wrapper.__wrapped__ = func
             return wrapper
 
         return decorator
 
-    def use_kwargs(self, *args, **kwargs):
+    def use_kwargs(self, *args, **kwargs) -> typing.Callable:
         """Decorator that injects parsed arguments into a view function or method
         as keyword arguments.
 
@@ -481,19 +467,23 @@ class Parser(object):
         kwargs["as_kwargs"] = True
         return self.use_args(*args, **kwargs)
 
-    def location_handler(self, name):
-        """Decorator that registers a function for parsing a request location.
-        The wrapped function receives a request, the name of the argument, and
-        the corresponding `Field <marshmallow.fields.Field>` object.
+    def location_loader(self, name: str):
+        """Decorator that registers a function for loading a request location.
+        The wrapped function receives a schema and a request.
+
+        The schema will usually not be relevant, but it's important in some
+        cases -- most notably in order to correctly load multidict values into
+        list fields. Without the schema, there would be no way to know whether
+        to simply `.get()` or `.getall()` from a multidict for a given value.
 
         Example: ::
 
             from webargs import core
             parser = core.Parser()
 
-            @parser.location_handler("name")
-            def parse_data(request, name, field):
-                return request.data.get(name)
+            @parser.location_loader("name")
+            def load_data(request, schema):
+                return request.data
 
         :param str name: The name of the location to register.
         """
@@ -504,7 +494,7 @@ class Parser(object):
 
         return decorator
 
-    def error_handler(self, func):
+    def error_handler(self, func: ErrorHandler) -> ErrorHandler:
         """Decorator that registers a custom error handling function. The
         function should receive the raised error, request object,
         `marshmallow.Schema` instance used to parse the request, error status code,
@@ -523,7 +513,7 @@ class Parser(object):
 
 
             @parser.error_handler
-            def handle_error(error, req, schema, status_code, headers):
+            def handle_error(error, req, schema, *, error_status_code, error_headers):
                 raise CustomError(error.messages)
 
         :param callable func: The error callback to register.
@@ -531,47 +521,107 @@ class Parser(object):
         self.error_callback = func
         return func
 
+    def _handle_invalid_json_error(
+        self,
+        error: typing.Union[json.JSONDecodeError, UnicodeDecodeError],
+        req: Request,
+        *args,
+        **kwargs
+    ) -> typing.NoReturn:
+        """Internal hook for overriding treatment of JSONDecodeErrors.
+
+        Invoked by default `load_json` implementation.
+
+        External parsers can just implement their own behavior for load_json ,
+        so this is not part of the public parser API.
+        """
+        raise error
+
+    def load_json(self, req: Request, schema: ma.Schema) -> typing.Any:
+        """Load JSON from a request object or return `missing` if no value can
+        be found.
+        """
+        # NOTE: although this implementation is real/concrete and used by
+        # several of the parsers in webargs, it relies on the internal hooks
+        # `_handle_invalid_json_error` and `_raw_load_json`
+        # these methods are not part of the public API and are used to simplify
+        # code sharing amongst the built-in webargs parsers
+        try:
+            return self._raw_load_json(req)
+        except json.JSONDecodeError as exc:
+            if exc.doc == "":
+                return missing
+            return self._handle_invalid_json_error(exc, req)
+        except UnicodeDecodeError as exc:
+            return self._handle_invalid_json_error(exc, req)
+
+    def load_json_or_form(self, req: Request, schema: ma.Schema):
+        """Load data from a request, accepting either JSON or form-encoded
+        data.
+
+        The data will first be loaded as JSON, and, if that fails, it will be
+        loaded as a form post.
+        """
+        data = self.load_json(req, schema)
+        if data is not missing:
+            return data
+        return self.load_form(req, schema)
+
     # Abstract Methods
 
-    def parse_json(self, req, name, arg):
-        """Pull a JSON value from a request object or return `missing` if the
-        value cannot be found.
+    def _raw_load_json(self, req: Request):
+        """Internal hook method for implementing load_json()
+
+        Get a request body for feeding in to `load_json`, and parse it either
+        using core.parse_json() or similar utilities which raise
+        JSONDecodeErrors.
+        Ensure consistent behavior when encountering decoding errors.
+
+        The default implementation here simply returns `missing`, and the default
+        implementation of `load_json` above will pass that value through.
+        However, by implementing a "mostly concrete" version of load_json with
+        this as a hook for getting data, we consolidate the logic for handling
+        those JSONDecodeErrors.
         """
         return missing
 
-    def parse_querystring(self, req, name, arg):
-        """Pull a value from the query string of a request object or return `missing` if
-        the value cannot be found.
+    def load_querystring(self, req: Request, schema: ma.Schema):
+        """Load the query string of a request object or return `missing` if no
+        value can be found.
         """
         return missing
 
-    def parse_form(self, req, name, arg):
-        """Pull a value from the form data of a request object or return
-        `missing` if the value cannot be found.
+    def load_form(self, req: Request, schema: ma.Schema):
+        """Load the form data of a request object or return `missing` if no
+        value can be found.
         """
         return missing
 
-    def parse_headers(self, req, name, arg):
-        """Pull a value from the headers or return `missing` if the value
-        cannot be found.
-        """
+    def load_headers(self, req: Request, schema: ma.Schema):
+        """Load the headers or return `missing` if no value can be found."""
         return missing
 
-    def parse_cookies(self, req, name, arg):
-        """Pull a cookie value from the request or return `missing` if the value
-        cannot be found.
+    def load_cookies(self, req: Request, schema: ma.Schema):
+        """Load the cookies from the request or return `missing` if no value
+        can be found.
         """
         return missing
 
-    def parse_files(self, req, name, arg):
-        """Pull a file from the request or return `missing` if the value file
-        cannot be found.
+    def load_files(self, req: Request, schema: ma.Schema):
+        """Load files from the request or return `missing` if no values can be
+        found.
         """
         return missing
 
     def handle_error(
-        self, error, req, schema, error_status_code=None, error_headers=None
-    ):
+        self,
+        error: ValidationError,
+        req: Request,
+        schema: ma.Schema,
+        *,
+        error_status_code: int,
+        error_headers: typing.Mapping[str, str]
+    ) -> typing.NoReturn:
         """Called if an error occurs while parsing args. By default, just logs and
         raises ``error``.
         """
diff --git a/src/webargs/dict2schema.py b/src/webargs/dict2schema.py
deleted file mode 100644
index 0300032..0000000
--- a/src/webargs/dict2schema.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# -*- coding: utf-8 -*-
-import marshmallow as ma
-
-
-def dict2schema(dct, schema_class=ma.Schema):
-    """Generate a `marshmallow.Schema` class given a dictionary of
-    `Fields <marshmallow.fields.Field>`.
-    """
-    if hasattr(schema_class, "from_dict"):  # marshmallow 3
-        return schema_class.from_dict(dct)
-    attrs = dct.copy()
-
-    class Meta(object):
-        strict = True
-
-    attrs["Meta"] = Meta
-    return type(str(""), (schema_class,), attrs)
diff --git a/src/webargs/djangoparser.py b/src/webargs/djangoparser.py
index cee03d7..73c135c 100644
--- a/src/webargs/djangoparser.py
+++ b/src/webargs/djangoparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Django request argument parsing.
 
 Example usage: ::
@@ -19,7 +18,11 @@ Example usage: ::
             return HttpResponse('Hello ' + args['name'])
 """
 from webargs import core
-from webargs.core import json
+from webargs.multidictproxy import MultiDictProxy
+
+
+def is_json_request(req):
+    return core.is_json(req.content_type)
 
 
 class DjangoParser(core.Parser):
@@ -33,44 +36,37 @@ class DjangoParser(core.Parser):
         the parser and returning the appropriate `HTTPResponse`.
     """
 
-    def parse_querystring(self, req, name, field):
-        """Pull the querystring value from the request."""
-        return core.get_value(req.GET, name, field)
-
-    def parse_form(self, req, name, field):
-        """Pull the form value from the request."""
-        return core.get_value(req.POST, name, field)
-
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request body."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            if not core.is_json(req.content_type):
-                return core.missing
-
-            try:
-                self._cache["json"] = json_data = core.parse_json(req.body)
-            except AttributeError:
-                return core.missing
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    return self.handle_invalid_json_error(e, req)
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_cookies(self, req, name, field):
-        """Pull the value from the cookiejar."""
-        return core.get_value(req.COOKIES, name, field)
-
-    def parse_headers(self, req, name, field):
-        raise NotImplementedError(
-            "Header parsing not supported by {0}".format(self.__class__.__name__)
-        )
-
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
-        return core.get_value(req.FILES, name, field)
+    def _raw_load_json(self, req):
+        """Read a json payload from the request for the core parser's load_json
+
+        Checks the input mimetype and may return 'missing' if the mimetype is
+        non-json, even if the request body is parseable as json."""
+        if not is_json_request(req):
+            return core.missing
+
+        return core.parse_json(req.body)
+
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.GET, schema)
+
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.POST, schema)
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request."""
+        return req.COOKIES
+
+    def load_headers(self, req, schema):
+        """Return headers from the request."""
+        # Django's HttpRequest.headers is a case-insensitive dict type, but it
+        # isn't a multidict, so this is not proxied
+        return req.headers
+
+    def load_files(self, req, schema):
+        """Return files from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.FILES, schema)
 
     def get_request_from_view_args(self, view, args, kwargs):
         # The first argument is either `self` or `request`
@@ -79,9 +75,6 @@ class DjangoParser(core.Parser):
         except AttributeError:  # first arg is request
             return args[0]
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
-        raise error
-
 
 parser = DjangoParser()
 use_args = parser.use_args
diff --git a/src/webargs/falconparser.py b/src/webargs/falconparser.py
index b8c5ec7..d2eb448 100644
--- a/src/webargs/falconparser.py
+++ b/src/webargs/falconparser.py
@@ -1,11 +1,12 @@
-# -*- coding: utf-8 -*-
 """Falcon request argument parsing module.
 """
 import falcon
 from falcon.util.uri import parse_query_string
 
+import marshmallow as ma
+
 from webargs import core
-from webargs.core import json
+from webargs.multidictproxy import MultiDictProxy
 
 HTTP_422 = "422 Unprocessable Entity"
 
@@ -30,30 +31,13 @@ def is_json_request(req):
     return content_type and core.is_json(content_type)
 
 
-def parse_json_body(req):
-    if req.content_length in (None, 0):
-        # Nothing to do
-        return {}
-    if is_json_request(req):
-        body = req.stream.read()
-        if body:
-            try:
-                return core.parse_json(body)
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    raise
-    return {}
-
-
 # NOTE: Adapted from falcon.request.Request._parse_form_urlencoded
 def parse_form_body(req):
     if (
         req.content_type is not None
         and "application/x-www-form-urlencoded" in req.content_type
     ):
-        body = req.stream.read()
+        body = req.stream.read(req.content_length or 0)
         try:
             body = body.decode("ascii")
         except UnicodeDecodeError:
@@ -66,95 +50,133 @@ def parse_form_body(req):
             )
 
         if body:
-            return parse_query_string(
-                body, keep_blank_qs_values=req.options.keep_blank_qs_values
-            )
-    return {}
+            return parse_query_string(body, keep_blank=req.options.keep_blank_qs_values)
+
+    return core.missing
 
 
 class HTTPError(falcon.HTTPError):
-    """HTTPError that stores a dictionary of validation error messages.
-    """
+    """HTTPError that stores a dictionary of validation error messages."""
 
     def __init__(self, status, errors, *args, **kwargs):
         self.errors = errors
-        super(HTTPError, self).__init__(status, *args, **kwargs)
+        super().__init__(status, *args, **kwargs)
 
     def to_dict(self, *args, **kwargs):
         """Override `falcon.HTTPError` to include error messages in responses."""
-        ret = super(HTTPError, self).to_dict(*args, **kwargs)
+        ret = super().to_dict(*args, **kwargs)
         if self.errors is not None:
             ret["errors"] = self.errors
         return ret
 
 
 class FalconParser(core.Parser):
-    """Falcon request argument parser."""
-
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return core.get_value(req.params, name, field)
-
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request.
+    """Falcon request argument parser.
+
+    Defaults to using the `media` location. See :py:meth:`~FalconParser.load_media` for
+    details on the media location."""
+
+    # by default, Falcon will use the 'media' location to load data
+    #
+    # this effectively looks the same as loading JSON data by default, but if
+    # you add a handler for a different media type to Falcon, webargs will
+    # automatically pick up on that capability
+    DEFAULT_LOCATION = "media"
+    DEFAULT_UNKNOWN_BY_LOCATION = dict(
+        media=ma.RAISE, **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION
+    )
+    __location_map__ = dict(media="load_media", **core.Parser.__location_map__)
+
+    # Note on the use of MultiDictProxy throughout:
+    # Falcon parses query strings and form values into ordinary dicts, but with
+    # the values listified where appropriate
+    # it is still therefore necessary in these cases to wrap them in
+    # MultiDictProxy because we need to use the schema to determine when single
+    # values should be wrapped in lists due to the type of the destination
+    # field
+
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.params, schema)
+
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy
 
         .. note::
 
             The request stream will be read and left at EOF.
         """
-        form = self._cache.get("form")
-        if form is None:
-            self._cache["form"] = form = parse_form_body(req)
-        return core.get_value(form, name, field)
+        form = parse_form_body(req)
+        if form is core.missing:
+            return form
+        return MultiDictProxy(form, schema)
+
+    def load_media(self, req, schema):
+        """Return data unpacked and parsed by one of Falcon's media handlers.
+        By default, Falcon only handles JSON payloads.
 
-    def parse_json(self, req, name, field):
-        """Pull a JSON body value from the request.
+        To configure additional media handlers, see the
+        `Falcon documentation on media types`__.
+
+        .. _FalconMedia: https://falcon.readthedocs.io/en/stable/api/media.html
+        __ FalconMedia_
 
         .. note::
 
             The request stream will be read and left at EOF.
         """
-        json_data = self._cache.get("json_data")
-        if json_data is None:
-            try:
-                self._cache["json_data"] = json_data = parse_json_body(req)
-            except json.JSONDecodeError as e:
-                return self.handle_invalid_json_error(e, req)
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_headers(self, req, name, field):
-        """Pull a header value from the request."""
-        # Use req.get_headers rather than req.headers for performance
-        return req.get_header(name, required=False) or core.missing
-
-    def parse_cookies(self, req, name, field):
-        """Pull a cookie value from the request."""
-        cookies = self._cache.get("cookies")
-        if cookies is None:
-            self._cache["cookies"] = cookies = req.cookies
-        return core.get_value(cookies, name, field)
+        # if there is no body, return missing instead of erroring
+        if req.content_length in (None, 0):
+            return core.missing
+        return req.media
+
+    def _raw_load_json(self, req):
+        """Return a json payload from the request for the core parser's load_json
+
+        Checks the input mimetype and may return 'missing' if the mimetype is
+        non-json, even if the request body is parseable as json."""
+        if not is_json_request(req) or req.content_length in (None, 0):
+            return core.missing
+        body = req.stream.read(req.content_length)
+        if body:
+            return core.parse_json(body)
+        return core.missing
+
+    def load_headers(self, req, schema):
+        """Return headers from the request."""
+        # Falcon only exposes headers as a dict (not multidict)
+        return req.headers
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request."""
+        # Cookies are expressed in Falcon as a dict, but the possibility of
+        # multiple values for a cookie is preserved internally -- if desired in
+        # the future, webargs could add a MultiDict type for Cookies here built
+        # from (req, schema), but Falcon does not provide one out of the box
+        return req.cookies
 
     def get_request_from_view_args(self, view, args, kwargs):
         """Get request from a resource method's arguments. Assumes that
         request is the second argument.
         """
         req = args[1]
-        assert isinstance(req, falcon.Request), "Argument is not a falcon.Request"
+        if not isinstance(req, falcon.Request):
+            raise TypeError("Argument is not a falcon.Request")
         return req
 
-    def parse_files(self, req, name, field):
+    def load_files(self, req, schema):
         raise NotImplementedError(
-            "Parsing files not yet supported by {0}".format(self.__class__.__name__)
+            f"Parsing files not yet supported by {self.__class__.__name__}"
         )
 
-    def handle_error(self, error, req, schema, error_status_code, error_headers):
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         """Handles errors during parsing."""
         status = status_map.get(error_status_code or self.DEFAULT_VALIDATION_STATUS)
         if status is None:
-            raise LookupError("Status code {0} not supported".format(error_status_code))
+            raise LookupError(f"Status code {error_status_code} not supported")
         raise HTTPError(status, errors=error.messages, headers=error_headers)
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
+    def _handle_invalid_json_error(self, error, req, *args, **kwargs):
         status = status_map[400]
         messages = {"json": ["Invalid JSON body."]}
         raise HTTPError(status, errors=messages)
diff --git a/src/webargs/fields.py b/src/webargs/fields.py
index 2828c56..806cc5d 100644
--- a/src/webargs/fields.py
+++ b/src/webargs/fields.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Field classes.
 
 Includes all fields from `marshmallow.fields` in addition to a custom
@@ -10,24 +9,21 @@ tells webargs where to parse the request argument from.
 .. code-block:: python
 
     args = {
-        "active": fields.Bool(location='query'),
+        "active": fields.Bool(location="query"),
         "content_type": fields.Str(data_key="Content-Type", location="headers"),
     }
-
-Note: `data_key` replaced `load_from` in marshmallow 3.
-When using marshmallow 2, use `load_from`.
 """
+import typing
+
 import marshmallow as ma
 
 # Expose all fields from marshmallow.fields.
 from marshmallow.fields import *  # noqa: F40
-from webargs.compat import MARSHMALLOW_VERSION_INFO
-from webargs.dict2schema import dict2schema
 
 __all__ = ["DelimitedList"] + ma.fields.__all__
 
 
-class Nested(ma.fields.Nested):
+class Nested(ma.fields.Nested):  # type: ignore[no-redef]
     """Same as `marshmallow.fields.Nested`, except can be passed a dictionary as
     the first argument, which will be converted to a `marshmallow.Schema`.
 
@@ -40,42 +36,79 @@ class Nested(ma.fields.Nested):
 
     def __init__(self, nested, *args, **kwargs):
         if isinstance(nested, dict):
-            nested = dict2schema(nested)
-        super(Nested, self).__init__(nested, *args, **kwargs)
+            nested = ma.Schema.from_dict(nested)
+        super().__init__(nested, *args, **kwargs)
+
+
+class DelimitedFieldMixin:
+    """
+    This is a mixin class for subclasses of ma.fields.List and ma.fields.Tuple
+    which split on a pre-specified delimiter. By default, the delimiter will be ","
+
+    Because we want the MRO to reach this class before the List or Tuple class,
+    it must be listed first in the superclasses
+
+    For example, a DelimitedList-like type can be defined like so:
+
+    >>> class MyDelimitedList(DelimitedFieldMixin, ma.fields.List):
+    >>>     pass
+    """
+
+    delimiter: str = ","
+
+    def _serialize(self, value, attr, obj, **kwargs):
+        # serializing will start with parent-class serialization, so that we correctly
+        # output lists of non-primitive types, e.g. DelimitedList(DateTime)
+        return self.delimiter.join(
+            format(each) for each in super()._serialize(value, attr, obj, **kwargs)
+        )
 
+    def _deserialize(self, value, attr, data, **kwargs):
+        # attempting to deserialize from a non-string source is an error
+        if not isinstance(value, (str, bytes)):
+            raise self.make_error("invalid")
+        return super()._deserialize(value.split(self.delimiter), attr, data, **kwargs)
+
+
+class DelimitedList(DelimitedFieldMixin, ma.fields.List):
+    """A field which is similar to a List, but takes its input as a delimited
+    string (e.g. "foo,bar,baz").
 
-class DelimitedList(ma.fields.List):
-    """Same as `marshmallow.fields.List`, except can load from either a list or
-    a delimited string (e.g. "foo,bar,baz").
+    Like List, it can be given a nested field type which it will use to
+    de/serialize each element of the list.
 
     :param Field cls_or_instance: A field class or instance.
     :param str delimiter: Delimiter between values.
-    :param bool as_string: Dump values to string.
     """
 
-    delimiter = ","
+    default_error_messages = {"invalid": "Not a valid delimited list."}
 
-    def __init__(self, cls_or_instance, delimiter=None, as_string=False, **kwargs):
+    def __init__(
+        self,
+        cls_or_instance: typing.Union[ma.fields.Field, type],
+        *,
+        delimiter: typing.Optional[str] = None,
+        **kwargs
+    ):
         self.delimiter = delimiter or self.delimiter
-        self.as_string = as_string
-        super(DelimitedList, self).__init__(cls_or_instance, **kwargs)
+        super().__init__(cls_or_instance, **kwargs)
 
-    def _serialize(self, value, attr, obj):
-        ret = super(DelimitedList, self)._serialize(value, attr, obj)
-        if self.as_string:
-            return self.delimiter.join(format(each) for each in ret)
-        return ret
 
-    def _deserialize(self, value, attr, data, **kwargs):
-        try:
-            ret = (
-                value
-                if ma.utils.is_iterable_but_not_string(value)
-                else value.split(self.delimiter)
-            )
-        except AttributeError:
-            if MARSHMALLOW_VERSION_INFO[0] < 3:
-                self.fail("invalid")
-            else:
-                raise self.make_error("invalid")
-        return super(DelimitedList, self)._deserialize(ret, attr, data, **kwargs)
+class DelimitedTuple(DelimitedFieldMixin, ma.fields.Tuple):
+    """A field which is similar to a Tuple, but takes its input as a delimited
+    string (e.g. "foo,bar,baz").
+
+    Like Tuple, it can be given a tuple of nested field types which it will use to
+    de/serialize each element of the tuple.
+
+    :param Iterable[Field] tuple_fields: An iterable of field classes or instances.
+    :param str delimiter: Delimiter between values.
+    """
+
+    default_error_messages = {"invalid": "Not a valid delimited tuple."}
+
+    def __init__(
+        self, tuple_fields, *, delimiter: typing.Optional[str] = None, **kwargs
+    ):
+        self.delimiter = delimiter or self.delimiter
+        super().__init__(tuple_fields, **kwargs)
diff --git a/src/webargs/flaskparser.py b/src/webargs/flaskparser.py
index 9b5c058..4fbe15c 100644
--- a/src/webargs/flaskparser.py
+++ b/src/webargs/flaskparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Flask request argument parsing module.
 
 Example: ::
@@ -10,20 +9,24 @@ Example: ::
 
     app = Flask(__name__)
 
-    hello_args = {
-        'name': fields.Str(required=True)
+    user_detail_args = {
+        'per_page': fields.Int()
     }
 
-    @app.route('/')
-    @use_args(hello_args)
-    def index(args):
-        return 'Hello ' + args['name']
+    @app.route("/user/<int:uid>")
+    @use_args(user_detail_args)
+    def user_detail(args, uid):
+        return ("The user page for user {uid}, showing {per_page} posts.").format(
+            uid=uid, per_page=args["per_page"]
+        )
 """
 import flask
 from werkzeug.exceptions import HTTPException
 
+import marshmallow as ma
+
 from webargs import core
-from webargs.core import json
+from webargs.multidictproxy import MultiDictProxy
 
 
 def abort(http_status_code, exc=None, **kwargs):
@@ -47,61 +50,55 @@ def is_json_request(req):
 class FlaskParser(core.Parser):
     """Flask request argument parser."""
 
+    DEFAULT_UNKNOWN_BY_LOCATION = {
+        "view_args": ma.RAISE,
+        "path": ma.RAISE,
+        **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
+    }
     __location_map__ = dict(
-        view_args="parse_view_args",
-        path="parse_view_args",
-        **core.Parser.__location_map__
+        view_args="load_view_args",
+        path="load_view_args",
+        **core.Parser.__location_map__,
     )
 
-    def parse_view_args(self, req, name, field):
-        """Pull a value from the request's ``view_args``."""
-        return core.get_value(req.view_args, name, field)
-
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            if not is_json_request(req):
-                return core.missing
-
-            # We decode the json manually here instead of
-            # using req.get_json() so that we can handle
-            # JSONDecodeErrors consistently
-            data = req.get_data(cache=True)
-            try:
-                self._cache["json"] = json_data = core.parse_json(data)
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    return self.handle_invalid_json_error(e, req)
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return core.get_value(req.args, name, field)
-
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request."""
-        try:
-            return core.get_value(req.form, name, field)
-        except AttributeError:
-            pass
-        return core.missing
-
-    def parse_headers(self, req, name, field):
-        """Pull a value from the header data."""
-        return core.get_value(req.headers, name, field)
-
-    def parse_cookies(self, req, name, field):
-        """Pull a value from the cookiejar."""
-        return core.get_value(req.cookies, name, field)
-
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
-        return core.get_value(req.files, name, field)
-
-    def handle_error(self, error, req, schema, error_status_code, error_headers):
+    def _raw_load_json(self, req):
+        """Return a json payload from the request for the core parser's load_json
+
+        Checks the input mimetype and may return 'missing' if the mimetype is
+        non-json, even if the request body is parseable as json."""
+        if not is_json_request(req):
+            return core.missing
+
+        return core.parse_json(req.get_data(cache=True))
+
+    def _handle_invalid_json_error(self, error, req, *args, **kwargs):
+        abort(400, exc=error, messages={"json": ["Invalid JSON body."]})
+
+    def load_view_args(self, req, schema):
+        """Return the request's ``view_args`` or ``missing`` if there are none."""
+        return req.view_args or core.missing
+
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.args, schema)
+
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.form, schema)
+
+    def load_headers(self, req, schema):
+        """Return headers from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.headers, schema)
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request."""
+        return req.cookies
+
+    def load_files(self, req, schema):
+        """Return files from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.files, schema)
+
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         """Handles errors during parsing. Aborts the current HTTP request and
         responds with a 422 error.
         """
@@ -114,11 +111,8 @@ class FlaskParser(core.Parser):
             headers=error_headers,
         )
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
-        abort(400, exc=error, messages={"json": ["Invalid JSON body."]})
-
     def get_default_request(self):
-        """Override to use Flask's thread-local request objec by default"""
+        """Override to use Flask's thread-local request object by default"""
         return flask.request
 
 
diff --git a/src/webargs/multidictproxy.py b/src/webargs/multidictproxy.py
new file mode 100644
index 0000000..19792dc
--- /dev/null
+++ b/src/webargs/multidictproxy.py
@@ -0,0 +1,81 @@
+from collections.abc import Mapping
+
+import marshmallow as ma
+
+from webargs.core import missing, is_multiple
+
+
+class MultiDictProxy(Mapping):
+    """
+    A proxy object which wraps multidict types along with a matching schema
+    Whenever a value is looked up, it is checked against the schema to see if
+    there is a matching field where `is_multiple` is True. If there is, then
+    the data should be loaded as a list or tuple.
+
+    In all other cases, __getitem__ proxies directly to the input multidict.
+    """
+
+    def __init__(self, multidict, schema: ma.Schema):
+        self.data = multidict
+        self.multiple_keys = self._collect_multiple_keys(schema)
+
+    @staticmethod
+    def _collect_multiple_keys(schema: ma.Schema):
+        result = set()
+        for name, field in schema.fields.items():
+            if not is_multiple(field):
+                continue
+            result.add(field.data_key if field.data_key is not None else name)
+        return result
+
+    def __getitem__(self, key):
+        val = self.data.get(key, missing)
+        if val is missing or key not in self.multiple_keys:
+            return val
+        if hasattr(self.data, "getlist"):
+            return self.data.getlist(key)
+        if hasattr(self.data, "getall"):
+            return self.data.getall(key)
+        if isinstance(val, (list, tuple)):
+            return val
+        if val is None:
+            return None
+        return [val]
+
+    def __str__(self):  # str(proxy) proxies to str(proxy.data)
+        return str(self.data)
+
+    def __repr__(self):
+        return "MultiDictProxy(data={!r}, multiple_keys={!r})".format(
+            self.data, self.multiple_keys
+        )
+
+    def __delitem__(self, key):
+        del self.data[key]
+
+    def __setitem__(self, key, value):
+        self.data[key] = value
+
+    def __getattr__(self, name):
+        return getattr(self.data, name)
+
+    def __iter__(self):
+        for x in iter(self.data):
+            # special case for header dicts which produce an iterator of tuples
+            # instead of an iterator of strings
+            if isinstance(x, tuple):
+                yield x[0]
+            else:
+                yield x
+
+    def __contains__(self, x):
+        return x in self.data
+
+    def __len__(self):
+        return len(self.data)
+
+    def __eq__(self, other):
+        return self.data == other
+
+    def __ne__(self, other):
+        return self.data != other
diff --git a/src/webargs/py.typed b/src/webargs/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/webargs/pyramidparser.py b/src/webargs/pyramidparser.py
index 9ee334e..9537fb9 100644
--- a/src/webargs/pyramidparser.py
+++ b/src/webargs/pyramidparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Pyramid request argument parsing.
 
 Example usage: ::
@@ -25,102 +24,107 @@ Example usage: ::
         server = make_server('0.0.0.0', 6543, app)
         server.serve_forever()
 """
-import collections
 import functools
+from collections.abc import Mapping
 
 from webob.multidict import MultiDict
 from pyramid.httpexceptions import exception_response
 
+import marshmallow as ma
+
 from webargs import core
 from webargs.core import json
-from webargs.compat import text_type
+from webargs.multidictproxy import MultiDictProxy
+
+
+def is_json_request(req):
+    return core.is_json(req.headers.get("content-type"))
 
 
 class PyramidParser(core.Parser):
     """Pyramid request argument parser."""
 
+    DEFAULT_UNKNOWN_BY_LOCATION = {
+        "matchdict": ma.RAISE,
+        "path": ma.RAISE,
+        **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
+    }
     __location_map__ = dict(
-        matchdict="parse_matchdict",
-        path="parse_matchdict",
-        **core.Parser.__location_map__
+        matchdict="load_matchdict",
+        path="load_matchdict",
+        **core.Parser.__location_map__,
     )
 
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return core.get_value(req.GET, name, field)
-
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request."""
-        return core.get_value(req.POST, name, field)
-
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            if not core.is_json(req.content_type):
-                return core.missing
-
-            try:
-                self._cache["json"] = json_data = core.parse_json(req.body, req.charset)
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    return self.handle_invalid_json_error(e, req)
-            if json_data is None:
-                return core.missing
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_cookies(self, req, name, field):
-        """Pull the value from the cookiejar."""
-        return core.get_value(req.cookies, name, field)
-
-    def parse_headers(self, req, name, field):
-        """Pull a value from the header data."""
-        return core.get_value(req.headers, name, field)
-
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
+    def _raw_load_json(self, req):
+        """Return a json payload from the request for the core parser's load_json
+
+        Checks the input mimetype and may return 'missing' if the mimetype is
+        non-json, even if the request body is parseable as json."""
+        if not is_json_request(req):
+            return core.missing
+
+        return core.parse_json(req.body, encoding=req.charset)
+
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.GET, schema)
+
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.POST, schema)
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.cookies, schema)
+
+    def load_headers(self, req, schema):
+        """Return headers from the request as a MultiDictProxy."""
+        return MultiDictProxy(req.headers, schema)
+
+    def load_files(self, req, schema):
+        """Return files from the request as a MultiDictProxy."""
         files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file"))
-        return core.get_value(MultiDict(files), name, field)
+        return MultiDictProxy(MultiDict(files), schema)
 
-    def parse_matchdict(self, req, name, field):
-        """Pull a value from the request's `matchdict`."""
-        return core.get_value(req.matchdict, name, field)
+    def load_matchdict(self, req, schema):
+        """Return the request's ``matchdict`` as a MultiDictProxy."""
+        return MultiDictProxy(req.matchdict, schema)
 
-    def handle_error(self, error, req, schema, error_status_code, error_headers):
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         """Handles errors during parsing. Aborts the current HTTP request and
         responds with a 400 error.
         """
         status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS
         response = exception_response(
             status_code,
-            detail=text_type(error),
+            detail=str(error),
             headers=error_headers,
             content_type="application/json",
         )
         body = json.dumps(error.messages)
-        response.body = body.encode("utf-8") if isinstance(body, text_type) else body
+        response.body = body.encode("utf-8") if isinstance(body, str) else body
         raise response
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
+    def _handle_invalid_json_error(self, error, req, *args, **kwargs):
         messages = {"json": ["Invalid JSON body."]}
         response = exception_response(
-            400, detail=text_type(messages), content_type="application/json"
+            400, detail=str(messages), content_type="application/json"
         )
         body = json.dumps(messages)
-        response.body = body.encode("utf-8") if isinstance(body, text_type) else body
+        response.body = body.encode("utf-8") if isinstance(body, str) else body
         raise response
 
     def use_args(
         self,
         argmap,
         req=None,
-        locations=core.Parser.DEFAULT_LOCATIONS,
+        *,
+        location=core.Parser.DEFAULT_LOCATION,
+        unknown=None,
         as_kwargs=False,
         validate=None,
         error_status_code=None,
-        error_headers=None,
+        error_headers=None
     ):
         """Decorator that injects parsed arguments into a view callable.
         Supports the *Class-based View* pattern where `request` is saved as an instance
@@ -130,7 +134,9 @@ class PyramidParser(core.Parser):
             of argname -> `marshmallow.fields.Field` pairs, or a callable
             which accepts a request and returns a `marshmallow.Schema`.
         :param req: The request object to parse. Pulled off of the view by default.
-        :param tuple locations: Where on the request to search for values.
+        :param str location: Where on the request to load values.
+        :param str unknown: A value to pass for ``unknown`` when calling the
+            schema's ``load`` method.
         :param bool as_kwargs: Whether to insert arguments as keyword arguments.
         :param callable validate: Validation function that receives the dictionary
             of parsed arguments. If the function returns ``False``, the parser
@@ -140,11 +146,11 @@ class PyramidParser(core.Parser):
         :param dict error_headers: Headers passed to error handler functions when a
             a `ValidationError` is raised.
         """
-        locations = locations or self.locations
+        location = location or self.location
         # Optimization: If argmap is passed as a dictionary, we only need
         # to generate a Schema once
-        if isinstance(argmap, collections.Mapping):
-            argmap = core.dict2schema(argmap, self.schema_class)()
+        if isinstance(argmap, Mapping):
+            argmap = self.schema_class.from_dict(argmap)()
 
         def decorator(func):
             @functools.wraps(func)
@@ -158,16 +164,16 @@ class PyramidParser(core.Parser):
                 parsed_args = self.parse(
                     argmap,
                     req=request,
-                    locations=locations,
+                    location=location,
+                    unknown=unknown,
                     validate=validate,
                     error_status_code=error_status_code,
                     error_headers=error_headers,
                 )
-                if as_kwargs:
-                    kwargs.update(parsed_args)
-                    return func(obj, *args, **kwargs)
-                else:
-                    return func(obj, parsed_args, *args, **kwargs)
+                args, kwargs = self._update_args_kwargs(
+                    args, kwargs, parsed_args, as_kwargs
+                )
+                return func(obj, *args, **kwargs)
 
             wrapper.__wrapped__ = func
             return wrapper
diff --git a/src/webargs/testing.py b/src/webargs/testing.py
index 3d0f6fb..23bf918 100644
--- a/src/webargs/testing.py
+++ b/src/webargs/testing.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Utilities for testing. Includes a base test class
 for testing parsers.
 
@@ -13,7 +12,7 @@ import webtest
 from webargs.core import json
 
 
-class CommonTestCase(object):
+class CommonTestCase:
     """Base test class that defines test methods for common functionality across all
     parsers. Subclasses must define `create_app`, which returns a WSGI-like app.
     """
@@ -40,24 +39,32 @@ class CommonTestCase(object):
     def test_parse_querystring_args(self, testapp):
         assert testapp.get("/echo?name=Fred").json == {"name": "Fred"}
 
-    def test_parse_querystring_with_query_location_specified(self, testapp):
-        assert testapp.get("/echo_query?name=Steve").json == {"name": "Steve"}
-
     def test_parse_form(self, testapp):
-        assert testapp.post("/echo", {"name": "Joe"}).json == {"name": "Joe"}
+        assert testapp.post("/echo_form", {"name": "Joe"}).json == {"name": "Joe"}
 
     def test_parse_json(self, testapp):
-        assert testapp.post_json("/echo", {"name": "Fred"}).json == {"name": "Fred"}
+        assert testapp.post_json("/echo_json", {"name": "Fred"}).json == {
+            "name": "Fred"
+        }
+
+    def test_parse_json_missing(self, testapp):
+        assert testapp.post("/echo_json", "").json == {"name": "World"}
+
+    def test_parse_json_or_form(self, testapp):
+        assert testapp.post_json("/echo_json_or_form", {"name": "Fred"}).json == {
+            "name": "Fred"
+        }
+        assert testapp.post("/echo_json_or_form", {"name": "Joe"}).json == {
+            "name": "Joe"
+        }
+        assert testapp.post("/echo_json_or_form", "").json == {"name": "World"}
 
     def test_parse_querystring_default(self, testapp):
         assert testapp.get("/echo").json == {"name": "World"}
 
-    def test_parse_json_default(self, testapp):
-        assert testapp.post_json("/echo", {}).json == {"name": "World"}
-
     def test_parse_json_with_charset(self, testapp):
         res = testapp.post(
-            "/echo",
+            "/echo_json",
             json.dumps({"name": "Steve"}),
             content_type="application/json;charset=UTF-8",
         )
@@ -65,23 +72,27 @@ class CommonTestCase(object):
 
     def test_parse_json_with_vendor_media_type(self, testapp):
         res = testapp.post(
-            "/echo",
+            "/echo_json",
             json.dumps({"name": "Steve"}),
             content_type="application/vnd.api+json;charset=UTF-8",
         )
         assert res.json == {"name": "Steve"}
 
-    def test_parse_json_ignores_extra_data(self, testapp):
-        assert testapp.post_json("/echo", {"extra": "data"}).json == {"name": "World"}
+    def test_parse_ignore_extra_data(self, testapp):
+        assert testapp.post_json(
+            "/echo_ignoring_extra_data", {"extra": "data"}
+        ).json == {"name": "World"}
 
-    def test_parse_json_blank(self, testapp):
-        assert testapp.post_json("/echo", None).json == {"name": "World"}
+    def test_parse_json_empty(self, testapp):
+        assert testapp.post_json("/echo_json", {}).json == {"name": "World"}
 
-    def test_parse_json_ignore_unexpected_int(self, testapp):
-        assert testapp.post_json("/echo", 1).json == {"name": "World"}
+    def test_parse_json_error_unexpected_int(self, testapp):
+        res = testapp.post_json("/echo_json", 1, expect_errors=True)
+        assert res.status_code == 422
 
-    def test_parse_json_ignore_unexpected_list(self, testapp):
-        assert testapp.post_json("/echo", [{"extra": "data"}]).json == {"name": "World"}
+    def test_parse_json_error_unexpected_list(self, testapp):
+        res = testapp.post_json("/echo_json", [{"extra": "data"}], expect_errors=True)
+        assert res.status_code == 422
 
     def test_parse_json_many_schema_invalid_input(self, testapp):
         res = testapp.post_json(
@@ -93,34 +104,54 @@ class CommonTestCase(object):
         res = testapp.post_json("/echo_many_schema", [{"name": "Steve"}]).json
         assert res == [{"name": "Steve"}]
 
-    def test_parse_json_many_schema_ignore_malformed_data(self, testapp):
-        assert testapp.post_json("/echo_many_schema", {"extra": "data"}).json == []
+    def test_parse_json_many_schema_error_malformed_data(self, testapp):
+        res = testapp.post_json(
+            "/echo_many_schema", {"extra": "data"}, expect_errors=True
+        )
+        assert res.status_code == 422
 
     def test_parsing_form_default(self, testapp):
-        assert testapp.post("/echo", {}).json == {"name": "World"}
+        assert testapp.post("/echo_form", {}).json == {"name": "World"}
 
     def test_parse_querystring_multiple(self, testapp):
         expected = {"name": ["steve", "Loria"]}
         assert testapp.get("/echo_multi?name=steve&name=Loria").json == expected
 
+    # test that passing a single value parses correctly
+    # on parsers like falconparser, where there is no native MultiDict type,
+    # this verifies the usage of MultiDictProxy to ensure that single values
+    # are "listified"
+    def test_parse_querystring_multiple_single_value(self, testapp):
+        expected = {"name": ["steve"]}
+        assert testapp.get("/echo_multi?name=steve").json == expected
+
     def test_parse_form_multiple(self, testapp):
         expected = {"name": ["steve", "Loria"]}
         assert (
-            testapp.post("/echo_multi", {"name": ["steve", "Loria"]}).json == expected
+            testapp.post("/echo_multi_form", {"name": ["steve", "Loria"]}).json
+            == expected
         )
 
     def test_parse_json_list(self, testapp):
         expected = {"name": ["Steve"]}
-        assert testapp.post_json("/echo_multi", {"name": "Steve"}).json == expected
+        assert (
+            testapp.post_json("/echo_multi_json", {"name": ["Steve"]}).json == expected
+        )
+
+    def test_parse_json_list_error_malformed_data(self, testapp):
+        res = testapp.post_json(
+            "/echo_multi_json", {"name": "Steve"}, expect_errors=True
+        )
+        assert res.status_code == 422
 
     def test_parse_json_with_nonascii_chars(self, testapp):
-        text = u"øˆƒ£ºº∆ƒˆ∆"
-        assert testapp.post_json("/echo", {"name": text}).json == {"name": text}
+        text = "øˆƒ£ºº∆ƒˆ∆"
+        assert testapp.post_json("/echo_json", {"name": text}).json == {"name": text}
 
     # https://github.com/marshmallow-code/webargs/issues/427
     def test_parse_json_with_nonutf8_chars(self, testapp):
         res = testapp.post(
-            "/echo",
+            "/echo_json",
             b"\xfe",
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
@@ -130,7 +161,7 @@ class CommonTestCase(object):
         assert res.json == {"json": ["Invalid JSON body."]}
 
     def test_validation_error_returns_422_response(self, testapp):
-        res = testapp.post("/echo", {"name": "b"}, expect_errors=True)
+        res = testapp.post_json("/echo_json", {"name": "b"}, expect_errors=True)
         assert res.status_code == 422
 
     def test_user_validation_error_returns_422_response_by_default(self, testapp):
@@ -187,10 +218,6 @@ class CommonTestCase(object):
         res = testapp.post_json("/echo_nested_many", in_data)
         assert res.json == {}
 
-    def test_parse_json_if_no_json(self, testapp):
-        res = testapp.post("/echo")
-        assert res.json == {"name": "World"}
-
     def test_parse_files(self, testapp):
         res = testapp.post(
             "/echo_file", {"myfile": webtest.Upload("README.rst", b"data")}
@@ -199,8 +226,14 @@ class CommonTestCase(object):
 
     # https://github.com/sloria/webargs/pull/297
     def test_empty_json(self, testapp):
+        res = testapp.post("/echo_json")
+        assert res.status_code == 200
+        assert res.json == {"name": "World"}
+
+    # https://github.com/sloria/webargs/pull/297
+    def test_empty_json_with_headers(self, testapp):
         res = testapp.post(
-            "/echo",
+            "/echo_json",
             "",
             headers={"Accept": "application/json", "Content-Type": "application/json"},
         )
@@ -210,7 +243,7 @@ class CommonTestCase(object):
     # https://github.com/sloria/webargs/issues/329
     def test_invalid_json(self, testapp):
         res = testapp.post(
-            "/echo",
+            "/echo_json",
             '{"foo": "bar", }',
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
diff --git a/src/webargs/tornadoparser.py b/src/webargs/tornadoparser.py
index 984c1e5..4c919a0 100644
--- a/src/webargs/tornadoparser.py
+++ b/src/webargs/tornadoparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 """Tornado request argument parsing module.
 
 Example: ::
@@ -15,11 +14,11 @@ Example: ::
             self.write(response)
 """
 import tornado.web
+import tornado.concurrent
 from tornado.escape import _unicode
 
 from webargs import core
-from webargs.compat import basestring
-from webargs.core import json
+from webargs.multidictproxy import MultiDictProxy
 
 
 class HTTPError(tornado.web.HTTPError):
@@ -28,98 +27,97 @@ class HTTPError(tornado.web.HTTPError):
     def __init__(self, *args, **kwargs):
         self.messages = kwargs.pop("messages", {})
         self.headers = kwargs.pop("headers", None)
-        super(HTTPError, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
 
-def parse_json_body(req):
-    """Return the decoded JSON body from the request."""
+def is_json_request(req):
     content_type = req.headers.get("Content-Type")
-    if content_type and core.is_json(content_type):
-        try:
-            return core.parse_json(req.body)
-        except TypeError:
-            pass
-        except json.JSONDecodeError as e:
-            if e.doc == "":
-                return core.missing
-            else:
-                raise
-    return {}
+    return content_type is not None and core.is_json(content_type)
 
 
-# From tornado.web.RequestHandler.decode_argument
-def decode_argument(value, name=None):
-    """Decodes an argument from the request.
+class WebArgsTornadoMultiDictProxy(MultiDictProxy):
+    """
+    Override class for Tornado multidicts, handles argument decoding
+    requirements.
     """
-    try:
-        return _unicode(value)
-    except UnicodeDecodeError:
-        raise HTTPError(400, "Invalid unicode in %s: %r" % (name or "url", value[:40]))
-
-
-def get_value(d, name, field):
-    """Handle gets from 'multidicts' made of lists
 
-    It handles cases: ``{"key": [value]}`` and ``{"key": value}``
+    def __getitem__(self, key):
+        try:
+            value = self.data.get(key, core.missing)
+            if value is core.missing:
+                return core.missing
+            if key in self.multiple_keys:
+                return [
+                    _unicode(v) if isinstance(v, (str, bytes)) else v for v in value
+                ]
+            if value and isinstance(value, (list, tuple)):
+                value = value[0]
+
+            if isinstance(value, (str, bytes)):
+                return _unicode(value)
+            return value
+        # based on tornado.web.RequestHandler.decode_argument
+        except UnicodeDecodeError:
+            raise HTTPError(400, "Invalid unicode in {}: {!r}".format(key, value[:40]))
+
+
+class WebArgsTornadoCookiesMultiDictProxy(MultiDictProxy):
     """
-    multiple = core.is_multiple(field)
-    value = d.get(name, core.missing)
-    if value is core.missing:
-        return core.missing
-    if multiple and value is not core.missing:
-        return [
-            decode_argument(v, name) if isinstance(v, basestring) else v for v in value
-        ]
-    ret = value
-    if value and isinstance(value, (list, tuple)):
-        ret = value[0]
-    if isinstance(ret, basestring):
-        return decode_argument(ret, name)
-    else:
-        return ret
+    And a special override for cookies because they come back as objects with a
+    `value` attribute we need to extract.
+    Also, does not use the `_unicode` decoding step
+    """
+
+    def __getitem__(self, key):
+        cookie = self.data.get(key, core.missing)
+        if cookie is core.missing:
+            return core.missing
+        if key in self.multiple_keys:
+            return [cookie.value]
+        return cookie.value
 
 
 class TornadoParser(core.Parser):
     """Tornado request argument parser."""
 
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            try:
-                self._cache["json"] = json_data = parse_json_body(req)
-            except json.JSONDecodeError as e:
-                return self.handle_invalid_json_error(e, req)
-            if json_data is None:
-                return core.missing
-        return core.get_value(json_data, name, field, allow_many_nested=True)
+    def _raw_load_json(self, req):
+        """Return a json payload from the request for the core parser's load_json
 
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return get_value(req.query_arguments, name, field)
+        Checks the input mimetype and may return 'missing' if the mimetype is
+        non-json, even if the request body is parseable as json."""
+        if not is_json_request(req):
+            return core.missing
 
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request."""
-        return get_value(req.body_arguments, name, field)
+        # request.body may be a concurrent.Future on streaming requests
+        # this would cause a TypeError if we try to parse it
+        if isinstance(req.body, tornado.concurrent.Future):
+            return core.missing
 
-    def parse_headers(self, req, name, field):
-        """Pull a value from the header data."""
-        return get_value(req.headers, name, field)
+        return core.parse_json(req.body)
 
-    def parse_cookies(self, req, name, field):
-        """Pull a value from the header data."""
-        cookie = req.cookies.get(name)
+    def load_querystring(self, req, schema):
+        """Return query params from the request as a MultiDictProxy."""
+        return WebArgsTornadoMultiDictProxy(req.query_arguments, schema)
 
-        if cookie is not None:
-            return [cookie.value] if core.is_multiple(field) else cookie.value
-        else:
-            return [] if core.is_multiple(field) else None
+    def load_form(self, req, schema):
+        """Return form values from the request as a MultiDictProxy."""
+        return WebArgsTornadoMultiDictProxy(req.body_arguments, schema)
+
+    def load_headers(self, req, schema):
+        """Return headers from the request as a MultiDictProxy."""
+        return WebArgsTornadoMultiDictProxy(req.headers, schema)
+
+    def load_cookies(self, req, schema):
+        """Return cookies from the request as a MultiDictProxy."""
+        # use the specialized subclass specifically for handling Tornado
+        # cookies
+        return WebArgsTornadoCookiesMultiDictProxy(req.cookies, schema)
 
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
-        return get_value(req.files, name, field)
+    def load_files(self, req, schema):
+        """Return files from the request as a MultiDictProxy."""
+        return WebArgsTornadoMultiDictProxy(req.files, schema)
 
-    def handle_error(self, error, req, schema, error_status_code, error_headers):
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         """Handles errors during parsing. Raises a `tornado.web.HTTPError`
         with a 400 error.
         """
@@ -136,7 +134,7 @@ class TornadoParser(core.Parser):
             headers=error_headers,
         )
 
-    def handle_invalid_json_error(self, error, req, *args, **kwargs):
+    def _handle_invalid_json_error(self, error, req, *args, **kwargs):
         raise HTTPError(
             400,
             log_message="Invalid JSON body.",
diff --git a/src/webargs/webapp2parser.py b/src/webargs/webapp2parser.py
deleted file mode 100644
index caeacef..0000000
--- a/src/webargs/webapp2parser.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Webapp2 request argument parsing module.
-
-Example: ::
-
-    import webapp2
-
-    from marshmallow import fields
-    from webargs.webobparser import use_args
-
-    hello_args = {
-        'name': fields.Str(missing='World')
-    }
-
-    class MainPage(webapp2.RequestHandler):
-
-        @use_args(hello_args)
-        def get_args(self, args):
-            self.response.write('Hello, {name}!'.format(name=args['name']))
-
-        @use_kwargs(hello_args)
-        def get_kwargs(self, name=None):
-            self.response.write('Hello, {name}!'.format(name=name))
-
-    app = webapp2.WSGIApplication([
-        webapp2.Route(r'/hello', MainPage, handler_method='get_args'),
-        webapp2.Route(r'/hello_dict', MainPage, handler_method='get_kwargs'),
-    ], debug=True)
-"""
-import webapp2
-import webob.multidict
-
-from webargs import core
-from webargs.core import json
-
-
-class Webapp2Parser(core.Parser):
-    """webapp2 request argument parser."""
-
-    def parse_json(self, req, name, field):
-        """Pull a json value from the request."""
-        json_data = self._cache.get("json")
-        if json_data is None:
-            if not core.is_json(req.content_type):
-                return core.missing
-
-            try:
-                self._cache["json"] = json_data = core.parse_json(req.body)
-            except json.JSONDecodeError as e:
-                if e.doc == "":
-                    return core.missing
-                else:
-                    raise
-        return core.get_value(json_data, name, field, allow_many_nested=True)
-
-    def parse_querystring(self, req, name, field):
-        """Pull a querystring value from the request."""
-        return core.get_value(req.GET, name, field)
-
-    def parse_form(self, req, name, field):
-        """Pull a form value from the request."""
-        return core.get_value(req.POST, name, field)
-
-    def parse_cookies(self, req, name, field):
-        """Pull the value from the cookiejar."""
-        return core.get_value(req.cookies, name, field)
-
-    def parse_headers(self, req, name, field):
-        """Pull a value from the header data."""
-        return core.get_value(req.headers, name, field)
-
-    def parse_files(self, req, name, field):
-        """Pull a file from the request."""
-        files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file"))
-        return core.get_value(webob.multidict.MultiDict(files), name, field)
-
-    def get_default_request(self):
-        return webapp2.get_request()
-
-
-parser = Webapp2Parser()
-use_args = parser.use_args
-use_kwargs = parser.use_kwargs
diff --git a/tests/__init__.py b/tests/__init__.py
index 40a96af..e69de29 100755
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1 +0,0 @@
-# -*- coding: utf-8 -*-
diff --git a/tests/apps/aiohttp_app.py b/tests/apps/aiohttp_app.py
index b72464e..8796371 100644
--- a/tests/apps/aiohttp_app.py
+++ b/tests/apps/aiohttp_app.py
@@ -1,13 +1,10 @@
-import asyncio
-
 import aiohttp
 from aiohttp.web import json_response
-from aiohttp import web
 import marshmallow as ma
 
 from webargs import fields
 from webargs.aiohttpparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO, json
+from webargs.core import json
 
 hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
 hello_multiple = {"name": fields.List(fields.Str())}
@@ -16,81 +13,103 @@ hello_multiple = {"name": fields.List(fields.Str())}
 class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
-    if MARSHMALLOW_VERSION_INFO[0] < 3:
 
-        class Meta:
-            strict = True
+hello_many_schema = HelloSchema(many=True)
 
+# variant which ignores unknown fields
+hello_exclude_schema = HelloSchema(unknown=ma.EXCLUDE)
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
 
 ##### Handlers #####
 
 
 async def echo(request):
-    try:
-        parsed = await parser.parse(hello_args, request)
-    except json.JSONDecodeError:
-        raise web.HTTPBadRequest(
-            body=json.dumps(["Invalid JSON."]).encode("utf-8"),
-            content_type="application/json",
-        )
+    parsed = await parser.parse(hello_args, request, location="query")
     return json_response(parsed)
 
 
-async def echo_query(request):
-    parsed = await parser.parse(hello_args, request, locations=("query",))
+async def echo_form(request):
+    parsed = await parser.parse(hello_args, request, location="form")
     return json_response(parsed)
 
 
 async def echo_json(request):
-    parsed = await parser.parse(hello_args, request, locations=("json",))
+    try:
+        parsed = await parser.parse(hello_args, request, location="json")
+    except json.JSONDecodeError:
+        raise aiohttp.web.HTTPBadRequest(
+            body=json.dumps(["Invalid JSON."]).encode("utf-8"),
+            content_type="application/json",
+        )
     return json_response(parsed)
 
 
-async def echo_form(request):
-    parsed = await parser.parse(hello_args, request, locations=("form",))
+async def echo_json_or_form(request):
+    try:
+        parsed = await parser.parse(hello_args, request, location="json_or_form")
+    except json.JSONDecodeError:
+        raise aiohttp.web.HTTPBadRequest(
+            body=json.dumps(["Invalid JSON."]).encode("utf-8"),
+            content_type="application/json",
+        )
     return json_response(parsed)
 
 
-@use_args(hello_args)
+@use_args(hello_args, location="query")
 async def echo_use_args(request, args):
     return json_response(args)
 
 
-@use_kwargs(hello_args)
+@use_kwargs(hello_args, location="query")
 async def echo_use_kwargs(request, name):
     return json_response({"name": name})
 
 
-@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42)
+@use_args(
+    {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form"
+)
 async def echo_use_args_validated(request, args):
     return json_response(args)
 
 
+async def echo_ignoring_extra_data(request):
+    return json_response(
+        await parser.parse(hello_exclude_schema, request, unknown=None)
+    )
+
+
 async def echo_multi(request):
+    parsed = await parser.parse(hello_multiple, request, location="query")
+    return json_response(parsed)
+
+
+async def echo_multi_form(request):
+    parsed = await parser.parse(hello_multiple, request, location="form")
+    return json_response(parsed)
+
+
+async def echo_multi_json(request):
     parsed = await parser.parse(hello_multiple, request)
     return json_response(parsed)
 
 
 async def echo_many_schema(request):
-    parsed = await parser.parse(hello_many_schema, request, locations=("json",))
+    parsed = await parser.parse(hello_many_schema, request)
     return json_response(parsed)
 
 
-@use_args({"value": fields.Int()})
+@use_args({"value": fields.Int()}, location="query")
 async def echo_use_args_with_path_param(request, args):
     return json_response(args)
 
 
-@use_kwargs({"value": fields.Int()})
+@use_kwargs({"value": fields.Int()}, location="query")
 async def echo_use_kwargs_with_path_param(request, value):
     return json_response({"value": value})
 
 
-@use_args({"page": fields.Int(), "q": fields.Int()}, locations=("query",))
-@use_args({"name": fields.Str()}, locations=("json",))
+@use_args({"page": fields.Int(), "q": fields.Int()}, location="query")
+@use_args({"name": fields.Str()})
 async def echo_use_args_multiple(request, query_parsed, json_parsed):
     return json_response({"query_parsed": query_parsed, "json_parsed": json_parsed})
 
@@ -105,12 +124,12 @@ async def always_error(request):
 
 
 async def echo_headers(request):
-    parsed = await parser.parse(hello_args, request, locations=("headers",))
+    parsed = await parser.parse(hello_args, request, location="headers")
     return json_response(parsed)
 
 
 async def echo_cookie(request):
-    parsed = await parser.parse(hello_args, request, locations=("cookies",))
+    parsed = await parser.parse(hello_args, request, location="cookies")
     return json_response(parsed)
 
 
@@ -135,35 +154,34 @@ async def echo_nested_many(request):
 
 
 async def echo_nested_many_data_key(request):
-    data_key_kwarg = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "X-Field"
+    args = {
+        "x_field": fields.Nested({"id": fields.Int()}, many=True, data_key="X-Field")
     }
-    args = {"x_field": fields.Nested({"id": fields.Int()}, many=True, **data_key_kwarg)}
     parsed = await parser.parse(args, request)
     return json_response(parsed)
 
 
 async def echo_match_info(request):
-    parsed = await parser.parse({"mymatch": fields.Int(location="match_info")}, request)
+    parsed = await parser.parse(
+        {"mymatch": fields.Int()}, request, location="match_info"
+    )
     return json_response(parsed)
 
 
 class EchoHandler:
-    @use_args(hello_args)
+    @use_args(hello_args, location="query")
     async def get(self, request, args):
         return json_response(args)
 
 
-class EchoHandlerView(web.View):
-    @asyncio.coroutine
-    @use_args(hello_args)
-    def get(self, args):
+class EchoHandlerView(aiohttp.web.View):
+    @use_args(hello_args, location="query")
+    async def get(self, args):
         return json_response(args)
 
 
-@asyncio.coroutine
-@use_args(HelloSchema, as_kwargs=True)
-def echo_use_schema_as_kwargs(request, name):
+@use_args(HelloSchema, as_kwargs=True, location="query")
+async def echo_use_schema_as_kwargs(request, name):
     return json_response({"name": name})
 
 
@@ -178,14 +196,17 @@ def add_route(app, methods, route, handler):
 def create_app():
     app = aiohttp.web.Application()
 
-    add_route(app, ["GET", "POST"], "/echo", echo)
-    add_route(app, ["GET"], "/echo_query", echo_query)
-    add_route(app, ["POST"], "/echo_json", echo_json)
+    add_route(app, ["GET"], "/echo", echo)
     add_route(app, ["POST"], "/echo_form", echo_form)
-    add_route(app, ["GET", "POST"], "/echo_use_args", echo_use_args)
-    add_route(app, ["GET", "POST"], "/echo_use_kwargs", echo_use_kwargs)
-    add_route(app, ["GET", "POST"], "/echo_use_args_validated", echo_use_args_validated)
-    add_route(app, ["GET", "POST"], "/echo_multi", echo_multi)
+    add_route(app, ["POST"], "/echo_json", echo_json)
+    add_route(app, ["POST"], "/echo_json_or_form", echo_json_or_form)
+    add_route(app, ["GET"], "/echo_use_args", echo_use_args)
+    add_route(app, ["GET"], "/echo_use_kwargs", echo_use_kwargs)
+    add_route(app, ["POST"], "/echo_use_args_validated", echo_use_args_validated)
+    add_route(app, ["POST"], "/echo_ignoring_extra_data", echo_ignoring_extra_data)
+    add_route(app, ["GET"], "/echo_multi", echo_multi)
+    add_route(app, ["POST"], "/echo_multi_form", echo_multi_form)
+    add_route(app, ["POST"], "/echo_multi_json", echo_multi_json)
     add_route(app, ["GET", "POST"], "/echo_many_schema", echo_many_schema)
     add_route(
         app,
diff --git a/tests/apps/bottle_app.py b/tests/apps/bottle_app.py
index da13c84..199cd98 100644
--- a/tests/apps/bottle_app.py
+++ b/tests/apps/bottle_app.py
@@ -1,10 +1,10 @@
-from webargs.core import json
 from bottle import Bottle, HTTPResponse, debug, request, response
 
 import marshmallow as ma
 from webargs import fields
 from webargs.bottleparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO
+from webargs.core import json
+
 
 hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
 hello_multiple = {"name": fields.List(fields.Str())}
@@ -14,74 +14,99 @@ class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
+hello_many_schema = HelloSchema(many=True)
+
+# variant which ignores unknown fields
+hello_exclude_schema = HelloSchema(unknown=ma.EXCLUDE)
 
 
 app = Bottle()
 debug(True)
 
 
-@app.route("/echo", method=["GET", "POST"])
+@app.route("/echo", method=["GET"])
 def echo():
-    return parser.parse(hello_args, request)
+    return parser.parse(hello_args, request, location="query")
 
 
-@app.route("/echo_query")
-def echo_query():
-    return parser.parse(hello_args, request, locations=("query",))
+@app.route("/echo_form", method=["POST"])
+def echo_form():
+    return parser.parse(hello_args, location="form")
 
 
 @app.route("/echo_json", method=["POST"])
 def echo_json():
-    return parser.parse(hello_args, request, locations=("json",))
+    return parser.parse(hello_args, location="json")
 
 
-@app.route("/echo_form", method=["POST"])
-def echo_form():
-    return parser.parse(hello_args, request, locations=("form",))
+@app.route("/echo_json_or_form", method=["POST"])
+def echo_json_or_form():
+    return parser.parse(hello_args, location="json_or_form")
 
 
-@app.route("/echo_use_args", method=["GET", "POST"])
-@use_args(hello_args)
+@app.route("/echo_use_args", method=["GET"])
+@use_args(hello_args, location="query")
 def echo_use_args(args):
     return args
 
 
-@app.route("/echo_use_kwargs", method=["GET", "POST"], apply=use_kwargs(hello_args))
-def echo_use_kwargs(name):
-    return {"name": name}
-
-
 @app.route(
     "/echo_use_args_validated",
-    method=["GET", "POST"],
-    apply=use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42),
+    method=["POST"],
+    apply=use_args(
+        {"value": fields.Int()},
+        validate=lambda args: args["value"] > 42,
+        location="form",
+    ),
 )
 def echo_use_args_validated(args):
     return args
 
 
-@app.route("/echo_multi", method=["GET", "POST"])
+@app.route("/echo_ignoring_extra_data", method=["POST"])
+def echo_json_ignore_extra_data():
+    return parser.parse(hello_exclude_schema, unknown=None)
+
+
+@app.route(
+    "/echo_use_kwargs", method=["GET"], apply=use_kwargs(hello_args, location="query")
+)
+def echo_use_kwargs(name):
+    return {"name": name}
+
+
+@app.route("/echo_multi", method=["GET"])
 def echo_multi():
-    return parser.parse(hello_multiple, request)
+    return parser.parse(hello_multiple, request, location="query")
+
+
+@app.route("/echo_multi_form", method=["POST"])
+def multi_form():
+    return parser.parse(hello_multiple, location="form")
+
+
+@app.route("/echo_multi_json", method=["POST"])
+def multi_json():
+    return parser.parse(hello_multiple)
 
 
-@app.route("/echo_many_schema", method=["GET", "POST"])
+@app.route("/echo_many_schema", method=["POST"])
 def echo_many_schema():
-    arguments = parser.parse(hello_many_schema, request, locations=("json",))
+    arguments = parser.parse(hello_many_schema, request)
     return HTTPResponse(body=json.dumps(arguments), content_type="application/json")
 
 
 @app.route(
-    "/echo_use_args_with_path_param/<name>", apply=use_args({"value": fields.Int()})
+    "/echo_use_args_with_path_param/<name>",
+    apply=use_args({"value": fields.Int()}, location="query"),
 )
 def echo_use_args_with_path_param(args, name):
     return args
 
 
 @app.route(
-    "/echo_use_kwargs_with_path_param/<name>", apply=use_kwargs({"value": fields.Int()})
+    "/echo_use_kwargs_with_path_param/<name>",
+    apply=use_kwargs({"value": fields.Int()}, location="query"),
 )
 def echo_use_kwargs_with_path_param(name, value):
     return {"value": value}
@@ -98,18 +123,18 @@ def always_error():
 
 @app.route("/echo_headers")
 def echo_headers():
-    return parser.parse(hello_args, request, locations=("headers",))
+    return parser.parse(hello_args, request, location="headers")
 
 
 @app.route("/echo_cookie")
 def echo_cookie():
-    return parser.parse(hello_args, request, locations=("cookies",))
+    return parser.parse(hello_args, request, location="cookies")
 
 
 @app.route("/echo_file", method=["POST"])
 def echo_file():
     args = {"myfile": fields.Field()}
-    result = parser.parse(args, locations=("files",))
+    result = parser.parse(args, location="files")
     myfile = result["myfile"]
     content = myfile.file.read().decode("utf8")
     return {"myfile": content}
diff --git a/tests/apps/django_app/base/settings.py b/tests/apps/django_app/base/settings.py
index a127df7..0dd41b0 100644
--- a/tests/apps/django_app/base/settings.py
+++ b/tests/apps/django_app/base/settings.py
@@ -7,7 +7,7 @@ DEBUG = True
 
 TEMPLATE_DEBUG = True
 
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ["*"]
 # Application definition
 
 INSTALLED_APPS = ("django.contrib.contenttypes",)
diff --git a/tests/apps/django_app/base/urls.py b/tests/apps/django_app/base/urls.py
index dfb6745..07a86e9 100644
--- a/tests/apps/django_app/base/urls.py
+++ b/tests/apps/django_app/base/urls.py
@@ -2,14 +2,19 @@ from django.conf.urls import url
 
 from tests.apps.django_app.echo import views
 
+
 urlpatterns = [
     url(r"^echo$", views.echo),
-    url(r"^echo_query$", views.echo_query),
-    url(r"^echo_json$", views.echo_json),
     url(r"^echo_form$", views.echo_form),
+    url(r"^echo_json$", views.echo_json),
+    url(r"^echo_json_or_form$", views.echo_json_or_form),
     url(r"^echo_use_args$", views.echo_use_args),
+    url(r"^echo_use_args_validated$", views.echo_use_args_validated),
+    url(r"^echo_ignoring_extra_data$", views.echo_ignoring_extra_data),
     url(r"^echo_use_kwargs$", views.echo_use_kwargs),
     url(r"^echo_multi$", views.echo_multi),
+    url(r"^echo_multi_form$", views.echo_multi_form),
+    url(r"^echo_multi_json$", views.echo_multi_json),
     url(r"^echo_many_schema$", views.echo_many_schema),
     url(
         r"^echo_use_args_with_path_param/(?P<name>\w+)$",
diff --git a/tests/apps/django_app/echo/views.py b/tests/apps/django_app/echo/views.py
index 2dffe97..e37428f 100644
--- a/tests/apps/django_app/echo/views.py
+++ b/tests/apps/django_app/echo/views.py
@@ -1,11 +1,11 @@
-from webargs.core import json
 from django.http import HttpResponse
 from django.views.generic import View
-
 import marshmallow as ma
+
 from webargs import fields
 from webargs.djangoparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO
+from webargs.core import json
+
 
 hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
 hello_multiple = {"name": fields.List(fields.Str())}
@@ -15,101 +15,140 @@ class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
+hello_many_schema = HelloSchema(many=True)
+
+# variant which ignores unknown fields
+hello_exclude_schema = HelloSchema(unknown=ma.EXCLUDE)
 
 
 def json_response(data, **kwargs):
     return HttpResponse(json.dumps(data), content_type="application/json", **kwargs)
 
 
+def handle_view_errors(f):
+    def wrapped(*args, **kwargs):
+        try:
+            return f(*args, **kwargs)
+        except ma.ValidationError as err:
+            return json_response(err.messages, status=422)
+        except json.JSONDecodeError:
+            return json_response({"json": ["Invalid JSON body."]}, status=400)
+
+    return wrapped
+
+
+@handle_view_errors
 def echo(request):
-    try:
-        args = parser.parse(hello_args, request)
-    except ma.ValidationError as err:
-        return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS)
-    except json.JSONDecodeError:
-        return json_response({"json": ["Invalid JSON body."]}, status=400)
-    return json_response(args)
+    return json_response(parser.parse(hello_args, request, location="query"))
 
 
-def echo_query(request):
-    return json_response(parser.parse(hello_args, request, locations=("query",)))
+@handle_view_errors
+def echo_form(request):
+    return json_response(parser.parse(hello_args, request, location="form"))
 
 
+@handle_view_errors
 def echo_json(request):
-    return json_response(parser.parse(hello_args, request, locations=("json",)))
+    return json_response(parser.parse(hello_args, request, location="json"))
 
 
-def echo_form(request):
-    return json_response(parser.parse(hello_args, request, locations=("form",)))
+@handle_view_errors
+def echo_json_or_form(request):
+    return json_response(parser.parse(hello_args, request, location="json_or_form"))
 
 
-@use_args(hello_args)
+@handle_view_errors
+@use_args(hello_args, location="query")
 def echo_use_args(request, args):
     return json_response(args)
 
 
-@use_kwargs(hello_args)
+@handle_view_errors
+@use_args(
+    {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form"
+)
+def echo_use_args_validated(args):
+    return json_response(args)
+
+
+@handle_view_errors
+def echo_ignoring_extra_data(request):
+    return json_response(parser.parse(hello_exclude_schema, request, unknown=None))
+
+
+@handle_view_errors
+@use_kwargs(hello_args, location="query")
 def echo_use_kwargs(request, name):
     return json_response({"name": name})
 
 
+@handle_view_errors
 def echo_multi(request):
+    return json_response(parser.parse(hello_multiple, request, location="query"))
+
+
+@handle_view_errors
+def echo_multi_form(request):
+    return json_response(parser.parse(hello_multiple, request, location="form"))
+
+
+@handle_view_errors
+def echo_multi_json(request):
     return json_response(parser.parse(hello_multiple, request))
 
 
+@handle_view_errors
 def echo_many_schema(request):
-    try:
-        return json_response(
-            parser.parse(hello_many_schema, request, locations=("json",))
-        )
-    except ma.ValidationError as err:
-        return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS)
+    return json_response(parser.parse(hello_many_schema, request))
 
 
-@use_args({"value": fields.Int()})
+@handle_view_errors
+@use_args({"value": fields.Int()}, location="query")
 def echo_use_args_with_path_param(request, args, name):
     return json_response(args)
 
 
-@use_kwargs({"value": fields.Int()})
+@handle_view_errors
+@use_kwargs({"value": fields.Int()}, location="query")
 def echo_use_kwargs_with_path_param(request, value, name):
     return json_response({"value": value})
 
 
+@handle_view_errors
 def always_error(request):
     def always_fail(value):
         raise ma.ValidationError("something went wrong")
 
     argmap = {"text": fields.Str(validate=always_fail)}
-    try:
-        return parser.parse(argmap, request)
-    except ma.ValidationError as err:
-        return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS)
+    return parser.parse(argmap, request)
 
 
+@handle_view_errors
 def echo_headers(request):
-    return json_response(parser.parse(hello_args, request, locations=("headers",)))
+    return json_response(parser.parse(hello_args, request, location="headers"))
 
 
+@handle_view_errors
 def echo_cookie(request):
-    return json_response(parser.parse(hello_args, request, locations=("cookies",)))
+    return json_response(parser.parse(hello_args, request, location="cookies"))
 
 
+@handle_view_errors
 def echo_file(request):
     args = {"myfile": fields.Field()}
-    result = parser.parse(args, request, locations=("files",))
+    result = parser.parse(args, request, location="files")
     myfile = result["myfile"]
     content = myfile.read().decode("utf8")
     return json_response({"myfile": content})
 
 
+@handle_view_errors
 def echo_nested(request):
     argmap = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})}
     return json_response(parser.parse(argmap, request))
 
 
+@handle_view_errors
 def echo_nested_many(request):
     argmap = {
         "users": fields.Nested({"id": fields.Int(), "name": fields.Str()}, many=True)
@@ -118,27 +157,33 @@ def echo_nested_many(request):
 
 
 class EchoCBV(View):
+    @handle_view_errors
     def get(self, request):
-        try:
-            args = parser.parse(hello_args, self.request)
-        except ma.ValidationError as err:
-            return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS)
-        return json_response(args)
+        location_kwarg = {} if request.method == "POST" else {"location": "query"}
+        return json_response(parser.parse(hello_args, self.request, **location_kwarg))
 
     post = get
 
 
 class EchoUseArgsCBV(View):
-    @use_args(hello_args)
+    @handle_view_errors
+    @use_args(hello_args, location="query")
     def get(self, request, args):
         return json_response(args)
 
-    post = get
+    @handle_view_errors
+    @use_args(hello_args)
+    def post(self, request, args):
+        return json_response(args)
 
 
 class EchoUseArgsWithParamCBV(View):
-    @use_args(hello_args)
+    @handle_view_errors
+    @use_args(hello_args, location="query")
     def get(self, request, args, pid):
         return json_response(args)
 
-    post = get
+    @handle_view_errors
+    @use_args(hello_args)
+    def post(self, request, args, pid):
+        return json_response(args)
diff --git a/tests/apps/falcon_app.py b/tests/apps/falcon_app.py
index 12f5cb7..cb22529 100644
--- a/tests/apps/falcon_app.py
+++ b/tests/apps/falcon_app.py
@@ -1,10 +1,9 @@
-from webargs.core import json
-
 import falcon
 import marshmallow as ma
+
 from webargs import fields
+from webargs.core import json
 from webargs.falconparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO
 
 hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
 hello_multiple = {"name": fields.List(fields.Str())}
@@ -14,94 +13,102 @@ class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
+hello_many_schema = HelloSchema(many=True)
+
+# variant which ignores unknown fields
+hello_exclude_schema = HelloSchema(unknown=ma.EXCLUDE)
 
 
-class Echo(object):
+class Echo:
     def on_get(self, req, resp):
-        try:
-            parsed = parser.parse(hello_args, req)
-        except json.JSONDecodeError:
-            resp.body = json.dumps(["Invalid JSON."])
-            resp.status = falcon.HTTP_400
-        else:
-            resp.body = json.dumps(parsed)
+        parsed = parser.parse(hello_args, req, location="query")
+        resp.body = json.dumps(parsed)
 
-    on_post = on_get
 
+class EchoForm:
+    def on_post(self, req, resp):
+        parsed = parser.parse(hello_args, req, location="form")
+        resp.body = json.dumps(parsed)
 
-class EchoQuery(object):
-    def on_get(self, req, resp):
-        parsed = parser.parse(hello_args, req, locations=("query",))
+
+class EchoJSON:
+    def on_post(self, req, resp):
+        parsed = parser.parse(hello_args, req, location="json")
         resp.body = json.dumps(parsed)
 
 
-class EchoJSON(object):
+class EchoMedia:
     def on_post(self, req, resp):
-        parsed = parser.parse(hello_args, req, locations=("json",))
+        parsed = parser.parse(hello_args, req, location="media")
         resp.body = json.dumps(parsed)
 
 
-class EchoForm(object):
+class EchoJSONOrForm:
     def on_post(self, req, resp):
-        parsed = parser.parse(hello_args, req, locations=("form",))
+        parsed = parser.parse(hello_args, req, location="json_or_form")
         resp.body = json.dumps(parsed)
 
 
-class EchoUseArgs(object):
-    @use_args(hello_args)
+class EchoUseArgs:
+    @use_args(hello_args, location="query")
     def on_get(self, req, resp, args):
         resp.body = json.dumps(args)
 
-    on_post = on_get
-
 
-class EchoUseKwargs(object):
-    @use_kwargs(hello_args)
+class EchoUseKwargs:
+    @use_kwargs(hello_args, location="query")
     def on_get(self, req, resp, name):
         resp.body = json.dumps({"name": name})
 
-    on_post = on_get
-
 
-class EchoUseArgsValidated(object):
-    @use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42)
-    def on_get(self, req, resp, args):
+class EchoUseArgsValidated:
+    @use_args(
+        {"value": fields.Int()},
+        validate=lambda args: args["value"] > 42,
+        location="form",
+    )
+    def on_post(self, req, resp, args):
         resp.body = json.dumps(args)
 
-    on_post = on_get
 
+class EchoJSONIgnoreExtraData:
+    def on_post(self, req, resp):
+        resp.body = json.dumps(parser.parse(hello_exclude_schema, req, unknown=None))
 
-class EchoMulti(object):
+
+class EchoMulti:
     def on_get(self, req, resp):
-        resp.body = json.dumps(parser.parse(hello_multiple, req))
+        resp.body = json.dumps(parser.parse(hello_multiple, req, location="query"))
 
-    on_post = on_get
 
+class EchoMultiForm:
+    def on_post(self, req, resp):
+        resp.body = json.dumps(parser.parse(hello_multiple, req, location="form"))
 
-class EchoManySchema(object):
-    def on_get(self, req, resp):
-        resp.body = json.dumps(
-            parser.parse(hello_many_schema, req, locations=("json",))
-        )
 
-    on_post = on_get
+class EchoMultiJSON:
+    def on_post(self, req, resp):
+        resp.body = json.dumps(parser.parse(hello_multiple, req))
+
+
+class EchoManySchema:
+    def on_post(self, req, resp):
+        resp.body = json.dumps(parser.parse(hello_many_schema, req))
 
 
-class EchoUseArgsWithPathParam(object):
-    @use_args({"value": fields.Int()})
+class EchoUseArgsWithPathParam:
+    @use_args({"value": fields.Int()}, location="query")
     def on_get(self, req, resp, args, name):
         resp.body = json.dumps(args)
 
 
-class EchoUseKwargsWithPathParam(object):
-    @use_kwargs({"value": fields.Int()})
+class EchoUseKwargsWithPathParam:
+    @use_kwargs({"value": fields.Int()}, location="query")
     def on_get(self, req, resp, value, name):
         resp.body = json.dumps({"value": value})
 
 
-class AlwaysError(object):
+class AlwaysError:
     def on_get(self, req, resp):
         def always_fail(value):
             raise ma.ValidationError("something went wrong")
@@ -112,23 +119,26 @@ class AlwaysError(object):
     on_post = on_get
 
 
-class EchoHeaders(object):
+class EchoHeaders:
     def on_get(self, req, resp):
-        resp.body = json.dumps(parser.parse(hello_args, req, locations=("headers",)))
+        class HeaderSchema(ma.Schema):
+            NAME = fields.Str(missing="World")
+
+        resp.body = json.dumps(parser.parse(HeaderSchema(), req, location="headers"))
 
 
-class EchoCookie(object):
+class EchoCookie:
     def on_get(self, req, resp):
-        resp.body = json.dumps(parser.parse(hello_args, req, locations=("cookies",)))
+        resp.body = json.dumps(parser.parse(hello_args, req, location="cookies"))
 
 
-class EchoNested(object):
+class EchoNested:
     def on_post(self, req, resp):
         args = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})}
         resp.body = json.dumps(parser.parse(args, req))
 
 
-class EchoNestedMany(object):
+class EchoNestedMany:
     def on_post(self, req, resp):
         args = {
             "users": fields.Nested(
@@ -139,15 +149,15 @@ class EchoNestedMany(object):
 
 
 def use_args_hook(args, context_key="args", **kwargs):
-    def hook(req, resp, params):
+    def hook(req, resp, resource, params):
         parsed_args = parser.parse(args, req=req, **kwargs)
         req.context[context_key] = parsed_args
 
     return hook
 
 
-@falcon.before(use_args_hook(hello_args))
-class EchoUseArgsHook(object):
+@falcon.before(use_args_hook(hello_args, location="query"))
+class EchoUseArgsHook:
     def on_get(self, req, resp):
         resp.body = json.dumps(req.context["args"])
 
@@ -155,13 +165,17 @@ class EchoUseArgsHook(object):
 def create_app():
     app = falcon.API()
     app.add_route("/echo", Echo())
-    app.add_route("/echo_query", EchoQuery())
-    app.add_route("/echo_json", EchoJSON())
     app.add_route("/echo_form", EchoForm())
+    app.add_route("/echo_json", EchoJSON())
+    app.add_route("/echo_media", EchoMedia())
+    app.add_route("/echo_json_or_form", EchoJSONOrForm())
     app.add_route("/echo_use_args", EchoUseArgs())
     app.add_route("/echo_use_kwargs", EchoUseKwargs())
     app.add_route("/echo_use_args_validated", EchoUseArgsValidated())
+    app.add_route("/echo_ignoring_extra_data", EchoJSONIgnoreExtraData())
     app.add_route("/echo_multi", EchoMulti())
+    app.add_route("/echo_multi_form", EchoMultiForm())
+    app.add_route("/echo_multi_json", EchoMultiJSON())
     app.add_route("/echo_many_schema", EchoManySchema())
     app.add_route("/echo_use_args_with_path_param/{name}", EchoUseArgsWithPathParam())
     app.add_route(
diff --git a/tests/apps/flask_app.py b/tests/apps/flask_app.py
index c546dd4..68d01ee 100644
--- a/tests/apps/flask_app.py
+++ b/tests/apps/flask_app.py
@@ -1,11 +1,10 @@
-from webargs.core import json
 from flask import Flask, jsonify as J, Response, request
 from flask.views import MethodView
-
 import marshmallow as ma
+
 from webargs import fields
 from webargs.flaskparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO
+from webargs.core import json
 
 
 class TestAppConfig:
@@ -20,70 +19,86 @@ class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
+hello_many_schema = HelloSchema(many=True)
 
 app = Flask(__name__)
 app.config.from_object(TestAppConfig)
 
 
-@app.route("/echo", methods=["GET", "POST"])
+@app.route("/echo", methods=["GET"])
 def echo():
-    return J(parser.parse(hello_args))
+    return J(parser.parse(hello_args, location="query"))
 
 
-@app.route("/echo_query")
-def echo_query():
-    return J(parser.parse(hello_args, request, locations=("query",)))
+@app.route("/echo_form", methods=["POST"])
+def echo_form():
+    return J(parser.parse(hello_args, location="form"))
 
 
 @app.route("/echo_json", methods=["POST"])
 def echo_json():
-    return J(parser.parse(hello_args, request, locations=("json",)))
+    return J(parser.parse(hello_args, location="json"))
 
 
-@app.route("/echo_form", methods=["POST"])
-def echo_form():
-    return J(parser.parse(hello_args, request, locations=("form",)))
+@app.route("/echo_json_or_form", methods=["POST"])
+def echo_json_or_form():
+    return J(parser.parse(hello_args, location="json_or_form"))
 
 
-@app.route("/echo_use_args", methods=["GET", "POST"])
-@use_args(hello_args)
+@app.route("/echo_use_args", methods=["GET"])
+@use_args(hello_args, location="query")
 def echo_use_args(args):
     return J(args)
 
 
-@app.route("/echo_use_args_validated", methods=["GET", "POST"])
-@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42)
+@app.route("/echo_use_args_validated", methods=["POST"])
+@use_args(
+    {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form"
+)
 def echo_use_args_validated(args):
     return J(args)
 
 
-@app.route("/echo_use_kwargs", methods=["GET", "POST"])
-@use_kwargs(hello_args)
+@app.route("/echo_ignoring_extra_data", methods=["POST"])
+def echo_json_ignore_extra_data():
+    return J(parser.parse(hello_args, unknown=ma.EXCLUDE))
+
+
+@app.route("/echo_use_kwargs", methods=["GET"])
+@use_kwargs(hello_args, location="query")
 def echo_use_kwargs(name):
     return J({"name": name})
 
 
-@app.route("/echo_multi", methods=["GET", "POST"])
+@app.route("/echo_multi", methods=["GET"])
 def multi():
+    return J(parser.parse(hello_multiple, location="query"))
+
+
+@app.route("/echo_multi_form", methods=["POST"])
+def multi_form():
+    return J(parser.parse(hello_multiple, location="form"))
+
+
+@app.route("/echo_multi_json", methods=["POST"])
+def multi_json():
     return J(parser.parse(hello_multiple))
 
 
 @app.route("/echo_many_schema", methods=["GET", "POST"])
 def many_nested():
-    arguments = parser.parse(hello_many_schema, locations=("json",))
+    arguments = parser.parse(hello_many_schema)
     return Response(json.dumps(arguments), content_type="application/json")
 
 
 @app.route("/echo_use_args_with_path_param/<name>")
-@use_args({"value": fields.Int()})
+@use_args({"value": fields.Int()}, location="query")
 def echo_use_args_with_path(args, name):
     return J(args)
 
 
 @app.route("/echo_use_kwargs_with_path_param/<name>")
-@use_kwargs({"value": fields.Int()})
+@use_kwargs({"value": fields.Int()}, location="query")
 def echo_use_kwargs_with_path(name, value):
     return J({"value": value})
 
@@ -99,18 +114,26 @@ def error():
 
 @app.route("/echo_headers")
 def echo_headers():
-    return J(parser.parse(hello_args, locations=("headers",)))
+    return J(parser.parse(hello_args, location="headers"))
+
+
+# as above, but in this case, turn off the default `EXCLUDE` behavior for
+# `headers`, so that errors will be raised
+@app.route("/echo_headers_raising")
+@use_args(HelloSchema(), location="headers", unknown=None)
+def echo_headers_raising(args):
+    return J(args)
 
 
 @app.route("/echo_cookie")
 def echo_cookie():
-    return J(parser.parse(hello_args, request, locations=("cookies",)))
+    return J(parser.parse(hello_args, request, location="cookies"))
 
 
 @app.route("/echo_file", methods=["POST"])
 def echo_file():
     args = {"myfile": fields.Field()}
-    result = parser.parse(args, locations=("files",))
+    result = parser.parse(args, location="files")
     fp = result["myfile"]
     content = fp.read().decode("utf8")
     return J({"myfile": content})
@@ -118,11 +141,11 @@ def echo_file():
 
 @app.route("/echo_view_arg/<view_arg>")
 def echo_view_arg(view_arg):
-    return J(parser.parse({"view_arg": fields.Int()}, locations=("view_args",)))
+    return J(parser.parse({"view_arg": fields.Int()}, location="view_args"))
 
 
 @app.route("/echo_view_arg_use_args/<view_arg>")
-@use_args({"view_arg": fields.Int(location="view_args")})
+@use_args({"view_arg": fields.Int()}, location="view_args")
 def echo_view_arg_with_use_args(args, **kwargs):
     return J(args)
 
@@ -143,10 +166,9 @@ def echo_nested_many():
 
 @app.route("/echo_nested_many_data_key", methods=["POST"])
 def echo_nested_many_with_data_key():
-    data_key_kwarg = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "X-Field"
+    args = {
+        "x_field": fields.Nested({"id": fields.Int()}, many=True, data_key="X-Field")
     }
-    args = {"x_field": fields.Nested({"id": fields.Int()}, many=True, **data_key_kwarg)}
     return J(parser.parse(args))
 
 
@@ -187,4 +209,5 @@ def echo_use_kwargs_missing(username, **kwargs):
 def handle_error(err):
     if err.code == 422:
         assert isinstance(err.data["schema"], ma.Schema)
+
     return J(err.data["messages"]), err.code
diff --git a/tests/apps/pyramid_app.py b/tests/apps/pyramid_app.py
index f4fa0e5..e22d26a 100644
--- a/tests/apps/pyramid_app.py
+++ b/tests/apps/pyramid_app.py
@@ -1,12 +1,10 @@
-from webargs.core import json
-
 from pyramid.config import Configurator
 from pyramid.httpexceptions import HTTPBadRequest
 import marshmallow as ma
 
 from webargs import fields
 from webargs.pyramidparser import parser, use_args, use_kwargs
-from webargs.core import MARSHMALLOW_VERSION_INFO
+from webargs.core import json
 
 hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
 hello_multiple = {"name": fields.List(fields.Str())}
@@ -16,13 +14,23 @@ class HelloSchema(ma.Schema):
     name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
 
 
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
-hello_many_schema = HelloSchema(many=True, **strict_kwargs)
+hello_many_schema = HelloSchema(many=True)
+
+# variant which ignores unknown fields
+hello_exclude_schema = HelloSchema(unknown=ma.EXCLUDE)
 
 
 def echo(request):
+    return parser.parse(hello_args, request, location="query")
+
+
+def echo_form(request):
+    return parser.parse(hello_args, request, location="form")
+
+
+def echo_json(request):
     try:
-        return parser.parse(hello_args, request)
+        return parser.parse(hello_args, request, location="json")
     except json.JSONDecodeError:
         error = HTTPBadRequest()
         error.body = json.dumps(["Invalid JSON."]).encode("utf-8")
@@ -30,47 +38,69 @@ def echo(request):
         raise error
 
 
-def echo_query(request):
-    return parser.parse(hello_args, request, locations=("query",))
+def echo_json_or_form(request):
+    try:
+        return parser.parse(hello_args, request, location="json_or_form")
+    except json.JSONDecodeError:
+        error = HTTPBadRequest()
+        error.body = json.dumps(["Invalid JSON."]).encode("utf-8")
+        error.content_type = "application/json"
+        raise error
 
 
-def echo_json(request):
-    return parser.parse(hello_args, request, locations=("json",))
+def echo_json_ignore_extra_data(request):
+    try:
+        return parser.parse(hello_exclude_schema, request, unknown=None)
+    except json.JSONDecodeError:
+        error = HTTPBadRequest()
+        error.body = json.dumps(["Invalid JSON."]).encode("utf-8")
+        error.content_type = "application/json"
+        raise error
 
 
-def echo_form(request):
-    return parser.parse(hello_args, request, locations=("form",))
+def echo_query(request):
+    return parser.parse(hello_args, request, location="query")
 
 
-@use_args(hello_args)
+@use_args(hello_args, location="query")
 def echo_use_args(request, args):
     return args
 
 
-@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42)
+@use_args(
+    {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form"
+)
 def echo_use_args_validated(request, args):
     return args
 
 
-@use_kwargs(hello_args)
+@use_kwargs(hello_args, location="query")
 def echo_use_kwargs(request, name):
     return {"name": name}
 
 
 def echo_multi(request):
+    return parser.parse(hello_multiple, request, location="query")
+
+
+def echo_multi_form(request):
+    return parser.parse(hello_multiple, request, location="form")
+
+
+def echo_multi_json(request):
     return parser.parse(hello_multiple, request)
 
 
 def echo_many_schema(request):
-    return parser.parse(hello_many_schema, request, locations=("json",))
+    return parser.parse(hello_many_schema, request)
 
 
-@use_args({"value": fields.Int()})
+@use_args({"value": fields.Int()}, location="query")
 def echo_use_args_with_path_param(request, args):
     return args
 
 
-@use_kwargs({"value": fields.Int()})
+@use_kwargs({"value": fields.Int()}, location="query")
 def echo_use_kwargs_with_path_param(request, value):
     return {"value": value}
 
@@ -84,16 +114,16 @@ def always_error(request):
 
 
 def echo_headers(request):
-    return parser.parse(hello_args, request, locations=("headers",))
+    return parser.parse(hello_args, request, location="headers")
 
 
 def echo_cookie(request):
-    return parser.parse(hello_args, request, locations=("cookies",))
+    return parser.parse(hello_args, request, location="cookies")
 
 
 def echo_file(request):
     args = {"myfile": fields.Field()}
-    result = parser.parse(args, request, locations=("files",))
+    result = parser.parse(args, request, location="files")
     myfile = result["myfile"]
     content = myfile.file.read().decode("utf8")
     return {"myfile": content}
@@ -112,14 +142,14 @@ def echo_nested_many(request):
 
 
 def echo_matchdict(request):
-    return parser.parse({"mymatch": fields.Int()}, request, locations=("matchdict",))
+    return parser.parse({"mymatch": fields.Int()}, request, location="matchdict")
 
 
-class EchoCallable(object):
+class EchoCallable:
     def __init__(self, request):
         self.request = request
 
-    @use_args({"value": fields.Int()})
+    @use_args({"value": fields.Int()}, location="query")
     def __call__(self, args):
         return args
 
@@ -135,13 +165,17 @@ def create_app():
     config = Configurator()
 
     add_route(config, "/echo", echo)
-    add_route(config, "/echo_query", echo_query)
-    add_route(config, "/echo_json", echo_json)
     add_route(config, "/echo_form", echo_form)
+    add_route(config, "/echo_json", echo_json)
+    add_route(config, "/echo_json_or_form", echo_json_or_form)
+    add_route(config, "/echo_query", echo_query)
+    add_route(config, "/echo_ignoring_extra_data", echo_json_ignore_extra_data)
     add_route(config, "/echo_use_args", echo_use_args)
     add_route(config, "/echo_use_args_validated", echo_use_args_validated)
     add_route(config, "/echo_use_kwargs", echo_use_kwargs)
     add_route(config, "/echo_multi", echo_multi)
+    add_route(config, "/echo_multi_form", echo_multi_form)
+    add_route(config, "/echo_multi_json", echo_multi_json)
     add_route(config, "/echo_many_schema", echo_many_schema)
     add_route(
         config, "/echo_use_args_with_path_param/{name}", echo_use_args_with_path_param
diff --git a/tests/compat.py b/tests/compat.py
deleted file mode 100644
index c0c7545..0000000
--- a/tests/compat.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# -*- coding: utf-8 -*-
-# flake8: noqa
-import sys
-
-PY2 = int(sys.version[0]) == 2
-
-if PY2:
-    text_type = unicode
-    binary_type = str
-    string_types = (str, unicode)
-    basestring = basestring
-else:
-    text_type = str
-    binary_type = bytes
-    string_types = (str,)
-    basestring = (str, bytes)
diff --git a/tests/test_py3/test_aiohttpparser.py b/tests/test_aiohttpparser.py
similarity index 62%
rename from tests/test_py3/test_aiohttpparser.py
rename to tests/test_aiohttpparser.py
index d3de2fb..1054ad0 100644
--- a/tests/test_py3/test_aiohttpparser.py
+++ b/tests/test_aiohttpparser.py
@@ -1,27 +1,34 @@
-# -*- coding: utf-8 -*-
+from io import BytesIO
+from unittest import mock
 
-import asyncio
 import webtest
 import webtest_aiohttp
 import pytest
 
-from io import BytesIO
-from webargs.core import MARSHMALLOW_VERSION_INFO
+from webargs import fields
+from webargs.aiohttpparser import AIOHTTPParser
 from webargs.testing import CommonTestCase
 from tests.apps.aiohttp_app import create_app
 
 
+@pytest.fixture
+def web_request():
+    req = mock.Mock()
+    req.query = {}
+    yield req
+    req.query = {}
+
+
 class TestAIOHTTPParser(CommonTestCase):
     def create_app(self):
         return create_app()
 
-    def create_testapp(self, app):
-        loop = asyncio.get_event_loop()
-        self.loop = loop
+    def create_testapp(self, app, loop):
         return webtest_aiohttp.TestApp(app, loop=loop)
 
-    def after_create_app(self):
-        self.loop.close()
+    @pytest.fixture
+    def testapp(self, loop):
+        return self.create_testapp(self.create_app(), loop)
 
     @pytest.mark.skip(reason="files location not supported for aiohttpparser")
     def test_parse_files(self, testapp):
@@ -38,18 +45,11 @@ class TestAIOHTTPParser(CommonTestCase):
 
     # regression test for https://github.com/marshmallow-code/webargs/issues/165
     def test_multiple_args(self, testapp):
-        res = testapp.post_json(
-            "/echo_multiple_args", {"first": "1", "last": "2", "_ignore": 0}
-        )
+        res = testapp.post_json("/echo_multiple_args", {"first": "1", "last": "2"})
         assert res.json == {"first": "1", "last": "2"}
 
     # regression test for https://github.com/marshmallow-code/webargs/issues/145
     def test_nested_many_with_data_key(self, testapp):
-        res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]})
-        # https://github.com/marshmallow-code/marshmallow/pull/714
-        if MARSHMALLOW_VERSION_INFO[0] < 3:
-            assert res.json == {"x_field": [{"id": 42}]}
-
         res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]})
         assert res.json == {"x_field": [{"id": 24}]}
 
@@ -77,3 +77,40 @@ class TestAIOHTTPParser(CommonTestCase):
             "query_parsed": {"page": 2, "q": 10},
             "json_parsed": {"name": "Steve"},
         }
+
+
+async def test_aiohttpparser_synchronous_error_handler(web_request):
+    parser = AIOHTTPParser()
+
+    class CustomError(Exception):
+        pass
+
+    @parser.error_handler
+    def custom_handle_error(error, req, schema, *, error_status_code, error_headers):
+        raise CustomError("foo")
+
+    with pytest.raises(CustomError):
+        await parser.parse(
+            {"foo": fields.Int(required=True)}, web_request, location="query"
+        )
+
+
+async def test_aiohttpparser_asynchronous_error_handler(web_request):
+    parser = AIOHTTPParser()
+
+    class CustomError(Exception):
+        pass
+
+    @parser.error_handler
+    async def custom_handle_error(
+        error, req, schema, *, error_status_code, error_headers
+    ):
+        async def inner():
+            raise CustomError("foo")
+
+        await inner()
+
+    with pytest.raises(CustomError):
+        await parser.parse(
+            {"foo": fields.Int(required=True)}, web_request, location="query"
+        )
diff --git a/tests/test_core.py b/tests/test_core.py
index be8038b..829a3fe 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,46 +1,47 @@
-# -*- coding: utf-8 -*-
-import itertools
-import mock
 import datetime
+from unittest import mock
 
 import pytest
-from marshmallow import Schema, post_load, pre_load, class_registry, validates_schema
+from marshmallow import (
+    Schema,
+    post_load,
+    pre_load,
+    validates_schema,
+    EXCLUDE,
+    INCLUDE,
+    RAISE,
+)
 from werkzeug.datastructures import MultiDict as WerkMultiDict
 from django.utils.datastructures import MultiValueDict as DjMultiDict
 from bottle import MultiDict as BotMultiDict
 
-from webargs import fields, missing, ValidationError
+from webargs import fields, ValidationError
 from webargs.core import (
     Parser,
-    get_value,
-    dict2schema,
     is_json,
     get_mimetype,
-    MARSHMALLOW_VERSION_INFO,
 )
-
-
-strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {}
+from webargs.multidictproxy import MultiDictProxy
 
 
 class MockHTTPError(Exception):
     def __init__(self, status_code, headers):
         self.status_code = status_code
         self.headers = headers
-        super(MockHTTPError, self).__init__(self, "HTTP Error occurred")
+        super().__init__(self, "HTTP Error occurred")
 
 
 class MockRequestParser(Parser):
     """A minimal parser implementation that parses mock requests."""
 
-    def parse_querystring(self, req, name, field):
-        return get_value(req.query, name, field)
+    def load_querystring(self, req, schema):
+        return MultiDictProxy(req.query, schema)
 
-    def parse_json(self, req, name, field):
-        return get_value(req.json, name, field)
+    def load_json(self, req, schema):
+        return req.json
 
-    def parse_cookies(self, req, name, field):
-        return get_value(req.cookies, name, field)
+    def load_cookies(self, req, schema):
+        return req.cookies
 
 
 @pytest.yield_fixture(scope="function")
@@ -59,65 +60,158 @@ def parser():
 # Parser tests
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_parse_json_called_by_parse_arg(parse_json, web_request):
-    field = fields.Field()
+@mock.patch("webargs.core.Parser.load_json")
+def test_load_json_called_by_parse_default(load_json, web_request):
+    schema = Schema.from_dict({"foo": fields.Field()})()
+    load_json.return_value = {"foo": 1}
     p = Parser()
-    p.parse_arg("foo", field, web_request)
-    parse_json.assert_called_with(web_request, "foo", field)
+    p.parse(schema, web_request)
+    load_json.assert_called_with(web_request, schema)
 
 
-@mock.patch("webargs.core.Parser.parse_querystring")
-def test_parse_querystring_called_by_parse_arg(parse_querystring, web_request):
-    field = fields.Field()
-    p = Parser()
-    p.parse_arg("foo", field, web_request)
-    assert parse_querystring.called_once()
+@pytest.mark.parametrize(
+    "location", ["querystring", "form", "headers", "cookies", "files"]
+)
+def test_load_nondefault_called_by_parse_with_location(location, web_request):
+    with mock.patch(
+        f"webargs.core.Parser.load_{location}"
+    ) as mock_loadfunc, mock.patch("webargs.core.Parser.load_json") as load_json:
+        mock_loadfunc.return_value = {}
+        load_json.return_value = {}
+        p = Parser()
+
+        # ensure that without location=..., the loader is not called (json is
+        # called)
+        p.parse({"foo": fields.Field()}, web_request)
+        assert mock_loadfunc.call_count == 0
+        assert load_json.call_count == 1
 
+        # but when location=... is given, the loader *is* called and json is
+        # not called
+        p.parse({"foo": fields.Field()}, web_request, location=location)
+        assert mock_loadfunc.call_count == 1
+        # it was already 1, should not go up
+        assert load_json.call_count == 1
 
-@mock.patch("webargs.core.Parser.parse_form")
-def test_parse_form_called_by_parse_arg(parse_form, web_request):
-    field = fields.Field()
-    p = Parser()
-    p.parse_arg("foo", field, web_request)
-    assert parse_form.called_once()
 
+def test_parse(parser, web_request):
+    web_request.json = {"username": 42, "password": 42}
+    argmap = {"username": fields.Field(), "password": fields.Field()}
+    ret = parser.parse(argmap, web_request)
+    assert {"username": 42, "password": 42} == ret
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_parse_json_not_called_when_json_not_a_location(parse_json, web_request):
-    field = fields.Field()
-    p = Parser()
-    p.parse_arg("foo", field, web_request, locations=("form", "querystring"))
-    assert parse_json.call_count == 0
 
+@pytest.mark.parametrize(
+    "set_location",
+    [
+        "schema_instance",
+        "parse_call",
+        "parser_default",
+        "parser_class_default",
+    ],
+)
+def test_parse_with_unknown_behavior_specified(parser, web_request, set_location):
+    web_request.json = {"username": 42, "password": 42, "fjords": 42}
 
-@mock.patch("webargs.core.Parser.parse_headers")
-def test_parse_headers_called_when_headers_is_a_location(parse_headers, web_request):
-    field = fields.Field()
-    p = Parser()
-    p.parse_arg("foo", field, web_request)
-    assert parse_headers.call_count == 0
-    p.parse_arg("foo", field, web_request, locations=("headers",))
-    parse_headers.assert_called()
+    class CustomSchema(Schema):
+        username = fields.Field()
+        password = fields.Field()
+
+    def parse_with_desired_behavior(value):
+        if set_location == "schema_instance":
+            if value is not None:
+                # pass 'unknown=None' to parse() in order to indicate that the
+                # schema setting should be respected
+                return parser.parse(
+                    CustomSchema(unknown=value), web_request, unknown=None
+                )
+            else:
+                return parser.parse(CustomSchema(), web_request)
+        elif set_location == "parse_call":
+            return parser.parse(CustomSchema(), web_request, unknown=value)
+        elif set_location == "parser_default":
+            parser.unknown = value
+            return parser.parse(CustomSchema(), web_request)
+        elif set_location == "parser_class_default":
+
+            class CustomParser(MockRequestParser):
+                DEFAULT_UNKNOWN_BY_LOCATION = {"json": value}
+
+            return CustomParser().parse(CustomSchema(), web_request)
+        else:
+            raise NotImplementedError
+
+    # with no unknown setting or unknown=RAISE, it blows up
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parse_with_desired_behavior(None)
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parse_with_desired_behavior(RAISE)
+
+    # with unknown=EXCLUDE the data is omitted
+    ret = parse_with_desired_behavior(EXCLUDE)
+    assert {"username": 42, "password": 42} == ret
+    # with unknown=INCLUDE it is added even though it isn't part of the schema
+    ret = parse_with_desired_behavior(INCLUDE)
+    assert {"username": 42, "password": 42, "fjords": 42} == ret
 
 
-@mock.patch("webargs.core.Parser.parse_cookies")
-def test_parse_cookies_called_when_cookies_is_a_location(parse_cookies, web_request):
-    field = fields.Field()
-    p = Parser()
-    p.parse_arg("foo", field, web_request)
-    assert parse_cookies.call_count == 0
-    p.parse_arg("foo", field, web_request, locations=("cookies",))
-    parse_cookies.assert_called()
+def test_parse_with_explicit_unknown_overrides_schema(parser, web_request):
+    web_request.json = {"username": 42, "password": 42, "fjords": 42}
 
+    class CustomSchema(Schema):
+        username = fields.Field()
+        password = fields.Field()
+
+    # setting RAISE in the parse call overrides schema setting
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parser.parse(CustomSchema(unknown=EXCLUDE), web_request, unknown=RAISE)
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parser.parse(CustomSchema(unknown=INCLUDE), web_request, unknown=RAISE)
+
+    # and the reverse -- setting EXCLUDE or INCLUDE in the parse call overrides
+    # a schema with RAISE already set
+    ret = parser.parse(CustomSchema(unknown=RAISE), web_request, unknown=EXCLUDE)
+    assert {"username": 42, "password": 42} == ret
+    ret = parser.parse(CustomSchema(unknown=RAISE), web_request, unknown=INCLUDE)
+    assert {"username": 42, "password": 42, "fjords": 42} == ret
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_parse(parse_json, web_request):
-    parse_json.return_value = 42
-    argmap = {"username": fields.Field(), "password": fields.Field()}
-    p = Parser()
-    ret = p.parse(argmap, web_request)
+
+@pytest.mark.parametrize("clear_method", ["custom_class", "instance_setting", "both"])
+def test_parse_with_default_unknown_cleared_uses_schema_value(
+    parser, web_request, clear_method
+):
+    web_request.json = {"username": 42, "password": 42, "fjords": 42}
+
+    class CustomSchema(Schema):
+        username = fields.Field()
+        password = fields.Field()
+
+    if clear_method == "custom_class":
+
+        class CustomParser(MockRequestParser):
+            DEFAULT_UNKNOWN_BY_LOCATION = {}
+
+        parser = CustomParser()
+    elif clear_method == "instance_setting":
+        parser = MockRequestParser(unknown=None)
+    elif clear_method == "both":
+        # setting things in multiple ways should not result in errors
+        class CustomParser(MockRequestParser):
+            DEFAULT_UNKNOWN_BY_LOCATION = {}
+
+        parser = CustomParser(unknown=None)
+    else:
+        raise NotImplementedError
+
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parser.parse(CustomSchema(), web_request)
+    with pytest.raises(ValidationError, match="Unknown field."):
+        parser.parse(CustomSchema(unknown=RAISE), web_request)
+
+    ret = parser.parse(CustomSchema(unknown=EXCLUDE), web_request)
     assert {"username": 42, "password": 42} == ret
+    ret = parser.parse(CustomSchema(unknown=INCLUDE), web_request)
+    assert {"username": 42, "password": 42, "fjords": 42} == ret
 
 
 def test_parse_required_arg_raises_validation_error(parser, web_request):
@@ -141,13 +235,10 @@ def test_arg_allow_none(parser, web_request):
     assert result == {"first": "Steve", "last": None}
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_parse_required_arg(parse_json, web_request):
-    arg = fields.Field(required=True)
-    parse_json.return_value = 42
-    p = Parser()
-    result = p.parse_arg("foo", arg, web_request, locations=("json",))
-    assert result == 42
+def test_parse_required_arg(parser, web_request):
+    web_request.json = {"foo": 42}
+    result = parser.parse({"foo": fields.Field(required=True)}, web_request)
+    assert result == {"foo": 42}
 
 
 def test_parse_required_list(parser, web_request):
@@ -155,7 +246,9 @@ def test_parse_required_list(parser, web_request):
     args = {"foo": fields.List(fields.Field(), required=True)}
     with pytest.raises(ValidationError) as excinfo:
         parser.parse(args, web_request)
-    assert excinfo.value.messages["foo"][0] == "Missing data for required field."
+    assert (
+        excinfo.value.messages["json"]["foo"][0] == "Missing data for required field."
+    )
 
 
 # Regression test for https://github.com/marshmallow-code/webargs/issues/107
@@ -170,7 +263,7 @@ def test_parse_list_dont_allow_none(parser, web_request):
     args = {"foo": fields.List(fields.Field(), allow_none=False)}
     with pytest.raises(ValidationError) as excinfo:
         parser.parse(args, web_request)
-    assert excinfo.value.messages["foo"][0] == "Field may not be null."
+    assert excinfo.value.messages["json"]["foo"][0] == "Field may not be null."
 
 
 def test_parse_empty_list(parser, web_request):
@@ -185,21 +278,21 @@ def test_parse_missing_list(parser, web_request):
     assert parser.parse(args, web_request) == {}
 
 
-def test_default_locations():
-    assert set(Parser.DEFAULT_LOCATIONS) == set(["json", "querystring", "form"])
+def test_default_location():
+    assert Parser.DEFAULT_LOCATION == "json"
 
 
 def test_missing_with_default(parser, web_request):
     web_request.json = {}
     args = {"val": fields.Field(missing="pizza")}
-    result = parser.parse(args, web_request, locations=("json",))
+    result = parser.parse(args, web_request)
     assert result["val"] == "pizza"
 
 
 def test_default_can_be_none(parser, web_request):
     web_request.json = {}
     args = {"val": fields.Field(missing=None, allow_none=True)}
-    result = parser.parse(args, web_request, locations=("json",))
+    result = parser.parse(args, web_request)
     assert result["val"] is None
 
 
@@ -210,141 +303,151 @@ def test_arg_with_default_and_location(parser, web_request):
         "p": fields.Int(
             missing=1,
             validate=lambda p: p > 0,
-            error=u"La page demandée n'existe pas",
+            error="La page demandée n'existe pas",
             location="querystring",
         )
     }
     assert parser.parse(args, web_request) == {"p": 1}
 
 
-def test_value_error_raised_if_parse_arg_called_with_invalid_location(web_request):
+def test_value_error_raised_if_parse_called_with_invalid_location(parser, web_request):
     field = fields.Field()
-    p = Parser()
-    with pytest.raises(ValueError) as excinfo:
-        p.parse_arg("foo", field, web_request, locations=("invalidlocation", "headers"))
-    msg = "Invalid locations arguments: {0}".format(["invalidlocation"])
-    assert msg in str(excinfo.value)
+    with pytest.raises(ValueError, match="Invalid location argument: invalidlocation"):
+        parser.parse({"foo": field}, web_request, location="invalidlocation")
 
 
-def test_value_error_raised_if_invalid_location_on_field(web_request, parser):
-    with pytest.raises(ValueError) as excinfo:
-        parser.parse({"foo": fields.Field(location="invalidlocation")}, web_request)
-    msg = "Invalid locations arguments: {0}".format(["invalidlocation"])
-    assert msg in str(excinfo.value)
+@mock.patch("webargs.core.Parser.handle_error")
+def test_handle_error_called_when_parsing_raises_error(handle_error, web_request):
+    # handle_error must raise an error to be valid
+    handle_error.side_effect = ValidationError("parsing failed")
 
+    def always_fail(*args, **kwargs):
+        raise ValidationError("error occurred")
 
-@mock.patch("webargs.core.Parser.handle_error")
-@mock.patch("webargs.core.Parser.parse_json")
-def test_handle_error_called_when_parsing_raises_error(
-    parse_json, handle_error, web_request
-):
-    val_err = ValidationError("error occurred")
-    parse_json.side_effect = val_err
     p = Parser()
-    p.parse({"foo": fields.Field()}, web_request, locations=("json",))
-    handle_error.assert_called()
-    parse_json.side_effect = ValidationError("another exception")
-    p.parse({"foo": fields.Field()}, web_request, locations=("json",))
+    assert handle_error.call_count == 0
+    with pytest.raises(ValidationError):
+        p.parse({"foo": fields.Field()}, web_request, validate=always_fail)
+    assert handle_error.call_count == 1
+    with pytest.raises(ValidationError):
+        p.parse({"foo": fields.Field()}, web_request, validate=always_fail)
     assert handle_error.call_count == 2
 
 
 def test_handle_error_reraises_errors(web_request):
     p = Parser()
     with pytest.raises(ValidationError):
-        p.handle_error(ValidationError("error raised"), web_request, Schema())
+        p.handle_error(
+            ValidationError("error raised"),
+            web_request,
+            Schema(),
+            error_status_code=422,
+            error_headers={},
+        )
 
 
-@mock.patch("webargs.core.Parser.parse_headers")
-def test_locations_as_init_arguments(parse_headers, web_request):
-    p = Parser(locations=("headers",))
+@mock.patch("webargs.core.Parser.load_headers")
+def test_location_as_init_argument(load_headers, web_request):
+    p = Parser(location="headers")
+    load_headers.return_value = {}
     p.parse({"foo": fields.Field()}, web_request)
-    assert parse_headers.called
-
-
-@mock.patch("webargs.core.Parser.parse_files")
-def test_parse_files(parse_files, web_request):
-    p = Parser()
-    p.parse({"foo": fields.Field()}, web_request, locations=("files",))
-    assert parse_files.called
+    assert load_headers.called
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_custom_error_handler(parse_json, web_request):
+def test_custom_error_handler(web_request):
     class CustomError(Exception):
         pass
 
-    def error_handler(error, req, schema, status_code, headers):
+    def error_handler(error, req, schema, *, error_status_code, error_headers):
         assert isinstance(schema, Schema)
         raise CustomError(error)
 
-    parse_json.side_effect = ValidationError("parse_json failed")
+    def failing_validate_func(args):
+        raise ValidationError("parsing failed")
+
+    class MySchema(Schema):
+        foo = fields.Int()
+
+    myschema = MySchema()
+    web_request.json = {"foo": "hello world"}
+
     p = Parser(error_handler=error_handler)
     with pytest.raises(CustomError):
-        p.parse({"foo": fields.Field()}, web_request)
+        p.parse(myschema, web_request, validate=failing_validate_func)
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_custom_error_handler_decorator(parse_json, web_request):
+def test_custom_error_handler_decorator(web_request):
     class CustomError(Exception):
         pass
 
-    parse_json.side_effect = ValidationError("parse_json failed")
-
+    mock_schema = mock.Mock(spec=Schema)
+    mock_schema.strict = True
+    mock_schema.load.side_effect = ValidationError("parsing json failed")
     parser = Parser()
 
     @parser.error_handler
-    def handle_error(error, req, schema, status_code, headers):
+    def handle_error(error, req, schema, *, error_status_code, error_headers):
         assert isinstance(schema, Schema)
         raise CustomError(error)
 
     with pytest.raises(CustomError):
-        parser.parse({"foo": fields.Field()}, web_request)
+        parser.parse(mock_schema, web_request)
+
+
+def test_custom_error_handler_must_reraise(web_request):
+    class CustomError(Exception):
+        pass
+
+    mock_schema = mock.Mock(spec=Schema)
+    mock_schema.strict = True
+    mock_schema.load.side_effect = ValidationError("parsing json failed")
+    parser = Parser()
+
+    @parser.error_handler
+    def handle_error(error, req, schema, *, error_status_code, error_headers):
+        pass
 
+    # because the handler above does not raise a new error, the parser should
+    # raise a ValueError -- indicating a programming error
+    with pytest.raises(ValueError):
+        parser.parse(mock_schema, web_request)
 
-def test_custom_location_handler(web_request):
+
+def test_custom_location_loader(web_request):
     web_request.data = {"foo": 42}
 
     parser = Parser()
 
-    @parser.location_handler("data")
-    def parse_data(req, name, arg):
-        return req.data.get(name, missing)
+    @parser.location_loader("data")
+    def load_data(req, schema):
+        return req.data
 
-    result = parser.parse({"foo": fields.Int()}, web_request, locations=("data",))
+    result = parser.parse({"foo": fields.Int()}, web_request, location="data")
     assert result["foo"] == 42
 
 
-def test_custom_location_handler_with_data_key(web_request):
+def test_custom_location_loader_with_data_key(web_request):
     web_request.data = {"X-Foo": 42}
     parser = Parser()
 
-    @parser.location_handler("data")
-    def parse_data(req, name, arg):
-        return req.data.get(name, missing)
+    @parser.location_loader("data")
+    def load_data(req, schema):
+        return req.data
 
-    data_key_kwarg = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "X-Foo"
-    }
     result = parser.parse(
-        {"x_foo": fields.Int(**data_key_kwarg)}, web_request, locations=("data",)
+        {"x_foo": fields.Int(data_key="X-Foo")}, web_request, location="data"
     )
     assert result["x_foo"] == 42
 
 
-def test_full_input_validation(web_request):
+def test_full_input_validation(parser, web_request):
 
     web_request.json = {"foo": 41, "bar": 42}
 
-    parser = MockRequestParser()
     args = {"foo": fields.Int(), "bar": fields.Int()}
     with pytest.raises(ValidationError):
         # Test that `validate` receives dictionary of args
-        parser.parse(
-            args,
-            web_request,
-            locations=("json",),
-            validate=lambda args: args["foo"] > args["bar"],
-        )
+        parser.parse(args, web_request, validate=lambda args: args["foo"] > args["bar"])
 
 
 def test_full_input_validation_with_multiple_validators(web_request, parser):
@@ -360,31 +463,27 @@ def test_full_input_validation_with_multiple_validators(web_request, parser):
     web_request.json = {"a": 2, "b": 1}
     validators = [validate1, validate2]
     with pytest.raises(ValidationError, match="b must be > a"):
-        parser.parse(args, web_request, locations=("json",), validate=validators)
+        parser.parse(args, web_request, validate=validators)
 
     web_request.json = {"a": 1, "b": 2}
     with pytest.raises(ValidationError, match="a must be > b"):
-        parser.parse(args, web_request, locations=("json",), validate=validators)
+        parser.parse(args, web_request, validate=validators)
 
 
-def test_required_with_custom_error(web_request):
+def test_required_with_custom_error(parser, web_request):
     web_request.json = {}
-    parser = MockRequestParser()
     args = {
         "foo": fields.Str(required=True, error_messages={"required": "We need foo"})
     }
     with pytest.raises(ValidationError) as excinfo:
         # Test that `validate` receives dictionary of args
-        parser.parse(args, web_request, locations=("json",))
+        parser.parse(args, web_request)
 
-    assert "We need foo" in excinfo.value.messages["foo"]
-    if MARSHMALLOW_VERSION_INFO[0] < 3:
-        assert "foo" in excinfo.value.field_names
+    assert "We need foo" in excinfo.value.messages["json"]["foo"]
 
 
-def test_required_with_custom_error_and_validation_error(web_request):
+def test_required_with_custom_error_and_validation_error(parser, web_request):
     web_request.json = {"foo": ""}
-    parser = MockRequestParser()
     args = {
         "foo": fields.Str(
             required="We need foo",
@@ -394,24 +493,22 @@ def test_required_with_custom_error_and_validation_error(web_request):
     }
     with pytest.raises(ValidationError) as excinfo:
         # Test that `validate` receives dictionary of args
-        parser.parse(args, web_request, locations=("json",))
+        parser.parse(args, web_request)
 
     assert "foo required length is 3" in excinfo.value.args[0]["foo"]
-    if MARSHMALLOW_VERSION_INFO[0] < 3:
-        assert "foo" in excinfo.value.field_names
 
 
 def test_full_input_validator_receives_nonascii_input(web_request):
     def validate(val):
         return False
 
-    text = u"øœ∑∆∑"
+    text = "øœ∑∆∑"
     web_request.json = {"text": text}
     parser = MockRequestParser()
     args = {"text": fields.Str()}
     with pytest.raises(ValidationError) as excinfo:
-        parser.parse(args, web_request, locations=("json",), validate=validate)
-    assert excinfo.value.messages == ["Invalid value."]
+        parser.parse(args, web_request, validate=validate)
+    assert excinfo.value.messages == {"json": ["Invalid value."]}
 
 
 def test_invalid_argument_for_validate(web_request, parser):
@@ -420,14 +517,6 @@ def test_invalid_argument_for_validate(web_request, parser):
     assert "not a callable or list of callables." in excinfo.value.args[0]
 
 
-def test_get_value_basic():
-    assert get_value({"foo": 42}, "foo", False) == 42
-    assert get_value({"foo": 42}, "bar", False) is missing
-    assert get_value({"foos": ["a", "b"]}, "foos", True) == ["a", "b"]
-    # https://github.com/marshmallow-code/webargs/pull/30
-    assert get_value({"foos": ["a", "b"]}, "bar", True) is missing
-
-
 def create_bottle_multi_dict():
     d = BotMultiDict()
     d["foos"] = "a"
@@ -443,34 +532,32 @@ multidicts = [
 
 
 @pytest.mark.parametrize("input_dict", multidicts)
-def test_get_value_multidict(input_dict):
-    field = fields.List(fields.Str())
-    assert get_value(input_dict, "foos", field) == ["a", "b"]
+def test_multidict_proxy(input_dict):
+    class ListSchema(Schema):
+        foos = fields.List(fields.Str())
 
+    class StrSchema(Schema):
+        foos = fields.Str()
 
-def test_parse_with_data_key(web_request):
-    web_request.json = {"Content-Type": "application/json"}
+    # this MultiDictProxy is aware that "foos" is a list field and will
+    # therefore produce a list with __getitem__
+    list_wrapped_multidict = MultiDictProxy(input_dict, ListSchema())
 
-    parser = MockRequestParser()
-    data_key_kwargs = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "Content-Type"
-    }
-    args = {"content_type": fields.Field(**data_key_kwargs)}
-    parsed = parser.parse(args, web_request, locations=("json",))
-    assert parsed == {"content_type": "application/json"}
+    # this MultiDictProxy is under the impression that "foos" is just a string
+    # and it should return "a" or "b"
+    # the decision between "a" and "b" in this case belongs to the framework
+    str_wrapped_multidict = MultiDictProxy(input_dict, StrSchema())
 
+    assert list_wrapped_multidict["foos"] == ["a", "b"]
+    assert str_wrapped_multidict["foos"] in ("a", "b")
 
-# https://github.com/marshmallow-code/webargs/issues/118
-@pytest.mark.skipif(
-    MARSHMALLOW_VERSION_INFO[0] >= 3, reason="Behaviour changed in marshmallow 3"
-)
-# https://github.com/marshmallow-code/marshmallow/pull/714
-def test_load_from_is_checked_after_given_key(web_request):
-    web_request.json = {"content_type": "application/json"}
+
+def test_parse_with_data_key(web_request):
+    web_request.json = {"Content-Type": "application/json"}
 
     parser = MockRequestParser()
-    args = {"content_type": fields.Field(load_from="Content-Type")}
-    parsed = parser.parse(args, web_request, locations=("json",))
+    args = {"content_type": fields.Field(data_key="Content-Type")}
+    parsed = parser.parse(args, web_request)
     assert parsed == {"content_type": "application/json"}
 
 
@@ -478,25 +565,20 @@ def test_parse_with_data_key_retains_field_name_in_error(web_request):
     web_request.json = {"Content-Type": 12345}
 
     parser = MockRequestParser()
-    data_key_kwargs = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "Content-Type"
-    }
-    args = {"content_type": fields.Str(**data_key_kwargs)}
+    args = {"content_type": fields.Str(data_key="Content-Type")}
     with pytest.raises(ValidationError) as excinfo:
-        parser.parse(args, web_request, locations=("json",))
-    assert "Content-Type" in excinfo.value.messages
-    assert excinfo.value.messages["Content-Type"] == ["Not a valid string."]
+        parser.parse(args, web_request)
+    assert "json" in excinfo.value.messages
+    assert "Content-Type" in excinfo.value.messages["json"]
+    assert excinfo.value.messages["json"]["Content-Type"] == ["Not a valid string."]
 
 
 def test_parse_nested_with_data_key(web_request):
     parser = MockRequestParser()
     web_request.json = {"nested_arg": {"wrong": "OK"}}
-    data_key_kwarg = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "wrong"
-    }
-    args = {"nested_arg": fields.Nested({"right": fields.Field(**data_key_kwarg)})}
+    args = {"nested_arg": fields.Nested({"right": fields.Field(data_key="wrong")})}
 
-    parsed = parser.parse(args, web_request, locations=("json",))
+    parsed = parser.parse(args, web_request)
     assert parsed == {"nested_arg": {"right": "OK"}}
 
 
@@ -504,16 +586,13 @@ def test_parse_nested_with_missing_key_and_data_key(web_request):
     parser = MockRequestParser()
 
     web_request.json = {"nested_arg": {}}
-    data_key_kwargs = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "miss"
-    }
     args = {
         "nested_arg": fields.Nested(
-            {"found": fields.Field(missing=None, allow_none=True, **data_key_kwargs)}
+            {"found": fields.Field(missing=None, allow_none=True, data_key="miss")}
         )
     }
 
-    parsed = parser.parse(args, web_request, locations=("json",))
+    parsed = parser.parse(args, web_request)
     assert parsed == {"nested_arg": {"found": None}}
 
 
@@ -523,7 +602,7 @@ def test_parse_nested_with_default(web_request):
     web_request.json = {"nested_arg": {}}
     args = {"nested_arg": fields.Nested({"miss": fields.Field(missing="<foo>")})}
 
-    parsed = parser.parse(args, web_request, locations=("json",))
+    parsed = parser.parse(args, web_request)
     assert parsed == {"nested_arg": {"miss": "<foo>"}}
 
 
@@ -554,8 +633,8 @@ def test_use_args_stacked(web_request, parser):
     web_request.json = {"username": "foo"}
     web_request.query = {"page": 42}
 
-    @parser.use_args(query_args, web_request, locations=("query",))
-    @parser.use_args(json_args, web_request, locations=("json",))
+    @parser.use_args(query_args, web_request, location="query")
+    @parser.use_args(json_args, web_request)
     def viewfunc(query_parsed, json_parsed):
         return {"json": json_parsed, "query": query_parsed}
 
@@ -570,8 +649,8 @@ def test_use_kwargs_stacked(web_request, parser):
     web_request.json = {"username": "foo"}
     web_request.query = {"page": 42}
 
-    @parser.use_kwargs(query_args, web_request, locations=("query",))
-    @parser.use_kwargs(json_args, web_request, locations=("json",))
+    @parser.use_kwargs(query_args, web_request, location="query")
+    @parser.use_kwargs(json_args, web_request)
     def viewfunc(page, username):
         return {"json": {"username": username}, "query": {"page": page}}
 
@@ -592,24 +671,26 @@ def test_decorators_dont_change_docstring(parser, decorator_name):
 
 def test_list_allowed_missing(web_request, parser):
     args = {"name": fields.List(fields.Str())}
-    web_request.json = {"fakedata": True}
+    web_request.json = {}
     result = parser.parse(args, web_request)
     assert result == {}
 
 
 def test_int_list_allowed_missing(web_request, parser):
     args = {"name": fields.List(fields.Int())}
-    web_request.json = {"fakedata": True}
+    web_request.json = {}
     result = parser.parse(args, web_request)
     assert result == {}
 
 
 def test_multiple_arg_required_with_int_conversion(web_request, parser):
     args = {"ids": fields.List(fields.Int(), required=True)}
-    web_request.json = {"fakedata": True}
+    web_request.json = {}
     with pytest.raises(ValidationError) as excinfo:
         parser.parse(args, web_request)
-    assert excinfo.value.messages == {"ids": ["Missing data for required field."]}
+    assert excinfo.value.messages == {
+        "json": {"ids": ["Missing data for required field."]}
+    }
 
 
 def test_parse_with_callable(web_request, parser):
@@ -619,11 +700,6 @@ def test_parse_with_callable(web_request, parser):
     class MySchema(Schema):
         foo = fields.Field()
 
-        if MARSHMALLOW_VERSION_INFO[0] < 3:
-
-            class Meta:
-                strict = True
-
     def make_schema(req):
         assert req is web_request
         return MySchema(context={"request": req})
@@ -637,11 +713,6 @@ def test_use_args_callable(web_request, parser):
     class HelloSchema(Schema):
         name = fields.Str()
 
-        if MARSHMALLOW_VERSION_INFO[0] < 3:
-
-            class Meta:
-                strict = True
-
         @post_load
         def request_data(self, item, **kwargs):
             item["data"] = self.context["request"].data
@@ -666,15 +737,11 @@ class TestPassingSchema:
         id = fields.Int(dump_only=True)
         email = fields.Email()
         password = fields.Str(load_only=True)
-        if MARSHMALLOW_VERSION_INFO[0] < 3:
-
-            class Meta:
-                strict = True
 
     def test_passing_schema_to_parse(self, parser, web_request):
         web_request.json = {"email": "foo@bar.com", "password": "bar"}
 
-        result = parser.parse(self.UserSchema(**strict_kwargs), web_request)
+        result = parser.parse(self.UserSchema(), web_request)
 
         assert result == {"email": "foo@bar.com", "password": "bar"}
 
@@ -682,7 +749,7 @@ class TestPassingSchema:
 
         web_request.json = {"email": "foo@bar.com", "password": "bar"}
 
-        @parser.use_args(self.UserSchema(**strict_kwargs), web_request)
+        @parser.use_args(self.UserSchema(), web_request)
         def viewfunc(args):
             return args
 
@@ -693,7 +760,7 @@ class TestPassingSchema:
 
         def factory(req):
             assert req is web_request
-            return self.UserSchema(context={"request": req}, **strict_kwargs)
+            return self.UserSchema(context={"request": req})
 
         result = parser.parse(factory, web_request)
 
@@ -704,7 +771,7 @@ class TestPassingSchema:
 
         def factory(req):
             assert req is web_request
-            return self.UserSchema(context={"request": req}, **strict_kwargs)
+            return self.UserSchema(context={"request": req})
 
         @parser.use_args(factory, web_request)
         def viewfunc(args):
@@ -716,7 +783,7 @@ class TestPassingSchema:
 
         web_request.json = {"email": "foo@bar.com", "password": "bar"}
 
-        @parser.use_kwargs(self.UserSchema(**strict_kwargs), web_request)
+        @parser.use_kwargs(self.UserSchema(), web_request)
         def viewfunc(email, password):
             return {"email": email, "password": password}
 
@@ -727,7 +794,7 @@ class TestPassingSchema:
 
         def factory(req):
             assert req is web_request
-            return self.UserSchema(context={"request": req}, **strict_kwargs)
+            return self.UserSchema(context={"request": req})
 
         @parser.use_kwargs(factory, web_request)
         def viewfunc(email, password):
@@ -735,22 +802,11 @@ class TestPassingSchema:
 
         assert viewfunc() == {"email": "foo@bar.com", "password": "bar"}
 
-    @pytest.mark.skipif(
-        MARSHMALLOW_VERSION_INFO[0] >= 3,
-        reason='"strict" parameter is removed in marshmallow 3',
-    )
-    def test_warning_raised_if_schema_is_not_in_strict_mode(self, web_request, parser):
-
-        with pytest.warns(UserWarning) as record:
-            parser.parse(self.UserSchema(strict=False), web_request)
-        warning = record[0]
-        assert "strict=True" in str(warning.message)
-
     def test_use_kwargs_stacked(self, web_request, parser):
         web_request.json = {"email": "foo@bar.com", "password": "bar", "page": 42}
 
-        @parser.use_kwargs({"page": fields.Int()}, web_request)
-        @parser.use_kwargs(self.UserSchema(**strict_kwargs), web_request)
+        @parser.use_kwargs({"page": fields.Int()}, web_request, unknown=EXCLUDE)
+        @parser.use_kwargs(self.UserSchema(), web_request, unknown=EXCLUDE)
         def viewfunc(email, password, page):
             return {"email": email, "password": password, "page": page}
 
@@ -763,10 +819,6 @@ class TestPassingSchema:
         class UserSchema(Schema):
             name = fields.Str()
             location = fields.Field(required=False)
-            if MARSHMALLOW_VERSION_INFO[0] < 3:
-
-                class Meta:
-                    strict = True
 
             @validates_schema(pass_original=True)
             def validate_schema(self, data, original_data, **kwargs):
@@ -774,18 +826,18 @@ class TestPassingSchema:
                 return True
 
         web_request.json = {"name": "Eric Cartman"}
-        res = parser.parse(UserSchema, web_request, locations=("json",))
+        res = parser.parse(UserSchema, web_request)
         assert res == {"name": "Eric Cartman"}
 
 
-def test_use_args_with_custom_locations_in_parser(web_request, parser):
+def test_use_args_with_custom_location_in_parser(web_request, parser):
     custom_args = {"foo": fields.Str()}
     web_request.json = {}
-    parser.locations = ("custom",)
+    parser.location = "custom"
 
-    @parser.location_handler("custom")
-    def parse_custom(req, name, arg):
-        return "bar"
+    @parser.location_loader("custom")
+    def load_custom(schema, req):
+        return {"foo": "bar"}
 
     @parser.use_args(custom_args, web_request)
     def viewfunc(args):
@@ -819,40 +871,60 @@ def test_use_kwargs_with_arg_missing(web_request, parser):
 
 def test_delimited_list_default_delimiter(web_request, parser):
     web_request.json = {"ids": "1,2,3"}
-    schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int())})
+    schema_cls = Schema.from_dict({"ids": fields.DelimitedList(fields.Int())})
     schema = schema_cls()
 
     parsed = parser.parse(schema, web_request)
     assert parsed["ids"] == [1, 2, 3]
 
-    dumped = schema.dump(parsed)
-    data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped
-    assert data["ids"] == [1, 2, 3]
+    data = schema.dump(parsed)
+    assert data["ids"] == "1,2,3"
 
 
-def test_delimited_list_as_string(web_request, parser):
-    web_request.json = {"ids": "1,2,3"}
-    schema_cls = dict2schema(
-        {"ids": fields.DelimitedList(fields.Int(), as_string=True)}
+def test_delimited_tuple_default_delimiter(web_request, parser):
+    """
+    Test load and dump from DelimitedTuple, including the use of a datetime
+    type (similar to a DelimitedList test below) which confirms that we aren't
+    relying on __str__, but are properly de/serializing the included fields
+    """
+    web_request.json = {"ids": "1,2,2020-05-04"}
+    schema_cls = Schema.from_dict(
+        {
+            "ids": fields.DelimitedTuple(
+                (fields.Int, fields.Int, fields.DateTime(format="%Y-%m-%d"))
+            )
+        }
     )
     schema = schema_cls()
 
     parsed = parser.parse(schema, web_request)
-    assert parsed["ids"] == [1, 2, 3]
+    assert parsed["ids"] == (1, 2, datetime.datetime(2020, 5, 4))
 
-    dumped = schema.dump(parsed)
-    data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped
-    assert data["ids"] == "1,2,3"
+    data = schema.dump(parsed)
+    assert data["ids"] == "1,2,2020-05-04"
+
+
+def test_delimited_tuple_incorrect_arity(web_request, parser):
+    web_request.json = {"ids": "1,2"}
+    schema_cls = Schema.from_dict(
+        {"ids": fields.DelimitedTuple((fields.Int, fields.Int, fields.Int))}
+    )
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError):
+        parser.parse(schema, web_request)
 
 
-def test_delimited_list_as_string_v2(web_request, parser):
+def test_delimited_list_with_datetime(web_request, parser):
+    """
+    Test that DelimitedList(DateTime(format=...)) correctly parses and dumps
+    dates to and from strings -- indicates that we're doing proper
+    serialization of values in dump() and not just relying on __str__ producing
+    correct results
+    """
     web_request.json = {"dates": "2018-11-01,2018-11-02"}
-    schema_cls = dict2schema(
-        {
-            "dates": fields.DelimitedList(
-                fields.DateTime(format="%Y-%m-%d"), as_string=True
-            )
-        }
+    schema_cls = Schema.from_dict(
+        {"dates": fields.DelimitedList(fields.DateTime(format="%Y-%m-%d"))}
     )
     schema = schema_cls()
 
@@ -862,38 +934,85 @@ def test_delimited_list_as_string_v2(web_request, parser):
         datetime.datetime(2018, 11, 2),
     ]
 
-    dumped = schema.dump(parsed)
-    data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped
+    data = schema.dump(parsed)
     assert data["dates"] == "2018-11-01,2018-11-02"
 
 
 def test_delimited_list_custom_delimiter(web_request, parser):
     web_request.json = {"ids": "1|2|3"}
-    schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int(), delimiter="|")})
+    schema_cls = Schema.from_dict(
+        {"ids": fields.DelimitedList(fields.Int(), delimiter="|")}
+    )
     schema = schema_cls()
 
     parsed = parser.parse(schema, web_request)
     assert parsed["ids"] == [1, 2, 3]
 
+    data = schema.dump(parsed)
+    assert data["ids"] == "1|2|3"
 
-def test_delimited_list_load_list(web_request, parser):
-    web_request.json = {"ids": [1, 2, 3]}
-    schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int())})
+
+def test_delimited_tuple_custom_delimiter(web_request, parser):
+    web_request.json = {"ids": "1|2"}
+    schema_cls = Schema.from_dict(
+        {"ids": fields.DelimitedTuple((fields.Int, fields.Int), delimiter="|")}
+    )
     schema = schema_cls()
 
     parsed = parser.parse(schema, web_request)
-    assert parsed["ids"] == [1, 2, 3]
+    assert parsed["ids"] == (1, 2)
+
+    data = schema.dump(parsed)
+    assert data["ids"] == "1|2"
+
+
+def test_delimited_list_load_list_errors(web_request, parser):
+    web_request.json = {"ids": [1, 2, 3]}
+    schema_cls = Schema.from_dict({"ids": fields.DelimitedList(fields.Int())})
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError) as excinfo:
+        parser.parse(schema, web_request)
+    exc = excinfo.value
+    assert isinstance(exc, ValidationError)
+    errors = exc.args[0]
+    assert errors["ids"] == ["Not a valid delimited list."]
+
+
+def test_delimited_tuple_load_list_errors(web_request, parser):
+    web_request.json = {"ids": [1, 2]}
+    schema_cls = Schema.from_dict(
+        {"ids": fields.DelimitedTuple((fields.Int, fields.Int))}
+    )
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError) as excinfo:
+        parser.parse(schema, web_request)
+    exc = excinfo.value
+    assert isinstance(exc, ValidationError)
+    errors = exc.args[0]
+    assert errors["ids"] == ["Not a valid delimited tuple."]
 
 
 # Regresion test for https://github.com/marshmallow-code/webargs/issues/149
 def test_delimited_list_passed_invalid_type(web_request, parser):
     web_request.json = {"ids": 1}
-    schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int())})
+    schema_cls = Schema.from_dict({"ids": fields.DelimitedList(fields.Int())})
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError) as excinfo:
+        parser.parse(schema, web_request)
+    assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited list."]}}
+
+
+def test_delimited_tuple_passed_invalid_type(web_request, parser):
+    web_request.json = {"ids": 1}
+    schema_cls = Schema.from_dict({"ids": fields.DelimitedTuple((fields.Int,))})
     schema = schema_cls()
 
     with pytest.raises(ValidationError) as excinfo:
         parser.parse(schema, web_request)
-    assert excinfo.value.messages == {"ids": ["Not a valid list."]}
+    assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited tuple."]}}
 
 
 def test_missing_list_argument_not_in_parsed_result(web_request, parser):
@@ -913,16 +1032,6 @@ def test_type_conversion_with_multiple_required(web_request, parser):
         parser.parse(args, web_request)
 
 
-def test_arg_location_param(web_request, parser):
-    web_request.json = {"foo": 24}
-    web_request.cookies = {"foo": 42}
-    args = {"foo": fields.Field(location="cookies")}
-
-    parsed = parser.parse(args, web_request)
-
-    assert parsed["foo"] == 42
-
-
 def test_validation_errors_in_validator_are_passed_to_handle_error(parser, web_request):
     def validate(value):
         raise ValidationError("Something went wrong.")
@@ -951,54 +1060,10 @@ def test_parse_raises_validation_error_if_data_invalid(web_request, parser):
         parser.parse(args, web_request)
 
 
-def test_dict2schema():
-    data_key_kwargs = {
-        "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "content-type"
-    }
-    argmap = {
-        "id": fields.Int(required=True),
-        "title": fields.Str(),
-        "description": fields.Str(),
-        "content_type": fields.Str(**data_key_kwargs),
-    }
-
-    schema_cls = dict2schema(argmap)
-    assert issubclass(schema_cls, Schema)
-
-    schema = schema_cls()
-
-    for each in ["id", "title", "description", "content_type"]:
-        assert each in schema.fields
-    assert schema.fields["id"].required
-    if MARSHMALLOW_VERSION_INFO[0] < 3:
-        assert schema.opts.strict is True
-
-
-# Regression test for https://github.com/marshmallow-code/webargs/issues/101
-def test_dict2schema_doesnt_add_to_class_registry():
-    old_n_entries = len(
-        list(
-            itertools.chain(
-                [classes for _, classes in class_registry._registry.items()]
-            )
-        )
-    )
-    argmap = {"id": fields.Field()}
-    dict2schema(argmap)
-    dict2schema(argmap)
-    new_n_entries = len(
-        list(
-            itertools.chain(
-                [classes for _, classes in class_registry._registry.items()]
-            )
-        )
-    )
-    assert new_n_entries == old_n_entries
-
-
-def test_dict2schema_with_nesting():
+def test_nested_field_from_dict():
+    # webargs.fields.Nested implements dict handling
     argmap = {"nest": fields.Nested({"foo": fields.Field()})}
-    schema_cls = dict2schema(argmap)
+    schema_cls = Schema.from_dict(argmap)
     assert issubclass(schema_cls, Schema)
     schema = schema_cls()
     assert "nest" in schema.fields
@@ -1016,13 +1081,10 @@ def test_is_json():
 def test_get_mimetype():
     assert get_mimetype("application/json") == "application/json"
     assert get_mimetype("application/json;charset=utf8") == "application/json"
-    assert get_mimetype(None) is None
 
 
 class MockRequestParserWithErrorHandler(MockRequestParser):
-    def handle_error(
-        self, error, req, schema, error_status_code=None, error_headers=None
-    ):
+    def handle_error(self, error, req, schema, *, error_status_code, error_headers):
         assert isinstance(error, ValidationError)
         assert isinstance(schema, Schema)
         raise MockHTTPError(error_status_code, error_headers)
@@ -1041,23 +1103,23 @@ def test_parse_with_error_status_code_and_headers(web_request):
     assert error.headers == {"X-Foo": "bar"}
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_custom_schema_class(parse_json, web_request):
+@mock.patch("webargs.core.Parser.load_json")
+def test_custom_schema_class(load_json, web_request):
     class CustomSchema(Schema):
         @pre_load
         def pre_load(self, data, **kwargs):
             data["value"] += " world"
             return data
 
-    parse_json.return_value = "hello"
+    load_json.return_value = {"value": "hello"}
     argmap = {"value": fields.Str()}
     p = Parser(schema_class=CustomSchema)
     ret = p.parse(argmap, web_request)
     assert ret == {"value": "hello world"}
 
 
-@mock.patch("webargs.core.Parser.parse_json")
-def test_custom_default_schema_class(parse_json, web_request):
+@mock.patch("webargs.core.Parser.load_json")
+def test_custom_default_schema_class(load_json, web_request):
     class CustomSchema(Schema):
         @pre_load
         def pre_load(self, data, **kwargs):
@@ -1067,7 +1129,7 @@ def test_custom_default_schema_class(parse_json, web_request):
     class CustomParser(Parser):
         DEFAULT_SCHEMA_CLASS = CustomSchema
 
-    parse_json.return_value = "hello"
+    load_json.return_value = {"value": "hello"}
     argmap = {"value": fields.Str()}
     p = CustomParser()
     ret = p.parse(argmap, web_request)
diff --git a/tests/test_djangoparser.py b/tests/test_djangoparser.py
index 5b8497a..2288cc8 100644
--- a/tests/test_djangoparser.py
+++ b/tests/test_djangoparser.py
@@ -1,6 +1,3 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
 import pytest
 from tests.apps.django_app.base.wsgi import application
 
@@ -17,18 +14,14 @@ class TestDjangoParser(CommonTestCase):
     def test_use_args_with_validation(self):
         pass
 
-    @pytest.mark.skip(reason="headers location not supported by DjangoParser")
-    def test_parsing_headers(self, testapp):
-        pass
-
     def test_parsing_in_class_based_view(self, testapp):
         assert testapp.get("/echo_cbv?name=Fred").json == {"name": "Fred"}
-        assert testapp.post("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"}
+        assert testapp.post_json("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"}
 
     def test_use_args_in_class_based_view(self, testapp):
         res = testapp.get("/echo_use_args_cbv?name=Fred")
         assert res.json == {"name": "Fred"}
-        res = testapp.post("/echo_use_args_cbv", {"name": "Fred"})
+        res = testapp.post_json("/echo_use_args_cbv", {"name": "Fred"})
         assert res.json == {"name": "Fred"}
 
     def test_use_args_in_class_based_view_with_path_param(self, testapp):
diff --git a/tests/test_falconparser.py b/tests/test_falconparser.py
index d6092c7..4f65313 100644
--- a/tests/test_falconparser.py
+++ b/tests/test_falconparser.py
@@ -1,5 +1,5 @@
-# -*- coding: utf-8 -*-
 import pytest
+import falcon.testing
 
 from webargs.testing import CommonTestCase
 from tests.apps.falcon_app import create_app
@@ -16,25 +16,59 @@ class TestFalconParser(CommonTestCase):
     def test_use_args_hook(self, testapp):
         assert testapp.get("/echo_use_args_hook?name=Fred").json == {"name": "Fred"}
 
+    def test_parse_media(self, testapp):
+        assert testapp.post_json("/echo_media", {"name": "Fred"}).json == {
+            "name": "Fred"
+        }
+
+    def test_parse_media_missing(self, testapp):
+        assert testapp.post("/echo_media", "").json == {"name": "World"}
+
+    def test_parse_media_empty(self, testapp):
+        assert testapp.post_json("/echo_media", {}).json == {"name": "World"}
+
+    def test_parse_media_error_unexpected_int(self, testapp):
+        res = testapp.post_json("/echo_media", 1, expect_errors=True)
+        assert res.status_code == 422
+
     # https://github.com/marshmallow-code/webargs/issues/427
-    def test_parse_json_with_nonutf8_chars(self, testapp):
+    @pytest.mark.parametrize("path", ["/echo_json", "/echo_media"])
+    def test_parse_json_with_nonutf8_chars(self, testapp, path):
         res = testapp.post(
-            "/echo",
+            path,
             b"\xfe",
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
         )
 
         assert res.status_code == 400
-        assert res.json["errors"] == {"json": ["Invalid JSON body."]}
+        if path.endswith("json"):
+            assert res.json["errors"] == {"json": ["Invalid JSON body."]}
 
     # https://github.com/sloria/webargs/issues/329
-    def test_invalid_json(self, testapp):
+    @pytest.mark.parametrize("path", ["/echo_json", "/echo_media"])
+    def test_invalid_json(self, testapp, path):
         res = testapp.post(
-            "/echo",
+            path,
             '{"foo": "bar", }',
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
         )
         assert res.status_code == 400
-        assert res.json["errors"] == {"json": ["Invalid JSON body."]}
+        if path.endswith("json"):
+            assert res.json["errors"] == {"json": ["Invalid JSON body."]}
+
+    # Falcon converts headers to all-caps
+    def test_parsing_headers(self, testapp):
+        res = testapp.get("/echo_headers", headers={"name": "Fred"})
+        assert res.json == {"NAME": "Fred"}
+
+    # `falcon.testing.TestClient.simulate_request` parses request with `wsgiref`
+    def test_body_parsing_works_with_simulate(self):
+        app = self.create_app()
+        client = falcon.testing.TestClient(app)
+        res = client.simulate_post(
+            "/echo_json",
+            json={"name": "Fred"},
+        )
+        assert res.json == {"name": "Fred"}
diff --git a/tests/test_flaskparser.py b/tests/test_flaskparser.py
index 5122196..8ea42fe 100644
--- a/tests/test_flaskparser.py
+++ b/tests/test_flaskparser.py
@@ -1,15 +1,13 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-import threading
+from unittest import mock
 
-from werkzeug.exceptions import HTTPException
-import mock
+from werkzeug.exceptions import HTTPException, BadRequest
 import pytest
 
+from marshmallow import Schema
 from flask import Flask
 from webargs import fields, ValidationError, missing
 from webargs.flaskparser import parser, abort
-from webargs.core import MARSHMALLOW_VERSION_INFO, json
+from webargs.core import json
 
 from .apps.flask_app import app
 from webargs.testing import CommonTestCase
@@ -26,30 +24,32 @@ class TestFlaskParser(CommonTestCase):
     def test_parsing_invalid_view_arg(self, testapp):
         res = testapp.get("/echo_view_arg/foo", expect_errors=True)
         assert res.status_code == 422
-        assert res.json == {"view_arg": ["Not a valid integer."]}
+        assert res.json == {"view_args": {"view_arg": ["Not a valid integer."]}}
 
     def test_use_args_with_view_args_parsing(self, testapp):
         res = testapp.get("/echo_view_arg_use_args/42")
         assert res.json == {"view_arg": 42}
 
     def test_use_args_on_a_method_view(self, testapp):
-        res = testapp.post("/echo_method_view_use_args", {"val": 42})
+        res = testapp.post_json("/echo_method_view_use_args", {"val": 42})
         assert res.json == {"val": 42}
 
     def test_use_kwargs_on_a_method_view(self, testapp):
-        res = testapp.post("/echo_method_view_use_kwargs", {"val": 42})
+        res = testapp.post_json("/echo_method_view_use_kwargs", {"val": 42})
         assert res.json == {"val": 42}
 
     def test_use_kwargs_with_missing_data(self, testapp):
-        res = testapp.post("/echo_use_kwargs_missing", {"username": "foo"})
+        res = testapp.post_json("/echo_use_kwargs_missing", {"username": "foo"})
         assert res.json == {"username": "foo"}
 
     # regression test for https://github.com/marshmallow-code/webargs/issues/145
     def test_nested_many_with_data_key(self, testapp):
-        res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]})
-        # https://github.com/marshmallow-code/marshmallow/pull/714
-        if MARSHMALLOW_VERSION_INFO[0] < 3:
-            assert res.json == {"x_field": [{"id": 42}]}
+        post_with_raw_fieldname_args = (
+            "/echo_nested_many_data_key",
+            {"x_field": [{"id": 42}]},
+        )
+        res = testapp.post_json(*post_with_raw_fieldname_args, expect_errors=True)
+        assert res.status_code == 422
 
         res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]})
         assert res.json == {"x_field": [{"id": 24}]}
@@ -57,9 +57,22 @@ class TestFlaskParser(CommonTestCase):
         res = testapp.post_json("/echo_nested_many_data_key", {})
         assert res.json == {}
 
+    # regression test for
+    # https://github.com/marshmallow-code/webargs/issues/500
+    def test_parsing_unexpected_headers_when_raising(self, testapp):
+        res = testapp.get(
+            "/echo_headers_raising", expect_errors=True, headers={"X-Unexpected": "foo"}
+        )
+        assert res.status_code == 422
+        assert "headers" in res.json
+        assert "X-Unexpected" in set(res.json["headers"].keys())
+
 
 @mock.patch("webargs.flaskparser.abort")
 def test_abort_called_on_validation_error(mock_abort):
+    # error handling must raise an error to be valid
+    mock_abort.side_effect = BadRequest("foo")
+
     app = Flask("testapp")
 
     def validate(x):
@@ -72,19 +85,23 @@ def test_abort_called_on_validation_error(mock_abort):
         data=json.dumps({"value": 41}),
         content_type="application/json",
     ):
-        parser.parse(argmap)
+        with pytest.raises(HTTPException):
+            parser.parse(argmap)
     mock_abort.assert_called()
     abort_args, abort_kwargs = mock_abort.call_args
     assert abort_args[0] == 422
     expected_msg = "Invalid value."
-    assert abort_kwargs["messages"]["value"] == [expected_msg]
+    assert abort_kwargs["messages"]["json"]["value"] == [expected_msg]
     assert type(abort_kwargs["exc"]) == ValidationError
 
 
-def test_parse_form_returns_missing_if_no_form():
+@pytest.mark.parametrize("mimetype", [None, "application/json"])
+def test_load_json_returns_missing_if_no_data(mimetype):
     req = mock.Mock()
-    req.form.get.side_effect = AttributeError("no form")
-    assert parser.parse_form(req, "foo", fields.Field()) is missing
+    req.mimetype = mimetype
+    req.get_data.return_value = ""
+    schema = Schema.from_dict({"foo": fields.Field()})()
+    assert parser.load_json(req, schema) is missing
 
 
 def test_abort_with_message():
@@ -111,37 +128,3 @@ def test_abort_has_serializable_data():
     error = json.loads(serialized_error)
     assert isinstance(error, dict)
     assert error["message"] == "custom error message"
-
-
-def test_json_cache_race_condition():
-    app = Flask("testapp")
-    lock = threading.Lock()
-    lock.acquire()
-
-    class MyField(fields.Field):
-        def _deserialize(self, value, attr, data, **kwargs):
-            with lock:
-                return value
-
-    argmap = {"value": MyField()}
-    results = {}
-
-    def thread_fn(value):
-        with app.test_request_context(
-            "/foo",
-            method="post",
-            data=json.dumps({"value": value}),
-            content_type="application/json",
-        ):
-            results[value] = parser.parse(argmap)["value"]
-
-    t1 = threading.Thread(target=thread_fn, args=(42,))
-    t2 = threading.Thread(target=thread_fn, args=(23,))
-    t1.start()
-    t2.start()
-    lock.release()
-    t1.join()
-    t2.join()
-    # ensure we didn't get contaminated by a parallel request
-    assert results[42] == 42
-    assert results[23] == 23
diff --git a/tests/test_py3/test_aiohttpparser_async_functions.py b/tests/test_py3/test_aiohttpparser_async_functions.py
deleted file mode 100644
index a0437c4..0000000
--- a/tests/test_py3/test_aiohttpparser_async_functions.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import pytest
-import webtest_aiohttp
-from aiohttp.web import Application, json_response
-
-from webargs import fields
-from webargs.aiohttpparser import parser, use_args, use_kwargs
-
-##### Test app handlers #####
-
-hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)}
-
-
-async def echo_parse(request):
-    parsed = await parser.parse(hello_args, request)
-    return json_response(parsed)
-
-
-@use_args(hello_args)
-async def echo_use_args(request, args):
-    return json_response(args)
-
-
-@use_kwargs(hello_args)
-async def echo_use_kwargs(request, name):
-    return json_response({"name": name})
-
-
-##### Fixtures #####
-
-
-@pytest.fixture()
-def app():
-    app_ = Application()
-    app_.router.add_route("GET", "/echo", echo_parse)
-    app_.router.add_route("GET", "/echo_use_args", echo_use_args)
-    app_.router.add_route("GET", "/echo_use_kwargs", echo_use_kwargs)
-    return app_
-
-
-@pytest.fixture()
-def testapp(app, loop):
-    return webtest_aiohttp.TestApp(app, loop=loop)
-
-
-##### Tests #####
-
-
-def test_async_parse(testapp):
-    assert testapp.get("/echo?name=Steve").json == {"name": "Steve"}
-
-
-def test_async_use_args(testapp):
-    assert testapp.get("/echo_use_args?name=Steve").json == {"name": "Steve"}
-
-
-def test_async_use_kwargs(testapp):
-    assert testapp.get("/echo_use_kwargs?name=Steve").json == {"name": "Steve"}
diff --git a/tests/test_pyramidparser.py b/tests/test_pyramidparser.py
index 2f68c7a..fb8577c 100644
--- a/tests/test_pyramidparser.py
+++ b/tests/test_pyramidparser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 from webargs.testing import CommonTestCase
 
 
diff --git a/tests/test_tornadoparser.py b/tests/test_tornadoparser.py
index 8eb2990..796aa53 100644
--- a/tests/test_tornadoparser.py
+++ b/tests/test_tornadoparser.py
@@ -1,291 +1,205 @@
-# -*- coding: utf-8 -*-
-
-from webargs.core import json
-
-try:
-    from urllib.parse import urlencode
-except ImportError:  # PY2
-    from urllib import urlencode  # type: ignore
-
-import mock
-import pytest
+from unittest import mock
+from urllib.parse import urlencode
 
 import marshmallow as ma
-
-import tornado.web
-import tornado.httputil
-import tornado.httpserver
-import tornado.http1connection
+import pytest
 import tornado.concurrent
+import tornado.http1connection
+import tornado.httpserver
+import tornado.httputil
 import tornado.ioloop
+import tornado.web
 from tornado.testing import AsyncHTTPTestCase
-
 from webargs import fields, missing
-from webargs.tornadoparser import parser, use_args, use_kwargs, get_value
-from webargs.core import parse_json
+from webargs.core import json, parse_json
+from webargs.tornadoparser import (
+    WebArgsTornadoMultiDictProxy,
+    parser,
+    use_args,
+    use_kwargs,
+)
+
 
 name = "name"
 value = "value"
 
 
-def test_get_value_basic():
-    field, multifield = fields.Field(), fields.List(fields.Str())
-    assert get_value({"foo": 42}, "foo", field) == 42
-    assert get_value({"foo": 42}, "bar", field) is missing
-    assert get_value({"foos": ["a", "b"]}, "foos", multifield) == ["a", "b"]
-    # https://github.com/marshmallow-code/webargs/pull/30
-    assert get_value({"foos": ["a", "b"]}, "bar", multifield) is missing
+class AuthorSchema(ma.Schema):
+    name = fields.Str(missing="World", validate=lambda n: len(n) >= 3)
+    works = fields.List(fields.Str())
 
 
-class TestQueryArgs(object):
-    def setup_method(self, method):
-        parser.clear_cache()
+author_schema = AuthorSchema()
 
-    def test_it_should_get_single_values(self):
-        query = [(name, value)]
-        field = fields.Field()
-        request = make_get_request(query)
 
-        result = parser.parse_querystring(request, name, field)
+def test_tornado_multidictproxy():
+    for dictval, fieldname, expected in (
+        ({"name": "Sophocles"}, "name", "Sophocles"),
+        ({"name": "Sophocles"}, "works", missing),
+        ({"works": ["Antigone", "Oedipus Rex"]}, "works", ["Antigone", "Oedipus Rex"]),
+        ({"works": ["Antigone", "Oedipus at Colonus"]}, "name", missing),
+    ):
+        proxy = WebArgsTornadoMultiDictProxy(dictval, author_schema)
+        assert proxy.get(fieldname) == expected
 
-        assert result == value
 
-    def test_it_should_get_multiple_values(self):
-        query = [(name, value), (name, value)]
-        field = fields.List(fields.Field())
+class TestQueryArgs:
+    def test_it_should_get_single_values(self):
+        query = [("name", "Aeschylus")]
         request = make_get_request(query)
+        result = parser.load_querystring(request, author_schema)
+        assert result["name"] == "Aeschylus"
 
-        result = parser.parse_querystring(request, name, field)
-
-        assert result == [value, value]
-
-    def test_it_should_return_missing_if_not_present(self):
-        query = []
-        field = fields.Field()
-        field2 = fields.List(fields.Int())
+    def test_it_should_get_multiple_values(self):
+        query = [("works", "Agamemnon"), ("works", "Nereids")]
         request = make_get_request(query)
+        result = parser.load_querystring(request, author_schema)
+        assert result["works"] == ["Agamemnon", "Nereids"]
 
-        result = parser.parse_querystring(request, name, field)
-        result2 = parser.parse_querystring(request, name, field2)
-
-        assert result is missing
-        assert result2 is missing
-
-    def test_it_should_return_empty_list_if_multiple_and_not_present(self):
+    def test_it_should_return_missing_if_not_present(self):
         query = []
-        field = fields.List(fields.Field())
         request = make_get_request(query)
-
-        result = parser.parse_querystring(request, name, field)
-
-        assert result is missing
+        result = parser.load_querystring(request, author_schema)
+        assert result["name"] is missing
+        assert result["works"] is missing
 
 
 class TestFormArgs:
-    def setup_method(self, method):
-        parser.clear_cache()
-
     def test_it_should_get_single_values(self):
-        query = [(name, value)]
-        field = fields.Field()
+        query = [("name", "Aristophanes")]
         request = make_form_request(query)
-
-        result = parser.parse_form(request, name, field)
-
-        assert result == value
+        result = parser.load_form(request, author_schema)
+        assert result["name"] == "Aristophanes"
 
     def test_it_should_get_multiple_values(self):
-        query = [(name, value), (name, value)]
-        field = fields.List(fields.Field())
+        query = [("works", "The Wasps"), ("works", "The Frogs")]
         request = make_form_request(query)
-
-        result = parser.parse_form(request, name, field)
-
-        assert result == [value, value]
+        result = parser.load_form(request, author_schema)
+        assert result["works"] == ["The Wasps", "The Frogs"]
 
     def test_it_should_return_missing_if_not_present(self):
         query = []
-        field = fields.Field()
         request = make_form_request(query)
+        result = parser.load_form(request, author_schema)
+        assert result["name"] is missing
+        assert result["works"] is missing
 
-        result = parser.parse_form(request, name, field)
-
-        assert result is missing
-
-    def test_it_should_return_empty_list_if_multiple_and_not_present(self):
-        query = []
-        field = fields.List(fields.Field())
-        request = make_form_request(query)
-
-        result = parser.parse_form(request, name, field)
-
-        assert result is missing
-
-
-class TestJSONArgs(object):
-    def setup_method(self, method):
-        parser.clear_cache()
 
+class TestJSONArgs:
     def test_it_should_get_single_values(self):
-        query = {name: value}
-        field = fields.Field()
+        query = {"name": "Euripides"}
         request = make_json_request(query)
-        result = parser.parse_json(request, name, field)
-
-        assert result == value
+        result = parser.load_json(request, author_schema)
+        assert result["name"] == "Euripides"
 
     def test_parsing_request_with_vendor_content_type(self):
-        query = {name: value}
-        field = fields.Field()
+        query = {"name": "Euripides"}
         request = make_json_request(
             query, content_type="application/vnd.api+json; charset=UTF-8"
         )
-        result = parser.parse_json(request, name, field)
-
-        assert result == value
+        result = parser.load_json(request, author_schema)
+        assert result["name"] == "Euripides"
 
     def test_it_should_get_multiple_values(self):
-        query = {name: [value, value]}
-        field = fields.List(fields.Field())
+        query = {"works": ["Medea", "Electra"]}
         request = make_json_request(query)
-        result = parser.parse_json(request, name, field)
-
-        assert result == [value, value]
+        result = parser.load_json(request, author_schema)
+        assert result["works"] == ["Medea", "Electra"]
 
     def test_it_should_get_multiple_nested_values(self):
-        query = {name: [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]}
-        field = fields.List(
-            fields.Nested({"id": fields.Field(), "name": fields.Field()})
-        )
+        class CustomSchema(ma.Schema):
+            works = fields.List(
+                fields.Nested({"author": fields.Str(), "workname": fields.Str()})
+            )
+
+        custom_schema = CustomSchema()
+
+        query = {
+            "works": [
+                {"author": "Euripides", "workname": "Hecuba"},
+                {"author": "Aristophanes", "workname": "The Birds"},
+            ]
+        }
         request = make_json_request(query)
-        result = parser.parse_json(request, name, field)
-        assert result == [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]
+        result = parser.load_json(request, custom_schema)
+        assert result["works"] == [
+            {"author": "Euripides", "workname": "Hecuba"},
+            {"author": "Aristophanes", "workname": "The Birds"},
+        ]
 
-    def test_it_should_return_missing_if_not_present(self):
+    def test_it_should_not_include_fieldnames_if_not_present(self):
         query = {}
-        field = fields.Field()
         request = make_json_request(query)
-        result = parser.parse_json(request, name, field)
-
-        assert result is missing
-
-    def test_it_should_return_empty_list_if_multiple_and_not_present(self):
-        query = {}
-        field = fields.List(fields.Field())
-        request = make_json_request(query)
-        result = parser.parse_json(request, name, field)
-
-        assert result is missing
-
-    def test_it_should_handle_type_error_on_parse_json(self):
-        field = fields.Field()
+        result = parser.load_json(request, author_schema)
+        assert result == {}
+
+    def test_it_should_handle_type_error_on_load_json(self, loop):
+        # but this is different from the test above where the payload was valid
+        # and empty -- missing vs {}
+        # NOTE: `loop` is the pytest-aiohttp event loop fixture, but it's
+        # important to get an event loop here so that we can construct a future
         request = make_request(
-            body=tornado.concurrent.Future, headers={"Content-Type": "application/json"}
+            body=tornado.concurrent.Future(),
+            headers={"Content-Type": "application/json"},
         )
-        result = parser.parse_json(request, name, field)
-        assert parser._cache["json"] == {}
+        result = parser.load_json(request, author_schema)
         assert result is missing
 
     def test_it_should_handle_value_error_on_parse_json(self):
-        field = fields.Field()
         request = make_request("this is json not")
-        result = parser.parse_json(request, name, field)
-        assert parser._cache["json"] == {}
+        result = parser.load_json(request, author_schema)
         assert result is missing
 
 
-class TestHeadersArgs(object):
-    def setup_method(self, method):
-        parser.clear_cache()
-
+class TestHeadersArgs:
     def test_it_should_get_single_values(self):
-        query = {name: value}
-        field = fields.Field()
+        query = {"name": "Euphorion"}
         request = make_request(headers=query)
-
-        result = parser.parse_headers(request, name, field)
-
-        assert result == value
+        result = parser.load_headers(request, author_schema)
+        assert result["name"] == "Euphorion"
 
     def test_it_should_get_multiple_values(self):
-        query = {name: [value, value]}
-        field = fields.List(fields.Field())
+        query = {"works": ["Prometheus Bound", "Prometheus Unbound"]}
         request = make_request(headers=query)
-
-        result = parser.parse_headers(request, name, field)
-
-        assert result == [value, value]
+        result = parser.load_headers(request, author_schema)
+        assert result["works"] == ["Prometheus Bound", "Prometheus Unbound"]
 
     def test_it_should_return_missing_if_not_present(self):
-        field = fields.Field(multiple=False)
         request = make_request()
+        result = parser.load_headers(request, author_schema)
+        assert result["name"] is missing
+        assert result["works"] is missing
 
-        result = parser.parse_headers(request, name, field)
-
-        assert result is missing
-
-    def test_it_should_return_empty_list_if_multiple_and_not_present(self):
-        query = {}
-        field = fields.List(fields.Field())
-        request = make_request(headers=query)
-
-        result = parser.parse_headers(request, name, field)
-
-        assert result is missing
-
-
-class TestFilesArgs(object):
-    def setup_method(self, method):
-        parser.clear_cache()
 
+class TestFilesArgs:
     def test_it_should_get_single_values(self):
-        query = [(name, value)]
-        field = fields.Field()
+        query = [("name", "Sappho")]
         request = make_files_request(query)
-
-        result = parser.parse_files(request, name, field)
-
-        assert result == value
+        result = parser.load_files(request, author_schema)
+        assert result["name"] == "Sappho"
 
     def test_it_should_get_multiple_values(self):
-        query = [(name, value), (name, value)]
-        field = fields.List(fields.Field())
+        query = [("works", "Sappho 31"), ("works", "Ode to Aphrodite")]
         request = make_files_request(query)
-
-        result = parser.parse_files(request, name, field)
-
-        assert result == [value, value]
+        result = parser.load_files(request, author_schema)
+        assert result["works"] == ["Sappho 31", "Ode to Aphrodite"]
 
     def test_it_should_return_missing_if_not_present(self):
         query = []
-        field = fields.Field()
         request = make_files_request(query)
+        result = parser.load_files(request, author_schema)
+        assert result["name"] is missing
+        assert result["works"] is missing
 
-        result = parser.parse_files(request, name, field)
 
-        assert result is missing
-
-    def test_it_should_return_empty_list_if_multiple_and_not_present(self):
-        query = []
-        field = fields.List(fields.Field())
-        request = make_files_request(query)
-
-        result = parser.parse_files(request, name, field)
-
-        assert result is missing
-
-
-class TestErrorHandler(object):
+class TestErrorHandler:
     def test_it_should_raise_httperror_on_failed_validation(self):
         args = {"foo": fields.Field(validate=lambda x: False)}
         with pytest.raises(tornado.web.HTTPError):
             parser.parse(args, make_json_request({"foo": 42}))
 
 
-class TestParse(object):
-    def setup_method(self, method):
-        parser.clear_cache()
-
+class TestParse:
     def test_it_should_parse_query_arguments(self):
         attrs = {"string": fields.Field(), "integer": fields.List(fields.Int())}
 
@@ -293,7 +207,7 @@ class TestParse(object):
             [("string", "value"), ("integer", "1"), ("integer", "2")]
         )
 
-        parsed = parser.parse(attrs, request)
+        parsed = parser.parse(attrs, request, location="query")
 
         assert parsed["integer"] == [1, 2]
         assert parsed["string"] == value
@@ -305,7 +219,7 @@ class TestParse(object):
             [("string", "value"), ("integer", "1"), ("integer", "2")]
         )
 
-        parsed = parser.parse(attrs, request)
+        parsed = parser.parse(attrs, request, location="form")
 
         assert parsed["integer"] == [1, 2]
         assert parsed["string"] == value
@@ -337,7 +251,7 @@ class TestParse(object):
 
         request = make_request(headers={"string": "value", "integer": ["1", "2"]})
 
-        parsed = parser.parse(attrs, request, locations=["headers"])
+        parsed = parser.parse(attrs, request, location="headers")
 
         assert parsed["string"] == value
         assert parsed["integer"] == [1, 2]
@@ -349,7 +263,7 @@ class TestParse(object):
             [("string", "value"), ("integer", "1"), ("integer", "2")]
         )
 
-        parsed = parser.parse(attrs, request, locations=["cookies"])
+        parsed = parser.parse(attrs, request, location="cookies")
 
         assert parsed["string"] == value
         assert parsed["integer"] == [2]
@@ -361,7 +275,7 @@ class TestParse(object):
             [("string", "value"), ("integer", "1"), ("integer", "2")]
         )
 
-        parsed = parser.parse(attrs, request, locations=["files"])
+        parsed = parser.parse(attrs, request, location="files")
 
         assert parsed["string"] == value
         assert parsed["integer"] == [1, 2]
@@ -383,12 +297,9 @@ class TestParse(object):
             parser.parse(args, request)
 
 
-class TestUseArgs(object):
-    def setup_method(self, method):
-        parser.clear_cache()
-
+class TestUseArgs:
     def test_it_should_pass_parsed_as_first_argument(self):
-        class Handler(object):
+        class Handler:
             request = make_json_request({"key": "value"})
 
             @use_args({"key": fields.Field()})
@@ -403,7 +314,7 @@ class TestUseArgs(object):
         assert result is True
 
     def test_it_should_pass_parsed_as_kwargs_arguments(self):
-        class Handler(object):
+        class Handler:
             request = make_json_request({"key": "value"})
 
             @use_kwargs({"key": fields.Field()})
@@ -418,7 +329,7 @@ class TestUseArgs(object):
         assert result is True
 
     def test_it_should_be_validate_arguments_when_validator_is_passed(self):
-        class Handler(object):
+        class Handler:
             request = make_json_request({"foo": 41})
 
             @use_kwargs({"foo": fields.Int()}, validate=lambda args: args["foo"] > 42)
@@ -476,8 +387,8 @@ def make_files_request(args):
 
 
 def make_request(uri=None, body=None, headers=None, files=None):
-    uri = uri if uri is not None else u""
-    body = body if body is not None else u""
+    uri = uri if uri is not None else ""
+    body = body if body is not None else ""
     method = "POST" if body else "GET"
     # Need to make a mock connection right now because Tornado 4.0 requires a
     # remote_ip in the context attribute. 4.1 addresses this, and this
@@ -486,7 +397,7 @@ def make_request(uri=None, body=None, headers=None, files=None):
     mock_connection = mock.Mock(spec=tornado.http1connection.HTTP1Connection)
     mock_connection.context = mock.Mock()
     mock_connection.remote_ip = None
-    content_type = headers.get("Content-Type", u"") if headers else u""
+    content_type = headers.get("Content-Type", "") if headers else ""
     request = tornado.httputil.HTTPServerRequest(
         method=method,
         uri=uri,
@@ -509,10 +420,22 @@ def make_request(uri=None, body=None, headers=None, files=None):
 class EchoHandler(tornado.web.RequestHandler):
     ARGS = {"name": fields.Str()}
 
-    @use_args(ARGS)
+    @use_args(ARGS, location="query")
     def get(self, args):
         self.write(args)
 
+
+class EchoFormHandler(tornado.web.RequestHandler):
+    ARGS = {"name": fields.Str()}
+
+    @use_args(ARGS, location="form")
+    def post(self, args):
+        self.write(args)
+
+
+class EchoJSONHandler(tornado.web.RequestHandler):
+    ARGS = {"name": fields.Str()}
+
     @use_args(ARGS)
     def post(self, args):
         self.write(args)
@@ -521,13 +444,18 @@ class EchoHandler(tornado.web.RequestHandler):
 class EchoWithParamHandler(tornado.web.RequestHandler):
     ARGS = {"name": fields.Str()}
 
-    @use_args(ARGS)
+    @use_args(ARGS, location="query")
     def get(self, id, args):
         self.write(args)
 
 
 echo_app = tornado.web.Application(
-    [(r"/echo", EchoHandler), (r"/echo_with_param/(\d+)", EchoWithParamHandler)]
+    [
+        (r"/echo", EchoHandler),
+        (r"/echo_form", EchoFormHandler),
+        (r"/echo_json", EchoJSONHandler),
+        (r"/echo_with_param/(\d+)", EchoWithParamHandler),
+    ]
 )
 
 
@@ -537,7 +465,7 @@ class TestApp(AsyncHTTPTestCase):
 
     def test_post(self):
         res = self.fetch(
-            "/echo",
+            "/echo_json",
             method="POST",
             headers={"Content-Type": "application/json"},
             body=json.dumps({"name": "Steve"}),
@@ -545,7 +473,7 @@ class TestApp(AsyncHTTPTestCase):
         json_body = parse_json(res.body)
         assert json_body["name"] == "Steve"
         res = self.fetch(
-            "/echo",
+            "/echo_json",
             method="POST",
             headers={"Content-Type": "application/json"},
             body=json.dumps({}),
@@ -577,7 +505,7 @@ class ValidateHandler(tornado.web.RequestHandler):
     def post(self, args):
         self.write(args)
 
-    @use_kwargs(ARGS)
+    @use_kwargs(ARGS, location="query")
     def get(self, name):
         self.write({"status": "success"})
 
diff --git a/tests/test_webapp2parser.py b/tests/test_webapp2parser.py
deleted file mode 100644
index f32fda6..0000000
--- a/tests/test_webapp2parser.py
+++ /dev/null
@@ -1,160 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Tests for the webapp2 parser"""
-try:
-    from urllib.parse import urlencode
-except ImportError:  # PY2
-    from urllib import urlencode  # type: ignore
-from webargs.core import json
-
-import pytest
-from marshmallow import fields, ValidationError
-
-import webtest
-import webapp2
-from webargs.webapp2parser import parser
-
-hello_args = {"name": fields.Str(missing="World")}
-
-hello_multiple = {"name": fields.List(fields.Str())}
-
-hello_validate = {
-    "num": fields.Int(
-        validate=lambda n: n != 3,
-        error_messages={"validator_failed": "Houston, we've had a problem."},
-    )
-}
-
-
-def test_parse_querystring_args():
-    request = webapp2.Request.blank("/echo?name=Fred")
-    assert parser.parse(hello_args, req=request) == {"name": "Fred"}
-
-
-def test_parse_querystring_multiple():
-    expected = {"name": ["steve", "Loria"]}
-    request = webapp2.Request.blank("/echomulti?name=steve&name=Loria")
-    assert parser.parse(hello_multiple, req=request) == expected
-
-
-def test_parse_form():
-    expected = {"name": "Joe"}
-    request = webapp2.Request.blank("/echo", POST=expected)
-    assert parser.parse(hello_args, req=request) == expected
-
-
-def test_parse_form_multiple():
-    expected = {"name": ["steve", "Loria"]}
-    request = webapp2.Request.blank("/echo", POST=urlencode(expected, doseq=True))
-    assert parser.parse(hello_multiple, req=request) == expected
-
-
-def test_parsing_form_default():
-    request = webapp2.Request.blank("/echo", POST="")
-    assert parser.parse(hello_args, req=request) == {"name": "World"}
-
-
-def test_parse_json():
-    expected = {"name": "Fred"}
-    request = webapp2.Request.blank(
-        "/echo", POST=json.dumps(expected), headers={"content-type": "application/json"}
-    )
-    assert parser.parse(hello_args, req=request) == expected
-
-
-def test_parse_json_content_type_mismatch():
-    request = webapp2.Request.blank(
-        "/echo_json",
-        POST=json.dumps({"name": "foo"}),
-        headers={"content-type": "application/x-www-form-urlencoded"},
-    )
-    assert parser.parse(hello_args, req=request) == {"name": "World"}
-
-
-def test_parse_invalid_json():
-    request = webapp2.Request.blank(
-        "/echo", POST='{"foo": "bar", }', headers={"content-type": "application/json"}
-    )
-    with pytest.raises(json.JSONDecodeError):
-        parser.parse(hello_args, req=request)
-
-
-def test_parse_json_with_vendor_media_type():
-    expected = {"name": "Fred"}
-    request = webapp2.Request.blank(
-        "/echo",
-        POST=json.dumps(expected),
-        headers={"content-type": "application/vnd.api+json"},
-    )
-    assert parser.parse(hello_args, req=request) == expected
-
-
-def test_parse_json_default():
-    request = webapp2.Request.blank(
-        "/echo", POST="", headers={"content-type": "application/json"}
-    )
-    assert parser.parse(hello_args, req=request) == {"name": "World"}
-
-
-def test_parsing_cookies():
-    # whitespace is not valid in a cookie name or value per RFC 6265
-    # http://tools.ietf.org/html/rfc6265#section-4.1.1
-    expected = {"name": "Jean-LucPicard"}
-    response = webapp2.Response()
-    response.set_cookie("name", expected["name"])
-    request = webapp2.Request.blank(
-        "/", headers={"Cookie": response.headers["Set-Cookie"]}
-    )
-    assert parser.parse(hello_args, req=request, locations=("cookies",)) == expected
-
-
-def test_parsing_headers():
-    expected = {"name": "Fred"}
-    request = webapp2.Request.blank("/", headers=expected)
-    assert parser.parse(hello_args, req=request, locations=("headers",)) == expected
-
-
-def test_parse_files():
-    """Test parsing file upload using WebTest since I don't know how to mock
-    that using a webob.Request
-    """
-
-    class Handler(webapp2.RequestHandler):
-        @parser.use_args({"myfile": fields.List(fields.Field())}, locations=("files",))
-        def post(self, args):
-            self.response.content_type = "application/json"
-
-            def _value(f):
-                return f.getvalue().decode("utf-8")
-
-            data = dict((i.filename, _value(i.file)) for i in args["myfile"])
-            self.response.write(json.dumps(data))
-
-    app = webapp2.WSGIApplication([("/", Handler)])
-    testapp = webtest.TestApp(app)
-    payload = [("myfile", "baz.txt", b"bar"), ("myfile", "moo.txt", b"zoo")]
-    res = testapp.post("/", upload_files=payload)
-    assert res.json == {"baz.txt": "bar", "moo.txt": "zoo"}
-
-
-def test_exception_on_validation_error():
-    request = webapp2.Request.blank("/", POST={"num": "3"})
-    with pytest.raises(ValidationError):
-        parser.parse(hello_validate, req=request)
-
-
-def test_validation_error_with_message():
-    request = webapp2.Request.blank("/", POST={"num": "3"})
-    with pytest.raises(ValidationError) as exc:
-        parser.parse(hello_validate, req=request)
-        assert "Houston, we've had a problem." in exc.value
-
-
-def test_default_app_request():
-    """Test that parser.parse uses the request from webapp2.get_request() if no
-    request is passed
-    """
-    expected = {"name": "Joe"}
-    request = webapp2.Request.blank("/echo", POST=expected)
-    app = webapp2.WSGIApplication([])
-    app.set_globals(app, request)
-    assert parser.parse(hello_args) == expected
diff --git a/tox.ini b/tox.ini
index 266e149..f3915a2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,26 +1,37 @@
 [tox]
 envlist=
     lint
-    py{27,35,36,37}-marshmallow2
-    py{35,36,37}-marshmallow3
-    py37-marshmallowdev
+    py{36,37,38,39}
+    py36-mindeps
+    py39-marshmallowdev
     docs
 
 [testenv]
 extras = tests
 deps =
-    marshmallow2: marshmallow==2.15.2
-    marshmallow3: marshmallow>=3.0.0rc2,<4.0.0
+    !marshmallowdev: marshmallow>=3.0.0,<4.0.0
     marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz
-commands =
-  py27: pytest --ignore=tests/test_py3/ {posargs}
-  py{35,36,37}: pytest {posargs}
+    mindeps: Flask==0.12.5
+    mindeps: Django==2.2.0
+    mindeps: bottle==0.12.13
+    mindeps: tornado==4.5.2
+    mindeps: pyramid==1.9.1
+    mindeps: falcon==2.0.0
+    mindeps: aiohttp==3.0.8
+commands = pytest {posargs}
 
 [testenv:lint]
-deps = pre-commit~=1.17
+deps = pre-commit~=2.4
 skip_install = true
 commands = pre-commit run --all-files
 
+# a separate `mypy` target which runs `mypy` in an environment with
+# `webargs` and `marshmallow` both installed is a valuable safeguard against
+# issues in which `mypy` running on every file standalone won't catch things
+[testenv:mypy]
+deps = mypy
+commands = mypy src/
+
 [testenv:docs]
 extras = docs
 commands = sphinx-build docs/ docs/_build {posargs}
@@ -31,7 +42,7 @@ commands = sphinx-build docs/ docs/_build {posargs}
 deps =
   sphinx-autobuild
 extras = docs
-commands = sphinx-autobuild --open-browser docs/ docs/_build {posargs} -z src/webargs -s 2
+commands = sphinx-autobuild --open-browser docs/ docs/_build {posargs} --watch src/webargs --delay 2
 
 [testenv:watch-readme]
 deps = restview