diff --git a/HISTORY.md b/HISTORY.md
index 59e4a9f707..ccf4e17400 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,7 +4,12 @@ Release History
 dev
 ---

--   \[Short description of non-trivial change.\]
+- \[Short description of non-trivial change.\]
+
+- Added a `requests.exceptions.JSONDecodeError` to decrease inconsistencies
+  in the library. This gets raised in the `response.json()` method, and is
+  backwards compatible as it inherits from previously thrown exceptions.
+  Can be caught from `requests.exceptions.RequestException` as well.

 2.26.0 (2021-07-13)
 -------------------
diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst
index b4649f00d5..73d0b2931a 100644
--- a/docs/user/quickstart.rst
+++ b/docs/user/quickstart.rst
@@ -153,9 +153,9 @@ There's also a builtin JSON decoder, in case you're dealing with JSON data::

 In case the JSON decoding fails, ``r.json()`` raises an exception. For example, if
 the response gets a 204 (No Content), or if the response contains invalid JSON,
-attempting ``r.json()`` raises ``simplejson.JSONDecodeError`` if simplejson is
-installed or raises ``ValueError: No JSON object could be decoded`` on Python 2 or
-``json.JSONDecodeError`` on Python 3.
+attempting ``r.json()`` raises ``requests.exceptions.JSONDecodeError``. This wrapper exception
+provides interoperability for multiple exceptions that may be thrown by different
+python versions and json serialization libraries.

 It should be noted that the success of the call to ``r.json()`` does **not**
 indicate the success of the response. Some servers may return a JSON object in a
diff --git a/requests/__init__.py b/requests/__init__.py
index 0ac7713b81..53a5b42af6 100644
--- a/requests/__init__.py
+++ b/requests/__init__.py
@@ -139,7 +139,7 @@ def _check_cryptography(cryptography_version):
 from .exceptions import (
     RequestException, Timeout, URLRequired,
     TooManyRedirects, HTTPError, ConnectionError,
-    FileModeWarning, ConnectTimeout, ReadTimeout
+    FileModeWarning, ConnectTimeout, ReadTimeout, JSONDecodeError
 )

 # Set default logging handler to avoid "No handler found" warnings.
diff --git a/requests/compat.py b/requests/compat.py
index 0b14f5015c..029ae62ac3 100644
--- a/requests/compat.py
+++ b/requests/compat.py
@@ -28,8 +28,10 @@
 #: Python 3.x?
 is_py3 = (_ver[0] == 3)

+has_simplejson = False
 try:
     import simplejson as json
+    has_simplejson = True
 except ImportError:
     import json

@@ -49,13 +51,13 @@
     # Keep OrderedDict for backwards compatibility.
     from collections import Callable, Mapping, MutableMapping, OrderedDict

-
     builtin_str = str
     bytes = str
     str = unicode
     basestring = basestring
     numeric_types = (int, long, float)
     integer_types = (int, long)
+    JSONDecodeError = ValueError

 elif is_py3:
     from urllib.parse import urlparse, urlunparse, urljoin, urlsplit, urlencode, quote, unquote, quote_plus, unquote_plus, urldefrag
@@ -66,6 +68,10 @@
     # Keep OrderedDict for backwards compatibility.
     from collections import OrderedDict
     from collections.abc import Callable, Mapping, MutableMapping
+    if has_simplejson:
+        from simplejson import JSONDecodeError
+    else:
+        from json import JSONDecodeError

     builtin_str = str
     str = str
diff --git a/requests/exceptions.py b/requests/exceptions.py
index c412ec9868..957e31f384 100644
--- a/requests/exceptions.py
+++ b/requests/exceptions.py
@@ -8,6 +8,8 @@
 """
 from urllib3.exceptions import HTTPError as BaseHTTPError

+from .compat import JSONDecodeError as CompatJSONDecodeError
+

 class RequestException(IOError):
     """There was an ambiguous exception that occurred while handling your
@@ -29,6 +31,10 @@ class InvalidJSONError(RequestException):
     """A JSON error occurred."""

+class JSONDecodeError(InvalidJSONError, CompatJSONDecodeError):
+    """Couldn't decode the text into json"""
+
+
 class HTTPError(RequestException):
     """An HTTP error occurred."""

diff --git a/requests/models.py b/requests/models.py
index aa6fb86e4e..e7d292d580 100644
--- a/requests/models.py
+++ b/requests/models.py
@@ -29,7 +29,9 @@
 from .cookies import cookiejar_from_dict, get_cookie_header, _copy_cookie_jar
 from .exceptions import (
     HTTPError, MissingSchema, InvalidURL, ChunkedEncodingError,
-    ContentDecodingError, ConnectionError, StreamConsumedError, InvalidJSONError)
+    ContentDecodingError, ConnectionError, StreamConsumedError,
+    InvalidJSONError)
+from .exceptions import JSONDecodeError as RequestsJSONDecodeError
 from ._internal_utils import to_native_string, unicode_is_ascii
 from .utils import (
     guess_filename, get_auth_from_url, requote_uri,
@@ -38,7 +40,7 @@
 from .compat import (
     Callable, Mapping,
     cookielib, urlunparse, urlsplit, urlencode, str, bytes,
-    is_py2, chardet, builtin_str, basestring)
+    is_py2, chardet, builtin_str, basestring, JSONDecodeError)
 from .compat import json as complexjson
 from .status_codes import codes

@@ -468,9 +470,9 @@ def prepare_body(self, data, files, json=None):
             content_type = 'application/json'

             try:
-              body = complexjson.dumps(json, allow_nan=False)
+                body = complexjson.dumps(json, allow_nan=False)
             except ValueError as ve:
-              raise InvalidJSONError(ve, request=self)
+                raise InvalidJSONError(ve, request=self)

             if not isinstance(body, bytes):
                 body = body.encode('utf-8')
@@ -882,12 +884,8 @@ def json(self, **kwargs):
         r"""Returns the json-encoded content of a response, if any.

         :param \*\*kwargs: Optional arguments that ``json.loads`` takes.
-        :raises simplejson.JSONDecodeError: If the response body does not
-            contain valid json and simplejson is installed.
-        :raises json.JSONDecodeError: If the response body does not contain
-            valid json and simplejson is not installed on Python 3.
-        :raises ValueError: If the response body does not contain valid
-            json and simplejson is not installed on Python 2.
+        :raises requests.exceptions.JSONDecodeError: If the response body does not
+            contain valid json.
         """

         if not self.encoding and self.content and len(self.content) > 3:
@@ -907,7 +905,16 @@ def json(self, **kwargs):
                     # and the server didn't bother to tell us what codec *was*
                     # used.
                     pass
-        return complexjson.loads(self.text, **kwargs)
+
+        try:
+            return complexjson.loads(self.text, **kwargs)
+        except JSONDecodeError as e:
+            # Catch JSON-related errors and raise as requests.JSONDecodeError
+            # This aliases json.JSONDecodeError and simplejson.JSONDecodeError
+            if is_py2: # e is a ValueError
+                raise RequestsJSONDecodeError(e.message)
+            else:
+                raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)

     @property
     def links(self):

diff --git a/tests/test_requests.py b/tests/test_requests.py
index b77cba007d..b6d97dd9f4 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -2570,4 +2570,9 @@ def test_parameters_for_nonstandard_schemes(self, input, params, expected):
     def test_post_json_nan(self, httpbin):
         data = {"foo": float("nan")}
         with pytest.raises(requests.exceptions.InvalidJSONError):
-          r = requests.post(httpbin('post'), json=data)
\ No newline at end of file
+          r = requests.post(httpbin('post'), json=data)
+
+    def test_json_decode_compatibility(self, httpbin):
+        r = requests.get(httpbin('bytes/20'))
+        with pytest.raises(requests.exceptions.JSONDecodeError):
+            r.json()
\ No newline at end of file

diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst
index b4649f00..73d0b293 100644
--- a/docs/user/quickstart.rst
+++ b/docs/user/quickstart.rst
@@ -153,9 +153,9 @@ There's also a builtin JSON decoder, in case you're dealing with JSON data::

 In case the JSON decoding fails, ``r.json()`` raises an exception. For example, if
 the response gets a 204 (No Content), or if the response contains invalid JSON,
-attempting ``r.json()`` raises ``simplejson.JSONDecodeError`` if simplejson is
-installed or raises ``ValueError: No JSON object could be decoded`` on Python 2 or
-``json.JSONDecodeError`` on Python 3.
+attempting ``r.json()`` raises ``requests.exceptions.JSONDecodeError``. This wrapper exception
+provides interoperability for multiple exceptions that may be thrown by different
+python versions and json serialization libraries.

 It should be noted that the success of the call to ``r.json()`` does **not**
 indicate the success of the response. Some servers may return a JSON object in a

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:


diff --git a/AUTHORS b/AUTHORS
index cf31e038961..2fd2ab15e5d 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -166,6 +166,7 @@ Tarcisio Fischer
 Tareq Alayan
 Ted Xiao
 Thomas Grainger
+Thomas Hisch
 Tom Viner
 Trevor Bekolay
 Tyler Goodlet
diff --git a/_pytest/config.py b/_pytest/config.py
index 690f9858774..0d77cfbbf04 100644
--- a/_pytest/config.py
+++ b/_pytest/config.py
@@ -105,7 +105,7 @@ def directory_arg(path, optname):
     "mark main terminal runner python fixtures debugging unittest capture skipping "
     "tmpdir monkeypatch recwarn pastebin helpconfig nose assertion "
     "junitxml resultlog doctest cacheprovider freeze_support "
-    "setuponly setupplan warnings").split()
+    "setuponly setupplan warnings logging").split()

 builtin_plugins = set(default_plugins)
diff --git a/_pytest/logging.py b/_pytest/logging.py
new file mode 100644
index 00000000000..ed4db25ad44
--- /dev/null
+++ b/_pytest/logging.py
@@ -0,0 +1,337 @@
+from __future__ import absolute_import, division, print_function
+
+import logging
+from contextlib import closing, contextmanager
+import sys
+import six
+
+import pytest
+import py
+
+
+DEFAULT_LOG_FORMAT = '%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s'
+DEFAULT_LOG_DATE_FORMAT = '%H:%M:%S'
+
+
+def get_option_ini(config, *names):
+    for name in names:
+        ret = config.getoption(name)  # 'default' arg won't work as expected
+        if ret is None:
+            ret = config.getini(name)
+        if ret:
+            return ret
+
+
+def pytest_addoption(parser):
+    """Add options to control log capturing."""
+    group = parser.getgroup('logging')
+
+    def add_option_ini(option, dest, default=None, type=None, **kwargs):
+        parser.addini(dest, default=default, type=type,
+                      help='default value for ' + option)
+        group.addoption(option, dest=dest, **kwargs)
+
+    add_option_ini(
+        '--no-print-logs',
+        dest='log_print', action='store_const', const=False, default=True,
+        type='bool',
+        help='disable printing caught logs on failed tests.')
+    add_option_ini(
+        '--log-level',
+        dest='log_level', default=None,
+        help='logging level used by the logging module')
+    add_option_ini(
+        '--log-format',
+        dest='log_format', default=DEFAULT_LOG_FORMAT,
+        help='log format as used by the logging module.')
+    add_option_ini(
+        '--log-date-format',
+        dest='log_date_format', default=DEFAULT_LOG_DATE_FORMAT,
+        help='log date format as used by the logging module.')
+    add_option_ini(
+        '--log-cli-level',
+        dest='log_cli_level', default=None,
+        help='cli logging level.')
+    add_option_ini(
+        '--log-cli-format',
+        dest='log_cli_format', default=None,
+        help='log format as used by the logging module.')
+    add_option_ini(
+        '--log-cli-date-format',
+        dest='log_cli_date_format', default=None,
+        help='log date format as used by the logging module.')
+    add_option_ini(
+        '--log-file',
+        dest='log_file', default=None,
+        help='path to a file when logging will be written to.')
+    add_option_ini(
+        '--log-file-level',
+        dest='log_file_level', default=None,
+        help='log file logging level.')
+    add_option_ini(
+        '--log-file-format',
+        dest='log_file_format', default=DEFAULT_LOG_FORMAT,
+        help='log format as used by the logging module.')
+    add_option_ini(
+        '--log-file-date-format',
+        dest='log_file_date_format', default=DEFAULT_LOG_DATE_FORMAT,
+        help='log date format as used by the logging module.')
+
+
+@contextmanager
+def logging_using_handler(handler, logger=None):
+    """Context manager that safely registers a given handler."""
+    logger = logger or logging.getLogger(logger)
+
+    if handler in logger.handlers:  # reentrancy
+        # Adding the same handler twice would confuse logging system.
+        # Just don't do that.
+        yield
+    else:
+        logger.addHandler(handler)
+        try:
+            yield
+        finally:
+            logger.removeHandler(handler)
+
+
+@contextmanager
+def catching_logs(handler, formatter=None,
+                  level=logging.NOTSET, logger=None):
+    """Context manager that prepares the whole logging machinery properly."""
+    logger = logger or logging.getLogger(logger)
+
+    if formatter is not None:
+        handler.setFormatter(formatter)
+    handler.setLevel(level)
+
+    with logging_using_handler(handler, logger):
+        orig_level = logger.level
+        logger.setLevel(min(orig_level, level))
+        try:
+            yield handler
+        finally:
+            logger.setLevel(orig_level)
+
+
+class LogCaptureHandler(logging.StreamHandler):
+    """A logging handler that stores log records and the log text."""
+
+    def __init__(self):
+        """Creates a new log handler."""
+        logging.StreamHandler.__init__(self, py.io.TextIO())
+        self.records = []
+
+    def emit(self, record):
+        """Keep the log records in a list in addition to the log text."""
+        self.records.append(record)
+        logging.StreamHandler.emit(self, record)
+
+
+class LogCaptureFixture(object):
+    """Provides access and control of log capturing."""
+
+    def __init__(self, item):
+        """Creates a new funcarg."""
+        self._item = item
+
+    @property
+    def handler(self):
+        return self._item.catch_log_handler
+
+    @property
+    def text(self):
+        """Returns the log text."""
+        return self.handler.stream.getvalue()
+
+    @property
+    def records(self):
+        """Returns the list of log records."""
+        return self.handler.records
+
+    @property
+    def record_tuples(self):
+        """Returns a list of a striped down version of log records intended
+        for use in assertion comparison.
+
+        The format of the tuple is:
+
+            (logger_name, log_level, message)
+        """
+        return [(r.name, r.levelno, r.getMessage()) for r in self.records]
+
+    def clear(self):
+        """Reset the list of log records."""
+        self.handler.records = []
+
+    def set_level(self, level, logger=None):
+        """Sets the level for capturing of logs.
+
+        By default, the level is set on the handler used to capture
+        logs. Specify a logger name to instead set the level of any
+        logger.
+        """
+        if logger is None:
+            logger = self.handler
+        else:
+            logger = logging.getLogger(logger)
+        logger.setLevel(level)
+
+    @contextmanager
+    def at_level(self, level, logger=None):
+        """Context manager that sets the level for capturing of logs.
+
+        By default, the level is set on the handler used to capture
+        logs. Specify a logger name to instead set the level of any
+        logger.
+        """
+        if logger is None:
+            logger = self.handler
+        else:
+            logger = logging.getLogger(logger)
+
+        orig_level = logger.level
+        logger.setLevel(level)
+        try:
+            yield
+        finally:
+            logger.setLevel(orig_level)
+
+
+@pytest.fixture
+def caplog(request):
+    """Access and control log capturing.
+
+    Captured logs are available through the following methods::
+
+    * caplog.text()          -> string containing formatted log output
+    * caplog.records()       -> list of logging.LogRecord instances
+    * caplog.record_tuples() -> list of (logger_name, level, message) tuples
+    """
+    return LogCaptureFixture(request.node)
+
+
+def get_actual_log_level(config, *setting_names):
+    """Return the actual logging level."""
+
+    for setting_name in setting_names:
+        log_level = config.getoption(setting_name)
+        if log_level is None:
+            log_level = config.getini(setting_name)
+        if log_level:
+            break
+    else:
+        return
+
+    if isinstance(log_level, six.string_types):
+        log_level = log_level.upper()
+    try:
+        return int(getattr(logging, log_level, log_level))
+    except ValueError:
+        # Python logging does not recognise this as a logging level
+        raise pytest.UsageError(
+            "'{0}' is not recognized as a logging level name for "
+            "'{1}'. Please consider passing the "
+            "logging level num instead.".format(
+                log_level,
+                setting_name))
+
+
+def pytest_configure(config):
+    config.pluginmanager.register(LoggingPlugin(config),
+                                  'logging-plugin')
+
+
+class LoggingPlugin(object):
+    """Attaches to the logging module and captures log messages for each test.
+    """
+
+    def __init__(self, config):
+        """Creates a new plugin to capture log messages.
+
+        The formatter can be safely shared across all handlers so
+        create a single one for the entire test session here.
+        """
+        self.log_cli_level = get_actual_log_level(
+            config, 'log_cli_level', 'log_level') or logging.WARNING
+
+        self.print_logs = get_option_ini(config, 'log_print')
+        self.formatter = logging.Formatter(
+            get_option_ini(config, 'log_format'),
+            get_option_ini(config, 'log_date_format'))
+
+        log_cli_handler = logging.StreamHandler(sys.stderr)
+        log_cli_format = get_option_ini(
+            config, 'log_cli_format', 'log_format')
+        log_cli_date_format = get_option_ini(
+            config, 'log_cli_date_format', 'log_date_format')
+        log_cli_formatter = logging.Formatter(
+            log_cli_format,
+            datefmt=log_cli_date_format)
+        self.log_cli_handler = log_cli_handler  # needed for a single unittest
+        self.live_logs = catching_logs(log_cli_handler,
+                                       formatter=log_cli_formatter,
+                                       level=self.log_cli_level)
+
+        log_file = get_option_ini(config, 'log_file')
+        if log_file:
+            self.log_file_level = get_actual_log_level(
+                config, 'log_file_level') or logging.WARNING
+
+            log_file_format = get_option_ini(
+                config, 'log_file_format', 'log_format')
+            log_file_date_format = get_option_ini(
+                config, 'log_file_date_format', 'log_date_format')
+            self.log_file_handler = logging.FileHandler(
+                log_file,
+                # Each pytest runtests session will write to a clean logfile
+                mode='w')
+            log_file_formatter = logging.Formatter(
+                log_file_format,
+                datefmt=log_file_date_format)
+            self.log_file_handler.setFormatter(log_file_formatter)
+        else:
+            self.log_file_handler = None
+
+    @contextmanager
+    def _runtest_for(self, item, when):
+        """Implements the internals of pytest_runtest_xxx() hook."""
+        with catching_logs(LogCaptureHandler(),
+                           formatter=self.formatter) as log_handler:
+            item.catch_log_handler = log_handler
+            try:
+                yield  # run test
+            finally:
+                del item.catch_log_handler
+
+            if self.print_logs:
+                # Add a captured log section to the report.
+                log = log_handler.stream.getvalue().strip()
+                item.add_report_section(when, 'log', log)
+
+    @pytest.hookimpl(hookwrapper=True)
+    def pytest_runtest_setup(self, item):
+        with self._runtest_for(item, 'setup'):
+            yield
+
+    @pytest.hookimpl(hookwrapper=True)
+    def pytest_runtest_call(self, item):
+        with self._runtest_for(item, 'call'):
+            yield
+
+    @pytest.hookimpl(hookwrapper=True)
+    def pytest_runtest_teardown(self, item):
+        with self._runtest_for(item, 'teardown'):
+            yield
+
+    @pytest.hookimpl(hookwrapper=True)
+    def pytest_runtestloop(self, session):
+        """Runs all collected test items."""
+        with self.live_logs:
+            if self.log_file_handler is not None:
+                with closing(self.log_file_handler):
+                    with catching_logs(self.log_file_handler,
+                                       level=self.log_file_level):
+                        yield  # run all the tests
+            else:
+                yield  # run all the tests
diff --git a/changelog/2794.feature b/changelog/2794.feature
new file mode 100644
index 00000000000..9cff1c4bb6c
--- /dev/null
+++ b/changelog/2794.feature
@@ -0,0 +1,3 @@
+Pytest now captures and displays output from the standard `logging` module. The user can control the logging level to be captured by specifying options in ``pytest.ini``, the command line and also during individual tests using markers. Also, a ``caplog`` fixture is available that enables users to test the captured log during specific tests (similar to ``capsys`` for example). For more information, please see the `docs <https://docs.pytest.org/en/latest/logging.html>`_.
+
+This feature was introduced by merging the popular `pytest-catchlog <https://pypi.org/project/pytest-catchlog/>`_ plugin, thanks to `Thomas Hisch <https://github.com/thisch>`_. Be advised that during the merging the backward compatibility interface with the defunct ``pytest-capturelog`` has been dropped.
\ No newline at end of file
diff --git a/doc/en/contents.rst b/doc/en/contents.rst
index 028414eb658..12dbce2ee17 100644
--- a/doc/en/contents.rst
+++ b/doc/en/contents.rst
@@ -30,6 +30,7 @@ Full pytest documentation
    xunit_setup
    plugins
    writing_plugins
+   logging

    goodpractices
    pythonpath
diff --git a/doc/en/logging.rst b/doc/en/logging.rst
new file mode 100644
index 00000000000..e3bf5603887
--- /dev/null
+++ b/doc/en/logging.rst
@@ -0,0 +1,192 @@
+.. _logging:
+
+Logging
+-------
+
+.. versionadded 3.3.0
+
+.. note::
+
+   This feature is a drop-in replacement for the `pytest-catchlog
+   <https://pypi.org/project/pytest-catchlog/>`_ plugin and they will conflict
+   with each other. The backward compatibility API with ``pytest-capturelog``
+   has been dropped when this feature was introduced, so if for that reason you
+   still need ``pytest-catchlog`` you can disable the internal feature by
+   adding to your ``pytest.ini``:
+
+   .. code-block:: ini
+
+       [pytest]
+           addopts=-p no:logging
+
+Log messages are captured by default and for each failed test will be shown in
+the same manner as captured stdout and stderr.
+
+Running without options::
+
+    pytest
+
+Shows failed tests like so::
+
+    ----------------------- Captured stdlog call ----------------------
+    test_reporting.py    26 INFO     text going to logger
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+By default each captured log message shows the module, line number, log level
+and message.  Showing the exact module and line number is useful for testing and
+debugging.  If desired the log format and date format can be specified to
+anything that the logging module supports.
+
+Running pytest specifying formatting options::
+
+    pytest --log-format="%(asctime)s %(levelname)s %(message)s" \
+            --log-date-format="%Y-%m-%d %H:%M:%S"
+
+Shows failed tests like so::
+
+    ----------------------- Captured stdlog call ----------------------
+    2010-04-10 14:48:44 INFO text going to logger
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+These options can also be customized through a configuration file:
+
+.. code-block:: ini
+
+    [pytest]
+    log_format = %(asctime)s %(levelname)s %(message)s
+    log_date_format = %Y-%m-%d %H:%M:%S
+
+Further it is possible to disable reporting logs on failed tests completely
+with::
+
+    pytest --no-print-logs
+
+Or in you ``pytest.ini``:
+
+.. code-block:: ini
+
+  [pytest]
+  log_print = False
+
+
+Shows failed tests in the normal manner as no logs were captured::
+
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+Inside tests it is possible to change the log level for the captured log
+messages.  This is supported by the ``caplog`` fixture::
+
+    def test_foo(caplog):
+        caplog.set_level(logging.INFO)
+        pass
+
+By default the level is set on the handler used to catch the log messages,
+however as a convenience it is also possible to set the log level of any
+logger::
+
+    def test_foo(caplog):
+        caplog.set_level(logging.CRITICAL, logger='root.baz')
+        pass
+
+It is also possible to use a context manager to temporarily change the log
+level::
+
+    def test_bar(caplog):
+        with caplog.at_level(logging.INFO):
+            pass
+
+Again, by default the level of the handler is affected but the level of any
+logger can be changed instead with::
+
+    def test_bar(caplog):
+        with caplog.at_level(logging.CRITICAL, logger='root.baz'):
+            pass
+
+Lastly all the logs sent to the logger during the test run are made available on
+the fixture in the form of both the LogRecord instances and the final log text.
+This is useful for when you want to assert on the contents of a message::
+
+    def test_baz(caplog):
+        func_under_test()
+        for record in caplog.records:
+            assert record.levelname != 'CRITICAL'
+        assert 'wally' not in caplog.text
+
+For all the available attributes of the log records see the
+``logging.LogRecord`` class.
+
+You can also resort to ``record_tuples`` if all you want to do is to ensure,
+that certain messages have been logged under a given logger name with a given
+severity and message::
+
+    def test_foo(caplog):
+        logging.getLogger().info('boo %s', 'arg')
+
+        assert caplog.record_tuples == [
+            ('root', logging.INFO, 'boo arg'),
+        ]
+
+You can call ``caplog.clear()`` to reset the captured log records in a test::
+
+    def test_something_with_clearing_records(caplog):
+        some_method_that_creates_log_records()
+        caplog.clear()
+        your_test_method()
+        assert ['Foo'] == [rec.message for rec in caplog.records]
+
+Live Logs
+^^^^^^^^^
+
+By default, pytest will output any logging records with a level higher or
+equal to WARNING. In order to actually see these logs in the console you have to
+disable pytest output capture by passing ``-s``.
+
+You can specify the logging level for which log records with equal or higher
+level are printed to the console by passing ``--log-cli-level``. This setting
+accepts the logging level names as seen in python's documentation or an integer
+as the logging level num.
+
+Additionally, you can also specify ``--log-cli-format`` and
+``--log-cli-date-format`` which mirror and default to ``--log-format`` and
+``--log-date-format`` if not provided, but are applied only to the console
+logging handler.
+
+All of the CLI log options can also be set in the configuration INI file. The
+option names are:
+
+* ``log_cli_level``
+* ``log_cli_format``
+* ``log_cli_date_format``
+
+If you need to record the whole test suite logging calls to a file, you can pass
+``--log-file=/path/to/log/file``. This log file is opened in write mode which
+means that it will be overwritten at each run tests session.
+
+You can also specify the logging level for the log file by passing
+``--log-file-level``. This setting accepts the logging level names as seen in
+python's documentation(ie, uppercased level names) or an integer as the logging
+level num.
+
+Additionally, you can also specify ``--log-file-format`` and
+``--log-file-date-format`` which are equal to ``--log-format`` and
+``--log-date-format`` but are applied to the log file logging handler.
+
+All of the log file options can also be set in the configuration INI file. The
+option names are:
+
+* ``log_file``
+* ``log_file_level``
+* ``log_file_format``
+* ``log_file_date_format``
diff --git a/doc/en/plugins.rst b/doc/en/plugins.rst
index ec031e9e033..bba7d3ecd74 100644
--- a/doc/en/plugins.rst
+++ b/doc/en/plugins.rst
@@ -27,9 +27,6 @@ Here is a little annotated list for some popular plugins:
   for `twisted <http://twistedmatrix.com>`_ apps, starting a reactor and
   processing deferreds from test functions.

-* `pytest-catchlog <http://pypi.python.org/pypi/pytest-catchlog>`_:
-  to capture and assert about messages from the logging module
-
 * `pytest-cov <http://pypi.python.org/pypi/pytest-cov>`_:
   coverage reporting, compatible with distributed testing

diff --git a/doc/en/usage.rst b/doc/en/usage.rst
index a8c6d40a027..1cb64ec8745 100644
--- a/doc/en/usage.rst
+++ b/doc/en/usage.rst
@@ -189,7 +189,6 @@ in your code and pytest automatically disables its output capture for that test:
   for test output occurring after you exit the interactive PDB_ tracing session
   and continue with the regular test run.

-
 .. _durations:

 Profiling test execution duration
diff --git a/setup.py b/setup.py
index 4d74e6bca3a..27d066fbacd 100644
--- a/setup.py
+++ b/setup.py
@@ -43,7 +43,7 @@ def has_environment_marker_support():

 def main():
-    install_requires = ['py>=1.4.33', 'six>=1.10.0', 'setuptools']
+    install_requires = ['py>=1.4.34', 'six>=1.10.0', 'setuptools']
     # if _PYTEST_SETUP_SKIP_PLUGGY_DEP is set, skip installing pluggy;
     # used by tox.ini to test with pluggy master
     if '_PYTEST_SETUP_SKIP_PLUGGY_DEP' not in os.environ:

diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py
new file mode 100644
index 00000000000..b5bee4233a5
--- /dev/null
+++ b/testing/logging/test_fixture.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+import logging
+
+
+logger = logging.getLogger(__name__)
+sublogger = logging.getLogger(__name__+'.baz')
+
+
+def test_fixture_help(testdir):
+    result = testdir.runpytest('--fixtures')
+    result.stdout.fnmatch_lines(['*caplog*'])
+
+
+def test_change_level(caplog):
+    caplog.set_level(logging.INFO)
+    logger.debug('handler DEBUG level')
+    logger.info('handler INFO level')
+
+    caplog.set_level(logging.CRITICAL, logger=sublogger.name)
+    sublogger.warning('logger WARNING level')
+    sublogger.critical('logger CRITICAL level')
+
+    assert 'DEBUG' not in caplog.text
+    assert 'INFO' in caplog.text
+    assert 'WARNING' not in caplog.text
+    assert 'CRITICAL' in caplog.text
+
+
+def test_with_statement(caplog):
+    with caplog.at_level(logging.INFO):
+        logger.debug('handler DEBUG level')
+        logger.info('handler INFO level')
+
+        with caplog.at_level(logging.CRITICAL, logger=sublogger.name):
+            sublogger.warning('logger WARNING level')
+            sublogger.critical('logger CRITICAL level')
+
+    assert 'DEBUG' not in caplog.text
+    assert 'INFO' in caplog.text
+    assert 'WARNING' not in caplog.text
+    assert 'CRITICAL' in caplog.text
+
+
+def test_log_access(caplog):
+    logger.info('boo %s', 'arg')
+    assert caplog.records[0].levelname == 'INFO'
+    assert caplog.records[0].msg == 'boo %s'
+    assert 'boo arg' in caplog.text
+
+
+def test_record_tuples(caplog):
+    logger.info('boo %s', 'arg')
+
+    assert caplog.record_tuples == [
+        (__name__, logging.INFO, 'boo arg'),
+    ]
+
+
+def test_unicode(caplog):
+    logger.info(u'bū')
+    assert caplog.records[0].levelname == 'INFO'
+    assert caplog.records[0].msg == u'bū'
+    assert u'bū' in caplog.text
+
+
+def test_clear(caplog):
+    logger.info(u'bū')
+    assert len(caplog.records)
+    caplog.clear()
+    assert not len(caplog.records)
diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py
new file mode 100644
index 00000000000..c02ee217227
--- /dev/null
+++ b/testing/logging/test_reporting.py
@@ -0,0 +1,398 @@
+# -*- coding: utf-8 -*-
+import os
+import pytest
+
+
+def test_nothing_logged(testdir):
+    testdir.makepyfile('''
+        import sys
+
+        def test_foo():
+            sys.stdout.write('text going to stdout')
+            sys.stderr.write('text going to stderr')
+            assert False
+        ''')
+    result = testdir.runpytest()
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured stdout call -*',
+                                 'text going to stdout'])
+    result.stdout.fnmatch_lines(['*- Captured stderr call -*',
+                                 'text going to stderr'])
+    with pytest.raises(pytest.fail.Exception):
+        result.stdout.fnmatch_lines(['*- Captured *log call -*'])
+
+
+def test_messages_logged(testdir):
+    testdir.makepyfile('''
+        import sys
+        import logging
+
+        logger = logging.getLogger(__name__)
+
+        def test_foo():
+            sys.stdout.write('text going to stdout')
+            sys.stderr.write('text going to stderr')
+            logger.info('text going to logger')
+            assert False
+        ''')
+    result = testdir.runpytest()
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured *log call -*',
+                                 '*text going to logger*'])
+    result.stdout.fnmatch_lines(['*- Captured stdout call -*',
+                                 'text going to stdout'])
+    result.stdout.fnmatch_lines(['*- Captured stderr call -*',
+                                 'text going to stderr'])
+
+
+def test_setup_logging(testdir):
+    testdir.makepyfile('''
+        import logging
+
+        logger = logging.getLogger(__name__)
+
+        def setup_function(function):
+            logger.info('text going to logger from setup')
+
+        def test_foo():
+            logger.info('text going to logger from call')
+            assert False
+        ''')
+    result = testdir.runpytest()
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured *log setup -*',
+                                 '*text going to logger from setup*',
+                                 '*- Captured *log call -*',
+                                 '*text going to logger from call*'])
+
+
+def test_teardown_logging(testdir):
+    testdir.makepyfile('''
+        import logging
+
+        logger = logging.getLogger(__name__)
+
+        def test_foo():
+            logger.info('text going to logger from call')
+
+        def teardown_function(function):
+            logger.info('text going to logger from teardown')
+            assert False
+        ''')
+    result = testdir.runpytest()
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured *log call -*',
+                                 '*text going to logger from call*',
+                                 '*- Captured *log teardown -*',
+                                 '*text going to logger from teardown*'])
+
+
+def test_disable_log_capturing(testdir):
+    testdir.makepyfile('''
+        import sys
+        import logging
+
+        logger = logging.getLogger(__name__)
+
+        def test_foo():
+            sys.stdout.write('text going to stdout')
+            logger.warning('catch me if you can!')
+            sys.stderr.write('text going to stderr')
+            assert False
+        ''')
+    result = testdir.runpytest('--no-print-logs')
+    print(result.stdout)
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured stdout call -*',
+                                 'text going to stdout'])
+    result.stdout.fnmatch_lines(['*- Captured stderr call -*',
+                                 'text going to stderr'])
+    with pytest.raises(pytest.fail.Exception):
+        result.stdout.fnmatch_lines(['*- Captured *log call -*'])
+
+
+def test_disable_log_capturing_ini(testdir):
+    testdir.makeini(
+        '''
+        [pytest]
+        log_print=False
+        '''
+    )
+    testdir.makepyfile('''
+        import sys
+        import logging
+
+        logger = logging.getLogger(__name__)
+
+        def test_foo():
+            sys.stdout.write('text going to stdout')
+            logger.warning('catch me if you can!')
+            sys.stderr.write('text going to stderr')
+            assert False
+        ''')
+    result = testdir.runpytest()
+    print(result.stdout)
+    assert result.ret == 1
+    result.stdout.fnmatch_lines(['*- Captured stdout call -*',
+                                 'text going to stdout'])
+    result.stdout.fnmatch_lines(['*- Captured stderr call -*',
+                                 'text going to stderr'])
+    with pytest.raises(pytest.fail.Exception):
+        result.stdout.fnmatch_lines(['*- Captured *log call -*'])
+
+
+def test_log_cli_default_level(testdir):
+    # Default log file level
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_cli(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_cli_handler.level == logging.WARNING
+            logging.getLogger('catchlog').info("This log message won't be shown")
+            logging.getLogger('catchlog').warning("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    result = testdir.runpytest('-s')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_cli_default_level.py PASSED',
+    ])
+    result.stderr.fnmatch_lines([
+        "* This log message will be shown"
+    ])
+    for line in result.errlines:
+        try:
+            assert "This log message won't be shown" in line
+            pytest.fail("A log message was shown and it shouldn't have been")
+        except AssertionError:
+            continue
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+
+
+def test_log_cli_level(testdir):
+    # Default log file level
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_cli(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_cli_handler.level == logging.INFO
+            logging.getLogger('catchlog').debug("This log message won't be shown")
+            logging.getLogger('catchlog').info("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    result = testdir.runpytest('-s', '--log-cli-level=INFO')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_cli_level.py PASSED',
+    ])
+    result.stderr.fnmatch_lines([
+        "* This log message will be shown"
+    ])
+    for line in result.errlines:
+        try:
+            assert "This log message won't be shown" in line
+            pytest.fail("A log message was shown and it shouldn't have been")
+        except AssertionError:
+            continue
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+
+    result = testdir.runpytest('-s', '--log-level=INFO')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_cli_level.py PASSED',
+    ])
+    result.stderr.fnmatch_lines([
+        "* This log message will be shown"
+    ])
+    for line in result.errlines:
+        try:
+            assert "This log message won't be shown" in line
+            pytest.fail("A log message was shown and it shouldn't have been")
+        except AssertionError:
+            continue
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+
+
+def test_log_cli_ini_level(testdir):
+    testdir.makeini(
+        """
+        [pytest]
+        log_cli_level = INFO
+        """)
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_cli(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_cli_handler.level == logging.INFO
+            logging.getLogger('catchlog').debug("This log message won't be shown")
+            logging.getLogger('catchlog').info("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    result = testdir.runpytest('-s')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_cli_ini_level.py PASSED',
+    ])
+    result.stderr.fnmatch_lines([
+        "* This log message will be shown"
+    ])
+    for line in result.errlines:
+        try:
+            assert "This log message won't be shown" in line
+            pytest.fail("A log message was shown and it shouldn't have been")
+        except AssertionError:
+            continue
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+
+
+def test_log_file_cli(testdir):
+    # Default log file level
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_file(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_file_handler.level == logging.WARNING
+            logging.getLogger('catchlog').info("This log message won't be shown")
+            logging.getLogger('catchlog').warning("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    log_file = testdir.tmpdir.join('pytest.log').strpath
+
+    result = testdir.runpytest('-s', '--log-file={0}'.format(log_file))
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_file_cli.py PASSED',
+    ])
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+    assert os.path.isfile(log_file)
+    with open(log_file) as rfh:
+        contents = rfh.read()
+        assert "This log message will be shown" in contents
+        assert "This log message won't be shown" not in contents
+
+
+def test_log_file_cli_level(testdir):
+    # Default log file level
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_file(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_file_handler.level == logging.INFO
+            logging.getLogger('catchlog').debug("This log message won't be shown")
+            logging.getLogger('catchlog').info("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    log_file = testdir.tmpdir.join('pytest.log').strpath
+
+    result = testdir.runpytest('-s',
+                               '--log-file={0}'.format(log_file),
+                               '--log-file-level=INFO')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_file_cli_level.py PASSED',
+    ])
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+    assert os.path.isfile(log_file)
+    with open(log_file) as rfh:
+        contents = rfh.read()
+        assert "This log message will be shown" in contents
+        assert "This log message won't be shown" not in contents
+
+
+def test_log_file_ini(testdir):
+    log_file = testdir.tmpdir.join('pytest.log').strpath
+
+    testdir.makeini(
+        """
+        [pytest]
+        log_file={0}
+        """.format(log_file))
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_file(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_file_handler.level == logging.WARNING
+            logging.getLogger('catchlog').info("This log message won't be shown")
+            logging.getLogger('catchlog').warning("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    result = testdir.runpytest('-s')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_file_ini.py PASSED',
+    ])
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+    assert os.path.isfile(log_file)
+    with open(log_file) as rfh:
+        contents = rfh.read()
+        assert "This log message will be shown" in contents
+        assert "This log message won't be shown" not in contents
+
+
+def test_log_file_ini_level(testdir):
+    log_file = testdir.tmpdir.join('pytest.log').strpath
+
+    testdir.makeini(
+        """
+        [pytest]
+        log_file={0}
+        log_file_level = INFO
+        """.format(log_file))
+    testdir.makepyfile('''
+        import pytest
+        import logging
+        def test_log_file(request):
+            plugin = request.config.pluginmanager.getplugin('logging-plugin')
+            assert plugin.log_file_handler.level == logging.INFO
+            logging.getLogger('catchlog').debug("This log message won't be shown")
+            logging.getLogger('catchlog').info("This log message will be shown")
+            print('PASSED')
+    ''')
+
+    result = testdir.runpytest('-s')
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        'test_log_file_ini_level.py PASSED',
+    ])
+
+    # make sure that that we get a '0' exit code for the testsuite
+    assert result.ret == 0
+    assert os.path.isfile(log_file)
+    with open(log_file) as rfh:
+        contents = rfh.read()
+        assert "This log message will be shown" in contents
+        assert "This log message won't be shown" not in contents
diff --git a/testing/test_capture.py b/testing/test_capture.py
index eb10f3c0725..336ca9ef01e 100644
--- a/testing/test_capture.py
+++ b/testing/test_capture.py
@@ -342,26 +342,6 @@ def teardown_module(function):
             # verify proper termination
             assert "closed" not in s

-    def test_logging_initialized_in_test(self, testdir):
-        p = testdir.makepyfile("""
-            import sys
-            def test_something():
-                # pytest does not import logging
-                assert 'logging' not in sys.modules
-                import logging
-                logging.basicConfig()
-                logging.warn("hello432")
-                assert 0
-        """)
-        result = testdir.runpytest_subprocess(
-            p, "--traceconfig",
-            "-p", "no:capturelog", "-p", "no:hypothesis", "-p", "no:hypothesispytest")
-        assert result.ret != 0
-        result.stdout.fnmatch_lines([
-            "*hello432*",
-        ])
-        assert 'operation on closed file' not in result.stderr.str()
-
     def test_conftestlogging_is_shown(self, testdir):
         testdir.makeconftest("""
                 import logging

diff --git a/doc/en/contents.rst b/doc/en/contents.rst
index 6b9eed010..7a6570e0b 100644
--- a/doc/en/contents.rst
+++ b/doc/en/contents.rst
@@ -30,6 +30,7 @@ Full pytest documentation
    xunit_setup
    plugins
    writing_plugins
+   logging

    goodpractices
    pythonpath

diff --git a/doc/en/logging.rst b/doc/en/logging.rst
new file mode 100644
index 000000000..e3bf56038
--- /dev/null
+++ b/doc/en/logging.rst
@@ -0,0 +1,192 @@
+.. _logging:
+
+Logging
+-------
+
+.. versionadded 3.3.0
+
+.. note::
+
+   This feature is a drop-in replacement for the `pytest-catchlog
+   <https://pypi.org/project/pytest-catchlog/>`_ plugin and they will conflict
+   with each other. The backward compatibility API with ``pytest-capturelog``
+   has been dropped when this feature was introduced, so if for that reason you
+   still need ``pytest-catchlog`` you can disable the internal feature by
+   adding to your ``pytest.ini``:
+
+   .. code-block:: ini
+
+       [pytest]
+           addopts=-p no:logging
+
+Log messages are captured by default and for each failed test will be shown in
+the same manner as captured stdout and stderr.
+
+Running without options::
+
+    pytest
+
+Shows failed tests like so::
+
+    ----------------------- Captured stdlog call ----------------------
+    test_reporting.py    26 INFO     text going to logger
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+By default each captured log message shows the module, line number, log level
+and message.  Showing the exact module and line number is useful for testing and
+debugging.  If desired the log format and date format can be specified to
+anything that the logging module supports.
+
+Running pytest specifying formatting options::
+
+    pytest --log-format="%(asctime)s %(levelname)s %(message)s" \
+            --log-date-format="%Y-%m-%d %H:%M:%S"
+
+Shows failed tests like so::
+
+    ----------------------- Captured stdlog call ----------------------
+    2010-04-10 14:48:44 INFO text going to logger
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+These options can also be customized through a configuration file:
+
+.. code-block:: ini
+
+    [pytest]
+    log_format = %(asctime)s %(levelname)s %(message)s
+    log_date_format = %Y-%m-%d %H:%M:%S
+
+Further it is possible to disable reporting logs on failed tests completely
+with::
+
+    pytest --no-print-logs
+
+Or in you ``pytest.ini``:
+
+.. code-block:: ini
+
+  [pytest]
+  log_print = False
+
+
+Shows failed tests in the normal manner as no logs were captured::
+
+    ----------------------- Captured stdout call ----------------------
+    text going to stdout
+    ----------------------- Captured stderr call ----------------------
+    text going to stderr
+    ==================== 2 failed in 0.02 seconds =====================
+
+Inside tests it is possible to change the log level for the captured log
+messages.  This is supported by the ``caplog`` fixture::
+
+    def test_foo(caplog):
+        caplog.set_level(logging.INFO)
+        pass
+
+By default the level is set on the handler used to catch the log messages,
+however as a convenience it is also possible to set the log level of any
+logger::
+
+    def test_foo(caplog):
+        caplog.set_level(logging.CRITICAL, logger='root.baz')
+        pass
+
+It is also possible to use a context manager to temporarily change the log
+level::
+
+    def test_bar(caplog):
+        with caplog.at_level(logging.INFO):
+            pass
+
+Again, by default the level of the handler is affected but the level of any
+logger can be changed instead with::
+
+    def test_bar(caplog):
+        with caplog.at_level(logging.CRITICAL, logger='root.baz'):
+            pass
+
+Lastly all the logs sent to the logger during the test run are made available on
+the fixture in the form of both the LogRecord instances and the final log text.
+This is useful for when you want to assert on the contents of a message::
+
+    def test_baz(caplog):
+        func_under_test()
+        for record in caplog.records:
+            assert record.levelname != 'CRITICAL'
+        assert 'wally' not in caplog.text
+
+For all the available attributes of the log records see the
+``logging.LogRecord`` class.
+
+You can also resort to ``record_tuples`` if all you want to do is to ensure,
+that certain messages have been logged under a given logger name with a given
+severity and message::
+
+    def test_foo(caplog):
+        logging.getLogger().info('boo %s', 'arg')
+
+        assert caplog.record_tuples == [
+            ('root', logging.INFO, 'boo arg'),
+        ]
+
+You can call ``caplog.clear()`` to reset the captured log records in a test::
+
+    def test_something_with_clearing_records(caplog):
+        some_method_that_creates_log_records()
+        caplog.clear()
+        your_test_method()
+        assert ['Foo'] == [rec.message for rec in caplog.records]
+
+Live Logs
+^^^^^^^^^
+
+By default, pytest will output any logging records with a level higher or
+equal to WARNING. In order to actually see these logs in the console you have to
+disable pytest output capture by passing ``-s``.
+
+You can specify the logging level for which log records with equal or higher
+level are printed to the console by passing ``--log-cli-level``. This setting
+accepts the logging level names as seen in python's documentation or an integer
+as the logging level num.
+
+Additionally, you can also specify ``--log-cli-format`` and
+``--log-cli-date-format`` which mirror and default to ``--log-format`` and
+``--log-date-format`` if not provided, but are applied only to the console
+logging handler.
+
+All of the CLI log options can also be set in the configuration INI file. The
+option names are:
+
+* ``log_cli_level``
+* ``log_cli_format``
+* ``log_cli_date_format``
+
+If you need to record the whole test suite logging calls to a file, you can pass
+``--log-file=/path/to/log/file``. This log file is opened in write mode which
+means that it will be overwritten at each run tests session.
+
+You can also specify the logging level for the log file by passing
+``--log-file-level``. This setting accepts the logging level names as seen in
+python's documentation(ie, uppercased level names) or an integer as the logging
+level num.
+
+Additionally, you can also specify ``--log-file-format`` and
+``--log-file-date-format`` which are equal to ``--log-format`` and
+``--log-date-format`` but are applied to the log file logging handler.
+
+All of the log file options can also be set in the configuration INI file. The
+option names are:
+
+* ``log_file``
+* ``log_file_level``
+* ``log_file_format``
+* ``log_file_date_format``

diff --git a/doc/en/plugins.rst b/doc/en/plugins.rst
index ec031e9e0..bba7d3ecd 100644
--- a/doc/en/plugins.rst
+++ b/doc/en/plugins.rst
@@ -27,9 +27,6 @@ Here is a little annotated list for some popular plugins:
   for `twisted <http://twistedmatrix.com>`_ apps, starting a reactor and
   processing deferreds from test functions.

-* `pytest-catchlog <http://pypi.python.org/pypi/pytest-catchlog>`_:
-  to capture and assert about messages from the logging module
-
 * `pytest-cov <http://pypi.python.org/pypi/pytest-cov>`_:
   coverage reporting, compatible with distributed testing

diff --git a/doc/en/usage.rst b/doc/en/usage.rst
index a8c6d40a0..1cb64ec87 100644
--- a/doc/en/usage.rst
+++ b/doc/en/usage.rst
@@ -189,7 +189,6 @@ in your code and pytest automatically disables its output capture for that test:
   for test output occurring after you exit the interactive PDB_ tracing session
   and continue with the regular test run.

-
 .. _durations:

 Profiling test execution duration

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'file', 'name': '_pytest/logging.py'}, {'type': 'field', 'name': 'ret'}]


diff --git a/doc/users/next_whats_new/auto_minor_tick.rst b/doc/users/next_whats_new/auto_minor_tick.rst
new file mode 100644
index 000000000000..02db8f6beb38
--- /dev/null
+++ b/doc/users/next_whats_new/auto_minor_tick.rst
@@ -0,0 +1,5 @@
+rcParams for ``AutoMinorLocator`` divisions
+-------------------------------------------
+The rcParams :rc:`xtick.minor.ndivs` and :rc:`ytick.minor.ndivs` have been
+added to enable setting the default number of divisions; if set to ``auto``,
+the number of divisions will be chosen by the distance between major ticks.
diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc
index 578777167d6c..86f70a23dacd 100644
--- a/lib/matplotlib/mpl-data/matplotlibrc
+++ b/lib/matplotlib/mpl-data/matplotlibrc
@@ -489,6 +489,7 @@
 #xtick.major.bottom:  True    # draw x axis bottom major ticks
 #xtick.minor.top:     True    # draw x axis top minor ticks
 #xtick.minor.bottom:  True    # draw x axis bottom minor ticks
+#xtick.minor.ndivs:   auto    # number of minor ticks between the major ticks on x-axis
 #xtick.alignment:     center  # alignment of xticks

 #ytick.left:          True    # draw ticks on the left side
@@ -510,6 +511,7 @@
 #ytick.major.right:   True    # draw y axis right major ticks
 #ytick.minor.left:    True    # draw y axis left minor ticks
 #ytick.minor.right:   True    # draw y axis right minor ticks
+#ytick.minor.ndivs:   auto    # number of minor ticks between the major ticks on y-axis
 #ytick.alignment:     center_baseline  # alignment of yticks

diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py
index b27ac35bcb4d..663ff4b70536 100644
--- a/lib/matplotlib/rcsetup.py
+++ b/lib/matplotlib/rcsetup.py
@@ -568,6 +568,14 @@ def _validate_greaterequal0_lessequal1(s):
         raise RuntimeError(f'Value must be >=0 and <=1; got {s}')

+def _validate_int_greaterequal0(s):
+    s = validate_int(s)
+    if s >= 0:
+        return s
+    else:
+        raise RuntimeError(f'Value must be >=0; got {s}')
+
+
 def validate_hatch(s):
     r"""
     Validate a hatch pattern.
@@ -587,6 +595,24 @@ def validate_hatch(s):
 validate_dashlist = _listify_validator(validate_floatlist)

+def _validate_minor_tick_ndivs(n):
+    """
+    Validate ndiv parameter related to the minor ticks.
+    It controls the number of minor ticks to be placed between
+    two major ticks.
+    """
+
+    if isinstance(n, str) and n.lower() == 'auto':
+        return n
+    try:
+        n = _validate_int_greaterequal0(n)
+        return n
+    except (RuntimeError, ValueError):
+        pass
+
+    raise ValueError("'tick.minor.ndivs' must be 'auto' or non-negative int")
+
+
 _prop_validators = {
         'color': _listify_validator(validate_color_for_prop_cycle,
                                     allow_stringlist=True),
@@ -1093,6 +1119,8 @@ def _convert_validator_spec(key, conv):
     "xtick.minor.bottom":  validate_bool,      # draw bottom minor xticks
     "xtick.major.top":     validate_bool,      # draw top major xticks
     "xtick.major.bottom":  validate_bool,      # draw bottom major xticks
+    # number of minor xticks
+    "xtick.minor.ndivs":   _validate_minor_tick_ndivs,
     "xtick.labelsize":     validate_fontsize,  # fontsize of xtick labels
     "xtick.direction":     ["out", "in", "inout"],  # direction of xticks
     "xtick.alignment":     ["center", "right", "left"],
@@ -1114,6 +1142,8 @@ def _convert_validator_spec(key, conv):
     "ytick.minor.right":   validate_bool,      # draw right minor yticks
     "ytick.major.left":    validate_bool,      # draw left major yticks
     "ytick.major.right":   validate_bool,      # draw right major yticks
+    # number of minor yticks
+    "ytick.minor.ndivs":   _validate_minor_tick_ndivs,
     "ytick.labelsize":     validate_fontsize,  # fontsize of ytick labels
     "ytick.direction":     ["out", "in", "inout"],  # direction of yticks
     "ytick.alignment":     [
diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py
index a29343029f60..bf059567f5fe 100644
--- a/lib/matplotlib/ticker.py
+++ b/lib/matplotlib/ticker.py
@@ -2869,7 +2869,11 @@ def __init__(self, n=None):
         major ticks; e.g., n=2 will place a single minor tick midway
         between major ticks.

-        If *n* is omitted or None, it will be set to 5 or 4.
+        If *n* is omitted or None, the value stored in rcParams will be used.
+        In case *n* is set to 'auto', it will be set to 4 or 5. If the distance
+        between the major ticks equals 1, 2.5, 5 or 10 it can be perfectly
+        divided in 5 equidistant sub-intervals with a length multiple of
+        0.05. Otherwise it is divided in 4 sub-intervals.
         """
         self.ndivs = n

@@ -2892,6 +2896,14 @@ def __call__(self):

         if self.ndivs is None:

+            if self.axis.axis_name == 'y':
+                self.ndivs = mpl.rcParams['ytick.minor.ndivs']
+            else:
+                # for x and z axis
+                self.ndivs = mpl.rcParams['xtick.minor.ndivs']
+
+        if self.ndivs == 'auto':
+
             majorstep_no_exponent = 10 ** (np.log10(majorstep) % 1)

             if np.isclose(majorstep_no_exponent, [1.0, 2.5, 5.0, 10.0]).any():

diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index 15981c4c9047..3d38df575f09 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -211,6 +211,60 @@ def test_additional(self, lim, ref):

         assert_almost_equal(ax.yaxis.get_ticklocs(minor=True), ref)

+    @pytest.mark.parametrize('use_rcparam', [False, True])
+    @pytest.mark.parametrize(
+        'lim, ref', [
+            ((0, 1.39),
+             [0.05, 0.1, 0.15, 0.25, 0.3, 0.35, 0.45, 0.5, 0.55, 0.65, 0.7,
+              0.75, 0.85, 0.9, 0.95, 1.05, 1.1, 1.15, 1.25, 1.3, 1.35]),
+            ((0, 0.139),
+             [0.005, 0.01, 0.015, 0.025, 0.03, 0.035, 0.045, 0.05, 0.055,
+              0.065, 0.07, 0.075, 0.085, 0.09, 0.095, 0.105, 0.11, 0.115,
+              0.125, 0.13, 0.135]),
+        ])
+    def test_number_of_minor_ticks_auto(self, lim, ref, use_rcparam):
+        if use_rcparam:
+            context = {'xtick.minor.ndivs': 'auto', 'ytick.minor.ndivs': 'auto'}
+            kwargs = {}
+        else:
+            context = {}
+            kwargs = {'n': 'auto'}
+
+        with mpl.rc_context(context):
+            fig, ax = plt.subplots()
+            ax.set_xlim(*lim)
+            ax.set_ylim(*lim)
+            ax.xaxis.set_minor_locator(mticker.AutoMinorLocator(**kwargs))
+            ax.yaxis.set_minor_locator(mticker.AutoMinorLocator(**kwargs))
+            assert_almost_equal(ax.xaxis.get_ticklocs(minor=True), ref)
+            assert_almost_equal(ax.yaxis.get_ticklocs(minor=True), ref)
+
+    @pytest.mark.parametrize('use_rcparam', [False, True])
+    @pytest.mark.parametrize(
+        'n, lim, ref', [
+            (2, (0, 4), [0.5, 1.5, 2.5, 3.5]),
+            (4, (0, 2), [0.25, 0.5, 0.75, 1.25, 1.5, 1.75]),
+            (10, (0, 1), [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]),
+        ])
+    def test_number_of_minor_ticks_int(self, n, lim, ref, use_rcparam):
+        if use_rcparam:
+            context = {'xtick.minor.ndivs': n, 'ytick.minor.ndivs': n}
+            kwargs = {}
+        else:
+            context = {}
+            kwargs = {'n': n}
+
+        with mpl.rc_context(context):
+            fig, ax = plt.subplots()
+            ax.set_xlim(*lim)
+            ax.set_ylim(*lim)
+            ax.xaxis.set_major_locator(mticker.MultipleLocator(1))
+            ax.xaxis.set_minor_locator(mticker.AutoMinorLocator(**kwargs))
+            ax.yaxis.set_major_locator(mticker.MultipleLocator(1))
+            ax.yaxis.set_minor_locator(mticker.AutoMinorLocator(**kwargs))
+            assert_almost_equal(ax.xaxis.get_ticklocs(minor=True), ref)
+            assert_almost_equal(ax.yaxis.get_ticklocs(minor=True), ref)
+

 class TestLogLocator:
     def test_basic(self):

diff --git a/doc/users/next_whats_new/auto_minor_tick.rst b/doc/users/next_whats_new/auto_minor_tick.rst
new file mode 100644
index 0000000000..02db8f6beb
--- /dev/null
+++ b/doc/users/next_whats_new/auto_minor_tick.rst
@@ -0,0 +1,5 @@
+rcParams for ``AutoMinorLocator`` divisions
+-------------------------------------------
+The rcParams :rc:`xtick.minor.ndivs` and :rc:`ytick.minor.ndivs` have been
+added to enable setting the default number of divisions; if set to ``auto``,
+the number of divisions will be chosen by the distance between major ticks.


diff --git a/doc/users/next_whats_new/legend-figure-outside.rst b/doc/users/next_whats_new/legend-figure-outside.rst
new file mode 100644
index 000000000000..a78725b4e2b6
--- /dev/null
+++ b/doc/users/next_whats_new/legend-figure-outside.rst
@@ -0,0 +1,8 @@
+Figure legends can be placed outside figures using constrained_layout
+---------------------------------------------------------------------
+Constrained layout will make space for Figure legends if they are specified
+by a *loc* keyword argument that starts with the string "outside". The
+codes are unique from axes codes, in that "outside upper right" will
+make room at the top of the figure for the legend, whereas
+"outside right upper" will make room on the right-hand side of the figure.
+See :doc:`/tutorials/intermediate/legend_guide` for details.
diff --git a/examples/text_labels_and_annotations/figlegend_demo.py b/examples/text_labels_and_annotations/figlegend_demo.py
index 6fc31235f634..c749ae795cd5 100644
--- a/examples/text_labels_and_annotations/figlegend_demo.py
+++ b/examples/text_labels_and_annotations/figlegend_demo.py
@@ -28,3 +28,26 @@

 plt.tight_layout()
 plt.show()
+
+##############################################################################
+# Sometimes we do not want the legend to overlap the axes.  If you use
+# constrained_layout you can specify "outside right upper", and
+# constrained_layout will make room for the legend.
+
+fig, axs = plt.subplots(1, 2, layout='constrained')
+
+x = np.arange(0.0, 2.0, 0.02)
+y1 = np.sin(2 * np.pi * x)
+y2 = np.exp(-x)
+l1, = axs[0].plot(x, y1)
+l2, = axs[0].plot(x, y2, marker='o')
+
+y3 = np.sin(4 * np.pi * x)
+y4 = np.exp(-2 * x)
+l3, = axs[1].plot(x, y3, color='tab:green')
+l4, = axs[1].plot(x, y4, color='tab:red', marker='^')
+
+fig.legend((l1, l2), ('Line 1', 'Line 2'), loc='upper left')
+fig.legend((l3, l4), ('Line 3', 'Line 4'), loc='outside right upper')
+
+plt.show()
diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py
index 996603300620..9554a156f1ec 100644
--- a/lib/matplotlib/_constrained_layout.py
+++ b/lib/matplotlib/_constrained_layout.py
@@ -418,6 +418,25 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0,
         # pass the new margins down to the layout grid for the solution...
         layoutgrids[gs].edit_outer_margin_mins(margin, ss)

+    # make margins for figure-level legends:
+    for leg in fig.legends:
+        inv_trans_fig = None
+        if leg._outside_loc and leg._bbox_to_anchor is None:
+            if inv_trans_fig is None:
+                inv_trans_fig = fig.transFigure.inverted().transform_bbox
+            bbox = inv_trans_fig(leg.get_tightbbox(renderer))
+            w = bbox.width + 2 * w_pad
+            h = bbox.height + 2 * h_pad
+            legendloc = leg._outside_loc
+            if legendloc == 'lower':
+                layoutgrids[fig].edit_margin_min('bottom', h)
+            elif legendloc == 'upper':
+                layoutgrids[fig].edit_margin_min('top', h)
+            if legendloc == 'right':
+                layoutgrids[fig].edit_margin_min('right', w)
+            elif legendloc == 'left':
+                layoutgrids[fig].edit_margin_min('left', w)
+

 def make_margin_suptitles(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0):
     # Figure out how large the suptitle is and make the
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index 58d95912665b..399a79e05cfe 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -294,7 +294,7 @@ def legend(self, *args, **kwargs):

         Other Parameters
         ----------------
-        %(_legend_kw_doc)s
+        %(_legend_kw_axes)s

         See Also
         --------
diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py
index b006ca467be1..30e117183176 100644
--- a/lib/matplotlib/figure.py
+++ b/lib/matplotlib/figure.py
@@ -1085,7 +1085,8 @@ def legend(self, *args, **kwargs):

         Other Parameters
         ----------------
-        %(_legend_kw_doc)s
+        %(_legend_kw_figure)s
+

         See Also
         --------
diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py
index 66182c68cb60..e9e0651066b3 100644
--- a/lib/matplotlib/legend.py
+++ b/lib/matplotlib/legend.py
@@ -94,51 +94,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
         self.legend.set_bbox_to_anchor(loc_in_bbox)

-_docstring.interpd.update(_legend_kw_doc="""
-loc : str or pair of floats, default: :rc:`legend.loc` ('best' for axes, \
-'upper right' for figures)
-    The location of the legend.
-
-    The strings
-    ``'upper left', 'upper right', 'lower left', 'lower right'``
-    place the legend at the corresponding corner of the axes/figure.
-
-    The strings
-    ``'upper center', 'lower center', 'center left', 'center right'``
-    place the legend at the center of the corresponding edge of the
-    axes/figure.
-
-    The string ``'center'`` places the legend at the center of the axes/figure.
-
-    The string ``'best'`` places the legend at the location, among the nine
-    locations defined so far, with the minimum overlap with other drawn
-    artists.  This option can be quite slow for plots with large amounts of
-    data; your plotting speed may benefit from providing a specific location.
-
-    The location can also be a 2-tuple giving the coordinates of the lower-left
-    corner of the legend in axes coordinates (in which case *bbox_to_anchor*
-    will be ignored).
-
-    For back-compatibility, ``'center right'`` (but no other location) can also
-    be spelled ``'right'``, and each "string" locations can also be given as a
-    numeric value:
-
-        ===============   =============
-        Location String   Location Code
-        ===============   =============
-        'best'            0
-        'upper right'     1
-        'upper left'      2
-        'lower left'      3
-        'lower right'     4
-        'right'           5
-        'center left'     6
-        'center right'    7
-        'lower center'    8
-        'upper center'    9
-        'center'          10
-        ===============   =============
-
+_legend_kw_doc_base = """
 bbox_to_anchor : `.BboxBase`, 2-tuple, or 4-tuple of floats
     Box that is used to position the legend in conjunction with *loc*.
     Defaults to `axes.bbox` (if called as a method to `.Axes.legend`) or
@@ -295,7 +251,79 @@ def _update_bbox_to_anchor(self, loc_in_canvas):

 draggable : bool, default: False
     Whether the legend can be dragged with the mouse.
-""")
+"""
+
+_loc_doc_base = """
+loc : str or pair of floats, {0}
+    The location of the legend.
+
+    The strings
+    ``'upper left', 'upper right', 'lower left', 'lower right'``
+    place the legend at the corresponding corner of the axes/figure.
+
+    The strings
+    ``'upper center', 'lower center', 'center left', 'center right'``
+    place the legend at the center of the corresponding edge of the
+    axes/figure.
+
+    The string ``'center'`` places the legend at the center of the axes/figure.
+
+    The string ``'best'`` places the legend at the location, among the nine
+    locations defined so far, with the minimum overlap with other drawn
+    artists.  This option can be quite slow for plots with large amounts of
+    data; your plotting speed may benefit from providing a specific location.
+
+    The location can also be a 2-tuple giving the coordinates of the lower-left
+    corner of the legend in axes coordinates (in which case *bbox_to_anchor*
+    will be ignored).
+
+    For back-compatibility, ``'center right'`` (but no other location) can also
+    be spelled ``'right'``, and each "string" locations can also be given as a
+    numeric value:
+
+        ===============   =============
+        Location String   Location Code
+        ===============   =============
+        'best'            0
+        'upper right'     1
+        'upper left'      2
+        'lower left'      3
+        'lower right'     4
+        'right'           5
+        'center left'     6
+        'center right'    7
+        'lower center'    8
+        'upper center'    9
+        'center'          10
+        ===============   =============
+    {1}"""
+
+_legend_kw_axes_st = (_loc_doc_base.format("default: :rc:`legend.loc`", '') +
+                      _legend_kw_doc_base)
+_docstring.interpd.update(_legend_kw_axes=_legend_kw_axes_st)
+
+_outside_doc = """
+    If a figure is using the constrained layout manager, the string codes
+    of the *loc* keyword argument can get better layout behaviour using the
+    prefix 'outside'. There is ambiguity at the corners, so 'outside
+    upper right' will make space for the legend above the rest of the
+    axes in the layout, and 'outside right upper' will make space on the
+    right side of the layout.  In addition to the values of *loc*
+    listed above, we have 'outside right upper', 'outside right lower',
+    'outside left upper', and 'outside left lower'.  See
+    :doc:`/tutorials/intermediate/legend_guide` for more details.
+"""
+
+_legend_kw_figure_st = (_loc_doc_base.format("default: 'upper right'",
+                                             _outside_doc) +
+                        _legend_kw_doc_base)
+_docstring.interpd.update(_legend_kw_figure=_legend_kw_figure_st)
+
+_legend_kw_both_st = (
+    _loc_doc_base.format("default: 'best' for axes, 'upper right' for figures",
+                         _outside_doc) +
+    _legend_kw_doc_base)
+_docstring.interpd.update(_legend_kw_doc=_legend_kw_both_st)

 class Legend(Artist):
@@ -482,13 +510,37 @@ def val_or_rc(val, rc_name):
             )
         self.parent = parent

+        loc0 = loc
         self._loc_used_default = loc is None
         if loc is None:
             loc = mpl.rcParams["legend.loc"]
             if not self.isaxes and loc in [0, 'best']:
                 loc = 'upper right'
+
+        # handle outside legends:
+        self._outside_loc = None
         if isinstance(loc, str):
+            if loc.split()[0] == 'outside':
+                # strip outside:
+                loc = loc.split('outside ')[1]
+                # strip "center" at the beginning
+                self._outside_loc = loc.replace('center ', '')
+                # strip first
+                self._outside_loc = self._outside_loc.split()[0]
+                locs = loc.split()
+                if len(locs) > 1 and locs[0] in ('right', 'left'):
+                    # locs doesn't accept "left upper", etc, so swap
+                    if locs[0] != 'center':
+                        locs = locs[::-1]
+                    loc = locs[0] + ' ' + locs[1]
+            # check that loc is in acceptable strings
             loc = _api.check_getitem(self.codes, loc=loc)
+
+        if self.isaxes and self._outside_loc:
+            raise ValueError(
+                f"'outside' option for loc='{loc0}' keyword argument only "
+                "works for figure legends")
+
         if not self.isaxes and loc == 0:
             raise ValueError(
                 "Automatic legend placement (loc='best') not implemented for "
diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py
index 596d6df93b55..338fca72fbf1 100644
--- a/tutorials/intermediate/legend_guide.py
+++ b/tutorials/intermediate/legend_guide.py
@@ -135,7 +135,54 @@
 ax_dict['bottom'].legend(bbox_to_anchor=(1.05, 1),
                          loc='upper left', borderaxespad=0.)

-plt.show()
+##############################################################################
+# Figure legends
+# --------------
+#
+# Sometimes it makes more sense to place a legend relative to the (sub)figure
+# rather than individual Axes.  By using ``constrained_layout`` and
+# specifying "outside" at the beginning of the *loc* keyword argument,
+# the legend is drawn outside the Axes on the (sub)figure.
+
+fig, axs = plt.subplot_mosaic([['left', 'right']], layout='constrained')
+
+axs['left'].plot([1, 2, 3], label="test1")
+axs['left'].plot([3, 2, 1], label="test2")
+
+axs['right'].plot([1, 2, 3], 'C2', label="test3")
+axs['right'].plot([3, 2, 1], 'C3', label="test4")
+# Place a legend to the right of this smaller subplot.
+fig.legend(loc='outside upper right')
+
+##############################################################################
+# This accepts a slightly different grammar than the normal *loc* keyword,
+# where "outside right upper" is different from "outside upper right".
+#
+ucl = ['upper', 'center', 'lower']
+lcr = ['left', 'center', 'right']
+fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7')
+
+ax.plot([1, 2], [1, 2], label='TEST')
+# Place a legend to the right of this smaller subplot.
+for loc in [
+        'outside upper left',
+        'outside upper center',
+        'outside upper right',
+        'outside lower left',
+        'outside lower center',
+        'outside lower right']:
+    fig.legend(loc=loc, title=loc)
+
+fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7')
+ax.plot([1, 2], [1, 2], label='test')
+
+for loc in [
+        'outside left upper',
+        'outside right upper',
+        'outside left lower',
+        'outside right lower']:
+    fig.legend(loc=loc, title=loc)
+

 ###############################################################################
 # Multiple legends on the same Axes

diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 56794a3428b8..a8d7fd107d8b 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -4,6 +4,7 @@
 import warnings

 import numpy as np
+from numpy.testing import assert_allclose
 import pytest

 from matplotlib.testing.decorators import check_figures_equal, image_comparison
@@ -18,7 +19,6 @@
 import matplotlib.legend as mlegend
 from matplotlib import rc_context
 from matplotlib.font_manager import FontProperties
-from numpy.testing import assert_allclose

 def test_legend_ordereddict():
@@ -486,6 +486,47 @@ def test_warn_args_kwargs(self):
             "be discarded.")

+def test_figure_legend_outside():
+    todos = ['upper ' + pos for pos in ['left', 'center', 'right']]
+    todos += ['lower ' + pos for pos in ['left', 'center', 'right']]
+    todos += ['left ' + pos for pos in ['lower', 'center', 'upper']]
+    todos += ['right ' + pos for pos in ['lower', 'center', 'upper']]
+
+    upperext = [20.347556,  27.722556, 790.583, 545.499]
+    lowerext = [20.347556,  71.056556, 790.583, 588.833]
+    leftext = [151.681556, 27.722556, 790.583, 588.833]
+    rightext = [20.347556,  27.722556, 659.249, 588.833]
+    axbb = [upperext, upperext, upperext,
+            lowerext, lowerext, lowerext,
+            leftext, leftext, leftext,
+            rightext, rightext, rightext]
+
+    legbb = [[10., 555., 133., 590.],     # upper left
+             [338.5, 555., 461.5, 590.],  # upper center
+             [667, 555., 790.,  590.],    # upper right
+             [10., 10., 133.,  45.],      # lower left
+             [338.5, 10., 461.5,  45.],   # lower center
+             [667., 10., 790.,  45.],     # lower right
+             [10., 10., 133., 45.],       # left lower
+             [10., 282.5, 133., 317.5],   # left center
+             [10., 555., 133., 590.],     # left upper
+             [667, 10., 790., 45.],       # right lower
+             [667., 282.5, 790., 317.5],  # right center
+             [667., 555., 790., 590.]]    # right upper
+
+    for nn, todo in enumerate(todos):
+        print(todo)
+        fig, axs = plt.subplots(constrained_layout=True, dpi=100)
+        axs.plot(range(10), label='Boo1')
+        leg = fig.legend(loc='outside ' + todo)
+        fig.draw_without_rendering()
+
+        assert_allclose(axs.get_window_extent().extents,
+                        axbb[nn])
+        assert_allclose(leg.get_window_extent().extents,
+                        legbb[nn])
+
+
 @image_comparison(['legend_stackplot.png'])
 def test_legend_stackplot():
     """Test legend for PolyCollection using stackplot."""

diff --git a/doc/users/next_whats_new/legend-figure-outside.rst b/doc/users/next_whats_new/legend-figure-outside.rst
new file mode 100644
index 0000000000..a78725b4e2
--- /dev/null
+++ b/doc/users/next_whats_new/legend-figure-outside.rst
@@ -0,0 +1,8 @@
+Figure legends can be placed outside figures using constrained_layout
+---------------------------------------------------------------------
+Constrained layout will make space for Figure legends if they are specified
+by a *loc* keyword argument that starts with the string "outside". The
+codes are unique from axes codes, in that "outside upper right" will
+make room at the top of the figure for the legend, whereas
+"outside right upper" will make room on the right-hand side of the figure.
+See :doc:`/tutorials/intermediate/legend_guide` for details.


diff --git a/doc/users/next_whats_new/bar_plot_labels.rst b/doc/users/next_whats_new/bar_plot_labels.rst
new file mode 100644
index 000000000000..6da57a317f59
--- /dev/null
+++ b/doc/users/next_whats_new/bar_plot_labels.rst
@@ -0,0 +1,16 @@
+Easier labelling of bars in bar plot
+------------------------------------
+
+The ``label`` argument of `~matplotlib.axes.Axes.bar` can now
+be passed a list of labels for the bars.
+
+.. code-block:: python
+
+    import matplotlib.pyplot as plt
+
+    x = ["a", "b", "c"]
+    y = [10, 20, 15]
+
+    fig, ax = plt.subplots()
+    bar_container = ax.barh(x, y, label=x)
+    [bar.get_label() for bar in bar_container]
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index 25b3cf36651f..3159a55b7e83 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -2256,6 +2256,14 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center",
             The tick labels of the bars.
             Default: None (Use default numeric labels.)

+        label : str or list of str, optional
+            A single label is attached to the resulting `.BarContainer` as a
+            label for the whole dataset.
+            If a list is provided, it must be the same length as *x* and
+            labels the individual bars. Repeated labels are not de-duplicated
+            and will cause repeated label entries, so this is best used when
+            bars also differ in style (e.g., by passing a list to *color*.)
+
         xerr, yerr : float or array-like of shape(N,) or shape(2, N), optional
             If not *None*, add horizontal / vertical errorbars to the bar tips.
             The values are +/- sizes relative to the data:
@@ -2381,6 +2389,16 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center",
             tick_label_axis = self.yaxis
             tick_label_position = y

+        if not isinstance(label, str) and np.iterable(label):
+            bar_container_label = '_nolegend_'
+            patch_labels = label
+        else:
+            bar_container_label = label
+            patch_labels = ['_nolegend_'] * len(x)
+        if len(patch_labels) != len(x):
+            raise ValueError(f'number of labels ({len(patch_labels)}) '
+                             f'does not match number of bars ({len(x)}).')
+
         linewidth = itertools.cycle(np.atleast_1d(linewidth))
         hatch = itertools.cycle(np.atleast_1d(hatch))
         color = itertools.chain(itertools.cycle(mcolors.to_rgba_array(color)),
@@ -2420,14 +2438,14 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center",

         patches = []
         args = zip(left, bottom, width, height, color, edgecolor, linewidth,
-                   hatch)
-        for l, b, w, h, c, e, lw, htch in args:
+                   hatch, patch_labels)
+        for l, b, w, h, c, e, lw, htch, lbl in args:
             r = mpatches.Rectangle(
                 xy=(l, b), width=w, height=h,
                 facecolor=c,
                 edgecolor=e,
                 linewidth=lw,
-                label='_nolegend_',
+                label=lbl,
                 hatch=htch,
                 )
             r._internal_update(kwargs)
@@ -2466,7 +2484,8 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center",
             datavalues = width

         bar_container = BarContainer(patches, errorbar, datavalues=datavalues,
-                                     orientation=orientation, label=label)
+                                     orientation=orientation,
+                                     label=bar_container_label)
         self.add_container(bar_container)

         if tick_labels is not None:

diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 51fbea3cfaaa..3a1ba341b2e0 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -1886,6 +1886,35 @@ def test_bar_hatches(fig_test, fig_ref):
     ax_test.bar(x, y, hatch=hatches)

+@pytest.mark.parametrize(
+    ("x", "width", "label", "expected_labels", "container_label"),
+    [
+        ("x", 1, "x", ["_nolegend_"], "x"),
+        (["a", "b", "c"], [10, 20, 15], ["A", "B", "C"],
+         ["A", "B", "C"], "_nolegend_"),
+        (["a", "b", "c"], [10, 20, 15], ["R", "Y", "_nolegend_"],
+         ["R", "Y", "_nolegend_"], "_nolegend_"),
+        (["a", "b", "c"], [10, 20, 15], "bars",
+         ["_nolegend_", "_nolegend_", "_nolegend_"], "bars"),
+    ]
+)
+def test_bar_labels(x, width, label, expected_labels, container_label):
+    _, ax = plt.subplots()
+    bar_container = ax.bar(x, width, label=label)
+    bar_labels = [bar.get_label() for bar in bar_container]
+    assert expected_labels == bar_labels
+    assert bar_container.get_label() == container_label
+
+
+def test_bar_labels_length():
+    _, ax = plt.subplots()
+    with pytest.raises(ValueError):
+        ax.bar(["x", "y"], [1, 2], label=["X", "Y", "Z"])
+    _, ax = plt.subplots()
+    with pytest.raises(ValueError):
+        ax.bar(["x", "y"], [1, 2], label=["X"])
+
+
 def test_pandas_minimal_plot(pd):
     # smoke test that series and index objects do not warn
     for x in [pd.Series([1, 2], dtype="float64"),

diff --git a/doc/users/next_whats_new/bar_plot_labels.rst b/doc/users/next_whats_new/bar_plot_labels.rst
new file mode 100644
index 0000000000..6da57a317f
--- /dev/null
+++ b/doc/users/next_whats_new/bar_plot_labels.rst
@@ -0,0 +1,16 @@
+Easier labelling of bars in bar plot
+------------------------------------
+
+The ``label`` argument of `~matplotlib.axes.Axes.bar` can now
+be passed a list of labels for the bars.
+
+.. code-block:: python
+
+    import matplotlib.pyplot as plt
+
+    x = ["a", "b", "c"]
+    y = [10, 20, 15]
+
+    fig, ax = plt.subplots()
+    bar_container = ax.barh(x, y, label=x)
+    [bar.get_label() for bar in bar_container]


diff --git a/doc/users/next_whats_new/pie_hatch.rst b/doc/users/next_whats_new/pie_hatch.rst
new file mode 100644
index 000000000000..f54def10e954
--- /dev/null
+++ b/doc/users/next_whats_new/pie_hatch.rst
@@ -0,0 +1,18 @@
+``hatch`` parameter for pie
+-------------------------------------------
+
+`~matplotlib.axes.Axes.pie` now accepts a *hatch* keyword that takes as input
+a hatch or list of hatches:
+
+.. plot::
+    :include-source: true
+    :alt: Two pie charts, identified as ax1 and ax2, both have a small blue slice, a medium orange slice, and a large green slice. ax1 has a dot hatching on the small slice, a small open circle hatching on the medium slice, and a large open circle hatching on the large slice. ax2 has the same large open circle with a dot hatch on every slice.
+
+    fig, (ax1, ax2) = plt.subplots(ncols=2)
+    x = [10, 30, 60]
+
+    ax1.pie(x, hatch=['.', 'o', 'O'])
+    ax2.pie(x, hatch='.O')
+
+    ax1.set_title("hatch=['.', 'o', 'O']")
+    ax2.set_title("hatch='.O'")
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index 58d95912665b..c46c1b467fc1 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -3083,7 +3083,7 @@ def pie(self, x, explode=None, labels=None, colors=None,
             autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1,
             startangle=0, radius=1, counterclock=True,
             wedgeprops=None, textprops=None, center=(0, 0),
-            frame=False, rotatelabels=False, *, normalize=True):
+            frame=False, rotatelabels=False, *, normalize=True, hatch=None):
         """
         Plot a pie chart.

@@ -3109,29 +3109,34 @@ def pie(self, x, explode=None, labels=None, colors=None,
             A sequence of colors through which the pie chart will cycle.  If
             *None*, will use the colors in the currently active cycle.

+        hatch : str or list, default: None
+            Hatching pattern applied to all pie wedges or sequence of patterns
+            through which the chart will cycle. For a list of valid patterns,
+            see :doc:`/gallery/shapes_and_collections/hatch_style_reference`.
+
+            .. versionadded:: 3.7
+
         autopct : None or str or callable, default: None
-            If not *None*, is a string or function used to label the wedges
-            with their numeric value.  The label will be placed inside the
-            wedge.  If it is a format string, the label will be ``fmt % pct``.
-            If it is a function, it will be called.
+            If not *None*, *autopct* is a string or function used to label the
+            wedges with their numeric value. The label will be placed inside
+            the wedge. If *autopct* is a format string, the label will be
+            ``fmt % pct``. If *autopct* is a function, then it will be called.

         pctdistance : float, default: 0.6
-            The ratio between the center of each pie slice and the start of
-            the text generated by *autopct*.  Ignored if *autopct* is *None*.
+            The relative distance along the radius at which the the text
+            generated by *autopct* is drawn. To draw the text outside the pie,
+            set *pctdistance* > 1. This parameter is ignored if *autopct* is
+            ``None``.
+
+        labeldistance : float or None, default: 1.1
+            The relative distance along the radius at which the labels are
+            drawn. To draw the labels inside the pie, set  *labeldistance* < 1.
+            If set to ``None``, labels are not drawn but are still stored for
+            use in `.legend`.

         shadow : bool, default: False
             Draw a shadow beneath the pie.

-        normalize : bool, default: True
-            When *True*, always make a full pie by normalizing x so that
-            ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1``
-            and raises a `ValueError` for ``sum(x) > 1``.
-
-        labeldistance : float or None, default: 1.1
-            The radial distance at which the pie labels are drawn.
-            If set to ``None``, label are not drawn, but are stored for use in
-            ``legend()``
-
         startangle : float, default: 0 degrees
             The angle by which the start of the pie is rotated,
             counterclockwise from the x-axis.
@@ -3143,11 +3148,11 @@ def pie(self, x, explode=None, labels=None, colors=None,
             Specify fractions direction, clockwise or counterclockwise.

         wedgeprops : dict, default: None
-            Dict of arguments passed to the wedge objects making the pie.
-            For example, you can pass in ``wedgeprops = {'linewidth': 3}``
-            to set the width of the wedge border lines equal to 3.
-            For more details, look at the doc/arguments of the wedge object.
-            By default, ``clip_on=False``.
+            Dict of arguments passed to each `.patches.Wedge` of the pie.
+            For example, ``wedgeprops = {'linewidth': 3}`` sets the width of
+            the wedge border lines equal to 3. By default, ``clip_on=False``.
+            When there is a conflict between these properties and other
+            keywords, properties passed to *wedgeprops* take precedence.

         textprops : dict, default: None
             Dict of arguments to pass to the text objects.
@@ -3161,6 +3166,11 @@ def pie(self, x, explode=None, labels=None, colors=None,
         rotatelabels : bool, default: False
             Rotate each label to the angle of the corresponding slice if true.

+        normalize : bool, default: True
+            When *True*, always make a full pie by normalizing x so that
+            ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1``
+            and raises a `ValueError` for ``sum(x) > 1``.
+
         data : indexable object, optional
             DATA_PARAMETER_PLACEHOLDER

@@ -3215,6 +3225,8 @@ def pie(self, x, explode=None, labels=None, colors=None,
             def get_next_color():
                 return next(color_cycle)

+        hatch_cycle = itertools.cycle(np.atleast_1d(hatch))
+
         _api.check_isinstance(Number, radius=radius, startangle=startangle)
         if radius <= 0:
             raise ValueError(f'radius must be a positive number, not {radius}')
@@ -3241,6 +3253,7 @@ def get_next_color():
             w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2),
                                360. * max(theta1, theta2),
                                facecolor=get_next_color(),
+                               hatch=next(hatch_cycle),
                                clip_on=False,
                                label=label)
             w.set(**wedgeprops)
diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py
index 4e9734e184dc..e62d4f0b7481 100644
--- a/lib/matplotlib/pyplot.py
+++ b/lib/matplotlib/pyplot.py
@@ -2784,7 +2784,7 @@ def pie(
         pctdistance=0.6, shadow=False, labeldistance=1.1,
         startangle=0, radius=1, counterclock=True, wedgeprops=None,
         textprops=None, center=(0, 0), frame=False,
-        rotatelabels=False, *, normalize=True, data=None):
+        rotatelabels=False, *, normalize=True, hatch=None, data=None):
     return gca().pie(
         x, explode=explode, labels=labels, colors=colors,
         autopct=autopct, pctdistance=pctdistance, shadow=shadow,
@@ -2792,7 +2792,7 @@ def pie(
         radius=radius, counterclock=counterclock,
         wedgeprops=wedgeprops, textprops=textprops, center=center,
         frame=frame, rotatelabels=rotatelabels, normalize=normalize,
-        **({"data": data} if data is not None else {}))
+        hatch=hatch, **({"data": data} if data is not None else {}))

 # Autogenerated by boilerplate.py.  Do not edit as changes will be lost.

diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 3b471d4e0d92..7acd1040c5d6 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -5744,6 +5744,24 @@ def test_normalize_kwarg_pie():
     assert abs(t2[0][-1].theta2 - 360.) > 1e-3

+@check_figures_equal()
+def test_pie_hatch_single(fig_test, fig_ref):
+    x = [0.3, 0.3, 0.1]
+    hatch = '+'
+    fig_test.subplots().pie(x, hatch=hatch)
+    wedges, _ = fig_ref.subplots().pie(x)
+    [w.set_hatch(hatch) for w in wedges]
+
+
+@check_figures_equal()
+def test_pie_hatch_multi(fig_test, fig_ref):
+    x = [0.3, 0.3, 0.1]
+    hatch = ['/', '+', '.']
+    fig_test.subplots().pie(x, hatch=hatch)
+    wedges, _ = fig_ref.subplots().pie(x)
+    [w.set_hatch(hp) for w, hp in zip(wedges, hatch)]
+
+
 @image_comparison(['set_get_ticklabels.png'])
 def test_set_get_ticklabels():
     # test issue 2246

diff --git a/doc/users/next_whats_new/pie_hatch.rst b/doc/users/next_whats_new/pie_hatch.rst
new file mode 100644
index 0000000000..f54def10e9
--- /dev/null
+++ b/doc/users/next_whats_new/pie_hatch.rst
@@ -0,0 +1,18 @@
+``hatch`` parameter for pie
+-------------------------------------------
+
+`~matplotlib.axes.Axes.pie` now accepts a *hatch* keyword that takes as input
+a hatch or list of hatches:
+
+.. plot::
+    :include-source: true
+    :alt: Two pie charts, identified as ax1 and ax2, both have a small blue slice, a medium orange slice, and a large green slice. ax1 has a dot hatching on the small slice, a small open circle hatching on the medium slice, and a large open circle hatching on the large slice. ax2 has the same large open circle with a dot hatch on every slice.
+
+    fig, (ax1, ax2) = plt.subplots(ncols=2)
+    x = [10, 30, 60]
+
+    ax1.pie(x, hatch=['.', 'o', 'O'])
+    ax2.pie(x, hatch='.O')
+
+    ax1.set_title("hatch=['.', 'o', 'O']")
+    ax2.set_title("hatch='.O'")