diff --git a/.travis.yml b/.travis.yml
index 92f365a05e..185da1bfe9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -49,7 +49,7 @@ before_script:
 script:
   - make lint
   - if [ $DOCTESTS == 'true' ];
-    then make coverage;
+    then make test;
     else make unittests;
     fi

diff --git a/Makefile b/Makefile
index a3a5c4d1dd..1e15125f36 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,10 @@
 export SHELL := /bin/bash

 test:
-	pytest -n auto --doctest-modules seaborn
+	pytest -n auto --doctest-modules --cov=seaborn --cov-config=.coveragerc seaborn

 unittests:
-	pytest -n auto seaborn
-
-coverage:
-	pytest -n auto --doctest-modules --cov=seaborn --cov-config=.coveragerc seaborn
+	pytest -n auto --cov=seaborn --cov-config=.coveragerc seaborn

 lint:
-	flake8 seaborn
\ No newline at end of file
+	flake8 seaborn
diff --git a/doc/api.rst b/doc/api.rst
index 563559c556..86b3478431 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -43,11 +43,12 @@ Distribution plots
 .. autosummary::
     :toctree: generated/

-    distplot
+    displot
     histplot
-    ecdfplot
     kdeplot
+    ecdfplot
     rugplot
+    distplot

 .. _regression_api:

diff --git a/doc/docstrings/displot.ipynb b/doc/docstrings/displot.ipynb
new file mode 100644
index 0000000000..4bcd1b7d83
--- /dev/null
+++ b/doc/docstrings/displot.ipynb
@@ -0,0 +1,234 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default plot kind is a histogram:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import seaborn as sns; sns.set(style=\"ticks\")\n",
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.displot(data=penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Use the ``kind`` parameter to select a different representation:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "There are three main plot kinds; in addition to histograms and kernel density estimates (KDEs), you can also draw empirical cumulative distribution functions (ECDFs):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kind=\"ecdf\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "While in histogram mode, it is also possible to add a KDE curve:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kde=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To draw a bivariate plot, assign both ``x`` and ``y``:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Currently, bivariate plots are available only for histograms and KDEs:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For each kind of plot, you can also show individual observations with a marginal \"rug\":"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\", kind=\"kde\", rug=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Each kind of plot can be drawn separately for subsets of data using ``hue`` mapping:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Additional keyword arguments are passed to the appropriate underlying plotting function, allowing for further customization:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The figure is constructed using a :class:`FacetGrid`, meaning that you can also show subsets on distinct subplots, or \"facets\":"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", col=\"sex\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Because the figure is drawn with a :class:`FacetGrid`, you control its size and shape with the ``height`` and ``aspect`` parameters:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(\n",
+    "    data=penguins, y=\"flipper_length_mm\", hue=\"sex\", col=\"species\",\n",
+    "    kind=\"ecdf\", height=4, aspect=.7,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The function returns the :class:`FacetGrid` object with the plot, and you can use the methods on this object to customize it further:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    data=penguins, y=\"flipper_length_mm\", hue=\"sex\", col=\"species\",\n",
+    "    kind=\"kde\", height=4, aspect=.7,\n",
+    ")\n",
+    "g.set_axis_labels(\"Density (a.u.)\", \"Flipper length (mm)\")\n",
+    "g.set_titles(\"{col_name} penguins\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-py38-latest",
+   "language": "python",
+   "name": "seaborn-py38-latest"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/doc/docstrings/histplot.ipynb b/doc/docstrings/histplot.ipynb
index 8e8fc5f7e1..66bf0fe853 100644
--- a/doc/docstrings/histplot.ipynb
+++ b/doc/docstrings/histplot.ipynb
@@ -95,8 +95,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "iris = sns.load_dataset(\"iris\")\n",
-    "sns.histplot(data=iris)"
+    "sns.histplot(data=penguins)"
    ]
   },
   {
diff --git a/doc/releases/v0.11.0.txt b/doc/releases/v0.11.0.txt
index 3e5449d837..631b497fc6 100644
--- a/doc/releases/v0.11.0.txt
+++ b/doc/releases/v0.11.0.txt
@@ -2,14 +2,21 @@
 v0.11.0 (Unreleased)
 --------------------

-- TODO stub for explaining improvements to variable specificiation. Make this good! (:issue:`2052`).
+- TODO stub for explaining improvements to variable specification. Make this good! (:issue:`2052`).

 - TODO stub for enforced keyword-only arguments for most parameters of most functions and classes (:issue:`2052,2081`).

 Modernization of distribution functions
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

-First, two new functions, :func:`histplot` and :func:`ecdfplot` has been added (:pr:`2125`).
+The distribution module has been completely overhauled, modernizing the API and introducing several new functions and features within existing functions. Some new features are explained here; the :ref:`tutorial documentation <distribution_tutorial>` has also been rewritten and serves as a good introduction to the functions.
+
+New functions
+^^^^^^^^^^^^^
+
+First, three new functions, :func:`displot`, :func:`histplot` and :func:`ecdfplot` has been added (:pr:`2125`, :pr:`2141`, and :pr:`2157`).
+
+:func:`displot` is a figure-level interface to the various distribution plot functions (analogous to :func:`relplot` or :func:`catplot`). It can draw univariate or bivariate histograms, density curves, ECDFs, and rug plots on a :class:`FacetGrid`.

 :func:`histplot` draws univariate or bivariate histograms with a number of features, including:

@@ -21,7 +28,12 @@ First, two new functions, :func:`histplot` and :func:`ecdfplot` has been added (

 :func:`ecdfplot` draws univariate empirical cumulative distribution functions, using a similar interface.

-Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled (see :pr:`2060,2104`). Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+Changes to existing functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled (see :pr:`2060,2104`).
+
+Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.

 The overhauled functions now share a common API with the rest of seaborn, they can show conditional distributions by mapping a third variable with a ``hue`` semantic, and have been improved in numerous other ways. The github pull request (:pr:`2104`) has a longer explanation of the changes and the motivation behind them.

@@ -39,20 +51,24 @@ Other new features include:
 - log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
 - "bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)

+Deprecations
+^^^^^^^^^^^^
+
+Finally, the :func:`distplot` function is now formally deprecated. Its features have been subsumed by :func:`displot` and :func:`histplot`. Some effort was made to gradually transition :func:`distplot` by adding the features in :func:`displot` and handling backwards compatibility, but this proved to be too difficult. The similarity in the names will likely cause some confusion during the transition, which is regrettable.

 Other
 ~~~~~

-- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type (:pr:`2084`). Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.
+- Changed how functions that use different representations for numeric and categorical data handle vectors with an ``object`` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data (:pr:`2084`).

-- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default (:pr:`2075`). Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_.
+- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Previously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_ (:pr:`2075`).

-- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. For more information see :pr:`2086`.
+- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. ``k_depth="tukey"`` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. ``k_depth=6``) or just plot boxes that will cover most of the data points (with ``k_depth="full"``). Added a new parameter ``trust_alpha`` to control the number of boxes when ``k_depth="trustworthy"``. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. (:pr:`2086`).

 - Added a ``tight_layout`` method to :class:`FacetGrid` and :class:`PairGrid`, which runs the :func:`matplotlib.pyplot.tight_layout` algorithm without interference from the external legend (:pr:`2073`).

-- Changed how :func:`scatterplot` sets the default linewidth for the edges of the scatter points (:pr:`2078`). New behaviot is to scale with the point sizes themselves (on a plot-wise, not point-wise basis). This change also slightly reduces the default width when point sizes are not varied. Set ``linewidth=0.75`` to repoduce the previous behavior.
+- Changed how :func:`scatterplot` sets the default linewidth for the edges of the scatter points. New behavior is to scale with the point sizes themselves (on a plot-wise, not point-wise basis). This change also slightly reduces the default width when point sizes are not varied. Set ``linewidth=0.75`` to reproduce the previous behavior. (:pr:`2708`).

 - Adapted to a change in matplotlib that prevented passing vectors of literal values to ``c`` and ``s`` in :func:`scatterplot` (:pr:`2079`).

diff --git a/doc/tutorial.rst b/doc/tutorial.rst
index 72c2ee2085..630c4fe7c7 100644
--- a/doc/tutorial.rst
+++ b/doc/tutorial.rst
@@ -52,7 +52,7 @@ Official seaborn tutorial

-
+

diff --git a/doc/tutorial/distributions.ipynb b/doc/tutorial/distributions.ipynb
index e2aeb60969..be1d986d53 100644
--- a/doc/tutorial/distributions.ipynb
+++ b/doc/tutorial/distributions.ipynb
@@ -13,19 +13,44 @@
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Visualizing the distribution of a dataset\n",
-    "=========================================\n",
+    "Visualizing distributions of data\n",
+    "==================================\n",
     "\n",
     ".. raw:: html\n",
     "\n",
-    "
\n"
+    "
\n",
+    "\n",
+    "The first step of any effort to analyze or model data should be to understand how the variables are distributed. Techniques for distribution visualization can provide quick answers to many important questions. What range do the observations cover? What is their central tendency? Are they heavily skewed in one direction? Is there evidence for bimodality? Are there significant outliers? Do the answers to these questions vary across subsets defined by other variables?\n",
+    "\n",
+    "The :ref:`distributions module <distributions_api>` contains several functions designed to answer questions such as these. The axes-level functions are :func:`histplot`, :func:`kdeplot`, :func:`ecdfplot`, and :func:`rugplot`. They are grouped together within the figure-level :func:`displot`, :func`jointplot`, and :func:`pairplot` functions.\n",
+    "\n",
+    "There are several different approaches to visualizing a distribution, and each has its relative advantages and drawbacks. It is important to understand theses factors so that you can choose the best approach for your particular aim."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": [
+     "hide"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "%matplotlib inline\n",
+    "import seaborn as sns; sns.set()"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "When dealing with a set of data, often the first thing you'll want to do is get a sense for how the variables are distributed. This chapter of the tutorial will give a brief introduction to some of the tools in seaborn for examining univariate and bivariate distributions. You may also want to look at the :ref:`categorical plots <categorical_tutorial>` chapter for examples of functions that make it easy to compare the distribution of a variable across levels of other variables."
+    ".. _tutorial_hist:\n",
+    "\n",
+    "Plotting univariate histograms\n",
+    "------------------------------\n",
+    "\n",
+    "Perhaps the most common approach to visualizing a distribution is the *histogram*. This is the default approach in :func:`displot`, which uses the same underlying code as :func:`histplot`. A histogram is a bar plot where the axis representing the data variable is divided into a set of discrete bins and the count of observations falling within each bin is shown using the height of the corresponding bar:"
    ]
   },
   {
@@ -34,11 +59,20 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import numpy as np\n",
-    "import pandas as pd\n",
-    "import seaborn as sns\n",
-    "import matplotlib.pyplot as plt\n",
-    "from scipy import stats"
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.displot(penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "This plot immediately affords a few insights about the ``flipper_length_mm`` variable. For instance, we can see that the most common flipper length is about 195 mm, but the distribution appears bimodal, so this one number does not represent the data well.\n",
+    "\n",
+    "Choosing the bin size\n",
+    "^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The size of the bins is an important parameter, and using the wrong bin size can mislead by obscuring important features of the data or by creating apparent features out of random variability. By default, :func:`displot`/:func:`histplot` choose a default bin size based on the variance of the data and the number of observations. But you should not be over-reliant on such automatic approaches, because they depend on particular assumptions about the structure of your data. It is always advisable to check that your impressions of the distribution are consistent across different bin sizes. To choose the size directly, set the `binwidth` parameter:"
    ]
   },
   {
@@ -47,31 +81,130 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.set(color_codes=True)"
+    "sns.displot(penguins, x=\"flipper_length_mm\", binwidth=3)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In other circumstances, it may make more sense to specify the *number* of bins, rather than their size:"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "tags": [
-     "hide"
-    ]
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
-    "%matplotlib inline\n",
-    "np.random.seed(sum(map(ord, \"distributions\")))"
+    "sns.displot(penguins, x=\"flipper_length_mm\", bins=20)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "One example of a situation where defaults fail is when the variable takes a relatively small number of integer values. In that case, the default bin width may be too small, creating awkward gaps in the distribution:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tips = sns.load_dataset(\"tips\")\n",
+    "sns.displot(tips, x=\"size\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "One approach would be to specify the precise bin breaks by passing an array to ``bins``:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"size\", bins=[1, 2, 3, 4, 5, 6, 7])"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "This can also be accomplished by setting ``discrete=True``, which chooses bin breaks that represent the unique values in a dataset with bars that are centered on their corresponding value."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"size\", discrete=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Plotting univariate distributions\n",
-    "---------------------------------\n",
+    "It's also possible to visualize the distribution of a categorical variable using the logic of a histogram. Discrete bins are automatically set for categorical variables, but it may also be helpful to \"shrink\" the bars slightly to emphasize the categorical nature of the axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"day\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Conditioning on other variables\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "The most convenient way to take a quick look at a univariate distribution in seaborn is the :func:`distplot` function. By default, this will draw a `histogram <https://en.wikipedia.org/wiki/Histogram>`_ and fit a `kernel density estimate <https://en.wikipedia.org/wiki/Kernel_density_estimation>`_ (KDE). "
+    "Once you understand the distribution of a variable, the next step is often to ask whether features of that distribution differ across other variables in the dataset. For example, what accounts for the bimodal distribution of flipper lengths that we saw above? :func:`displot` and :func:`histplot` provide support for conditional subsetting via the ``hue``semantic. Assigning a variable to ``hue`` will draw a separate histogram for each of its unique values and distinguish them by color:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "By default, the different histograms are \"layered\" on top of each other and, in some cases, they may be difficult to distinguish. One option is to change the visual representation of the histogram from a bar plot to a \"step\" plot:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"step\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Alternatively, instead of layering each bar, they can be \"stacked\", or moved vertically. In this plot, the outline of the full histogram will match the plot with only a single variable:"
    ]
   },
   {
@@ -80,20 +213,51 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.normal(size=100)\n",
-    "sns.distplot(x=x);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Histograms\n",
-    "^^^^^^^^^^\n",
+    "The stacked histogram emphasizes the part-whole relationship between the variables, but it can obscure other features (for example, it is difficult to determine the mode of the Adelie distribution. Another option is \"dodge\" the bars, which moves them horizontally and reduces their width. This ensures that there are no overlaps and that the bars remain comparable in terms of height. But it only works well when the categorical variable has a small number of levels:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"sex\", multiple=\"dodge\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Because :func:`displot` is a figure-level function and is drawn onto a :class:`FacetGrid`, it is also possible to draw each individual distribution in a separate subplot by assigning the second variable to ``col`` or ``row`` rather than (or in addition to) ``hue``. This represents the distribution of each subset well, but it makes it more difficult to draw direct comparisons:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", col=\"sex\", multiple=\"dodge\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "None of these approaches are perfect, and  we will soon see some alternatives to a histogram that are better-suited to the task of comparison.\n",
     "\n",
-    "Histograms are likely familiar, and a ``hist`` function already exists in matplotlib. A histogram represents the distribution of data by forming bins along the range of the data and then drawing bars to show the number of observations that fall in each bin.\n",
+    "Normalized histogram statistics\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "To illustrate this, let's remove the density curve and add a rug plot, which draws a small vertical tick at each observation. You can make the rug plot itself with the :func:`rugplot` function, but it is also available in :func:`distplot`:"
+    "Before we do, another point to note is that, when the subsets have unequal numbers of observations, comparing their distributions in terms of counts may not be ideal. One solution is to *normalize* the counts using the ``stat`` parameter:"
    ]
   },
   {
@@ -102,14 +266,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, kde=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"density\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "When drawing histograms, the main choice you have is the number of bins to use and where to place them. :func:`distplot` uses a simple rule to make a good guess for what the right number is by default, but trying more or fewer bins might reveal other features in the data:"
+    "By default, however, the normalization is applied to the entire distribution, so this simply rescales the height of the bars. By setting ``common_norm=False``, each subset will be normalized independently:"
    ]
   },
   {
@@ -118,19 +282,35 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, bins=20, kde=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"density\", common_norm=False)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    ".. _userguide_kde:\n",
+    "Density normalization scales the bars so that their *areas* sum to 1. As a result, the density axis is not directly interpretable. Another option is to normalize the bars to that their *heights* sum to 1. This makes most sense when the variable is discrete, but  it is an option for all histograms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"probability\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    ".. _tutorial_kde:\n",
     "\n",
     "Kernel density estimation\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "-------------------------\n",
     "\n",
-    "The kernel density estimate may be less familiar, but it can be a useful tool for plotting the shape of a distribution. Like the histogram, the KDE plots encode the density of observations on one axis with height along the other axis:"
+    "A histogram aims to approximate the underlying probability density function that generated the data by binning and counting observations. Kernel density estimation (KDE) presents a different solution to the same problem. Rather than using discrete bins, a KDE plot smooths the observations with a Gaussian kernel, producing a continuous density estimate:"
    ]
   },
   {
@@ -139,14 +319,17 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, hist=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Drawing a KDE is more computationally involved than drawing a histogram. What happens is that each observation is first replaced with a normal (Gaussian) curve centered at that value:"
+    "Choosing the smoothing bandwidth\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "Much like with the bin size in the histogram, the ability of the KDE to accurately represent the data depends on the choice of smoothing bandwidth. An over-smoothed estimate might erase meaningful features, but an under-smoothed estimate can obscure the true shape within random noise. The easiest way to check the robustness of the estimate is to adjust the default bandwidth:"
    ]
   },
   {
@@ -155,25 +338,100 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.normal(0, 1, size=30)\n",
-    "bandwidth = 1.06 * x.std() * x.size ** (-1 / 5.)\n",
-    "support = np.linspace(-4, 4, 200)\n",
-    "\n",
-    "kernels = []\n",
-    "for x_i in x:\n",
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\", bw_adjust=.25)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Note how the narrow bandwidth makes the bimodality much more apparent, but the curve is much less smooth. In contrast, a larger bandwidth obscures the bimodality almost completely:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\", bw_adjust=2)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Conditioning on other variables\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "    kernel = stats.norm(x_i, bandwidth).pdf(support)\n",
-    "    kernels.append(kernel)\n",
-    "    plt.plot(support, kernel, color=\"r\")\n",
+    "As with histograms, if you assign a ``hue`` variable, a separate density estimate will be computed for each level of that variable:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In many cases, the layered KDE is easier to interpret than the layered histogram, so it is often a good choice for the task of comparison. Many of the same options for resolving multiple distributions apply to the KDE as well, however:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Note how the stacked plot filled in the area between each curve by default. It is also possible to fill in the curves for single or layered densities, although the default alpha value (opacity) will be different, so that the individual densities are easier to resolve."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\", fill=True)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Kernel density estimation pitfalls\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "sns.rugplot(x=x, color=\".2\", linewidth=3);"
+    "KDE plots have many advantages. Important features of the data are easy to discern (central tendency, bimodality, skew), and they afford easy comparisons between subsets. But there are also situations where KDE poorly represents the underlying data. This is because the logic of KDE assumes that the underlying distribution is smooth and unbounded. One way this assumption can fail is when a varible reflects a quantity that is naturally bounded. If there are observations lying close to the bound (for example, small values of a variable that cannot be negative), the KDE curve may extend to unrealistic values:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"total_bill\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Next, these curves are summed to compute the value of the density at each point in the support grid. The resulting curve is then normalized so that the area under it is equal to 1:"
+    "This can be partially avoided with the ``cut`` parameter, which specifies how far the curve should extend beyond the extreme datapoints. But this influences only where the curve is drawn; the density estimate will still smooth over the range where no data can exist, causing it to be artifically low at the extremes of the distribution:"
    ]
   },
   {
@@ -182,17 +440,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from scipy.integrate import trapz\n",
-    "density = np.sum(kernels, axis=0)\n",
-    "density /= trapz(density, support)\n",
-    "plt.plot(support, density);"
+    "sns.displot(tips, x=\"total_bill\", kind=\"kde\", cut=0)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "We can see that if we use the :func:`kdeplot` function in seaborn, we get the same curve. This function is used by :func:`distplot`, but it provides a more direct interface with easier access to other options when you just want the density estimate:"
+    "The KDE approach also fails for discrete data or when data are naturally continuous but specific values are over-represented. The important thing to keep in mind is that the KDE will *always show you a smooth curve*, even when the data themselves are not smooth. For example, consider this distribution of diamond weights:"
    ]
   },
   {
@@ -201,14 +456,15 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x, fill=True);"
+    "diamonds = sns.load_dataset(\"diamonds\")\n",
+    "sns.displot(diamonds, x=\"carat\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "The bandwidth (``bw``) parameter of the KDE controls how tightly the estimation is fit to the data, much like the bin size in a histogram. It corresponds to the width of the kernels we plotted above. The default behavior tries to guess a good value using a common reference rule, but it may be helpful to try larger or smaller values:"
+    "While the KDE suggests that there are peaks around specific values, the histogram reveals a much more jagged distribution:"
    ]
   },
   {
@@ -217,17 +473,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x)\n",
-    "sns.kdeplot(x=x, bw=.2, label=\"bw: 0.2\")\n",
-    "sns.kdeplot(x=x, bw=2, label=\"bw: 2\")\n",
-    "plt.legend();"
+    "sns.displot(diamonds, x=\"carat\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "As you can see above, the nature of the Gaussian KDE process means that estimation extends past the largest and smallest values in the dataset. It's possible to control how far past the extreme values the curve is drawn with the ``cut`` parameter; however, this only influences how the curve is drawn and not how it is fit:"
+    "As a compromise, it is possible to combine these two approaches. While in histogram mode, :func:`displot` (as with :func:`histplot`) has the option of including the smoothed KDE curve (note ``kde=True``, not ``kind=\"kde\"``):"
    ]
   },
   {
@@ -236,18 +489,19 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x, fill=True, cut=0)\n",
-    "sns.rugplot(x=x);"
+    "sns.displot(diamonds, x=\"carat\", kde=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Fitting parametric distributions\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    ".. _tutorial_ecdf:\n",
+    "\n",
+    "Empirical cumulative distributions\n",
+    "----------------------------------\n",
     "\n",
-    "You can also use :func:`distplot` to fit a parametric distribution to a dataset and visually evaluate how closely it corresponds to the observed data:"
+    "A third option for visualizing distributions computes the \"empirical cumulative distribution function\" (ECDF). This plot draws a monotonically-increasing curve through each datapoint such that the height of the curve reflects the proportion of observations with a smaller value:"
    ]
   },
   {
@@ -256,18 +510,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.gamma(6, size=200)\n",
-    "sns.distplot(x=x, kde=False, fit=stats.gamma);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"ecdf\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Plotting bivariate distributions\n",
-    "--------------------------------\n",
-    "\n",
-    "It can also be useful to visualize a bivariate distribution of two variables. The easiest way to do this in seaborn is to just use the :func:`jointplot` function, which creates a multi-panel figure that shows both the bivariate (or joint) relationship between two variables along with the univariate (or marginal) distribution of each on separate axes."
+    "The ECDF plot has two key advantages. Unlike the histogram or KDE, it directly represents each datapoint. That means there is no bin size or smoothing parameter to consider. Additionally, because the curve is monotonically increasing, it is well-suited for comparing multiple distributions:"
    ]
   },
   {
@@ -276,19 +526,24 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "mean, cov = [0, 1], [(1, .5), (.5, 1)]\n",
-    "data = np.random.multivariate_normal(mean, cov, 200)\n",
-    "df = pd.DataFrame(data, columns=[\"x\", \"y\"])"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"ecdf\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Scatterplots\n",
-    "^^^^^^^^^^^^\n",
+    "The major downside to the ECDF plot is that it represents the shape of the distribution less intuitively than a histogram or density curve. Consider how the bimodality of flipper lengths is immediately apparent in the histogram, but to see it in the ECDF plot, you must look for varying slopes. Nevertheless, with practice, you can learn to answer all of the important questions about a distribution by examining the ECDF, and doing so can be a powerful approach."
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Visualizing bivariate distributions\n",
+    "-----------------------------------\n",
     "\n",
-    "The most familiar way to visualize a bivariate distribution is a scatterplot, where each observation is shown with point at the *x* and *y* values. This is analogous to a rug plot on two dimensions. You can draw a scatterplot with :func:`scatterplot`, and it is also the default kind of plot shown by the :func:`jointplot` function:"
+    "All of the examples so far have considered *univariate* distributions: distributions of a single variable, perhaps conditional on a second variable assigned to ``hue``. Assigning a second variable to ``y``, however, will plot a *bivariate* distribution:"
    ]
   },
   {
@@ -297,17 +552,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.jointplot(x=\"x\", y=\"y\", data=df);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Hexbin plots\n",
-    "^^^^^^^^^^^^\n",
-    "\n",
-    "A bivariate analogue of a histogram is known as a \"hexbin\" plot, because it shows the counts of observations that fall within hexagonal bins. This plot works best with relatively large datasets. It's available through in matplotlib as :meth:`matplotlib.axes.Axes.hexbin` and as a style in :func:`jointplot`. It looks best with a white background:"
+    "A bivariate histogram bins the data within rectangles that tile the plot and then shows the count of observations within each rectangle with the fill color (analagous to a :func:`heatmap`). Similarly, a bivariate KDE plot smoothes the (x, y) observations with a 2D Gaussian. The default representation then shows the *contours* of the 2D density:"
    ]
   },
   {
@@ -316,19 +568,30 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x, y = np.random.multivariate_normal(mean, cov, 1000).T\n",
-    "with sns.axes_style(\"white\"):\n",
-    "    sns.jointplot(x=x, y=y, kind=\"hex\", color=\"k\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Kernel density estimation\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^\n",
-    "\n",
-    "It is also possible to use the kernel density estimation procedure described above to visualize a bivariate distribution. In seaborn, this kind of plot is shown with a contour plot and is available as a style in :func:`jointplot`:"
+    "Assigning a ``hue`` variable will plot multiple heatmaps or contour sets using different colors. For bivariate histograms, this will only work well if there is minimal overlap between the conditional distributions:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The contour approach of the bivariate KDE plot lends itself better to evaluating overlap, although a plot with too many contours can get busy:"
    ]
   },
   {
@@ -337,14 +600,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.jointplot(x=\"x\", y=\"y\", data=df, kind=\"kde\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", hue=\"species\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "You can also draw a two-dimensional kernel density plot with the :func:`kdeplot` function. This allows you to draw this kind of plot onto a specific (and possibly already existing) matplotlib axes, whereas the :func:`jointplot` function manages its own figure:"
+    "Just as with univariate plots, the choice of bin size or smoothing bandwidth will determine how well the plot represents the underlying bivariate distribution. The same parameters apply, but they can be tuned for each variable by passing a pair of values:"
    ]
   },
   {
@@ -353,17 +616,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "f, ax = plt.subplots(figsize=(6, 6))\n",
-    "sns.kdeplot(x=df.x, y=df.y, ax=ax)\n",
-    "sns.rugplot(x=df.x, color=\"g\", ax=ax)\n",
-    "sns.rugplot(x=df.y, vertical=True, ax=ax);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", binwidth=(2, .5))"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "If you wish to show the bivariate density more continuously, you can simply increase the number of contour levels:"
+    "To aid interpretation of the heatmap, add a colorbar to show the mapping between counts and color intensity:"
    ]
   },
   {
@@ -372,16 +632,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "f, ax = plt.subplots(figsize=(6, 6))\n",
-    "cmap = sns.cubehelix_palette(as_cmap=True, dark=0, light=1, reverse=True)\n",
-    "sns.kdeplot(data=df, x=\"x\", y=\"y\", cmap=cmap, levels=60, fill=True);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", binwidth=(2, .5), cbar=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "The :func:`jointplot` function uses a :class:`JointGrid` to manage the figure. For more flexibility, you may want to draw your figure by using :class:`JointGrid` directly. :func:`jointplot` returns the :class:`JointGrid` object after plotting, which you can use to add more layers or to tweak other aspects of the visualization:"
+    "The meaning of the bivariate density contours is less straightforward. Because the density is not directly interpretable, the contours are drawn at *iso-proportions* of the density, meaning that each curve shows a level set such that some proportion *p* of the density lies below it. The *p* values are evenly spaced, with the lowest level contolled by the ``thresh`` parameter and the number controlled by ``levels``:"
    ]
   },
   {
@@ -390,20 +648,71 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "g = sns.jointplot(x=\"x\", y=\"y\", data=df, kind=\"kde\", color=\"m\")\n",
-    "g.plot_joint(plt.scatter, c=\"w\", s=30, linewidth=1, marker=\"+\")\n",
-    "g.ax_joint.collections[0].set_alpha(0)\n",
-    "g.set_axis_labels(\"$X$\", \"$Y$\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", thresh=.2, levels=4)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Visualizing pairwise relationships in a dataset\n",
-    "-----------------------------------------------\n",
+    "The ``levels`` parameter also accepts a list of values, for more control:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", levels=[.01, .05, .1, .8])"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The bivariate histogram allows one or both variables to be discrete. Plotting one discrete and one continuous variable offers another way to compare conditional univariate distributions:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(diamonds, x=\"price\", y=\"clarity\", log_scale=(True, False))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In contrast, plotting two discrete variables is an easy to way show the cross-tabulation of the observations:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(diamonds, x=\"color\", y=\"clarity\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Distribution visualization in other settings\n",
+    "--------------------------------------------\n",
+    "\n",
+    "Several other figure-level plotting functions in seaborn make use of the :func:`histplot` and :func:`kdeplot` functions.\n",
     "\n",
-    "To plot multiple pairwise bivariate distributions in a dataset, you can use the :func:`pairplot` function. This creates a matrix of axes and shows the relationship for each pair of columns in a DataFrame. By default, it also draws the univariate distribution of each variable on the diagonal Axes:"
+    "\n",
+    "Plotting joint and marginal distributions\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The first is :func:`jointplot`, which augments a bivariate relatonal or distribution plot with the marginal distributions of the two variables. By default, :func:`jointplot` represents the bivariate distribution using :func:`scatterplot` and the marginal distributions using :func:`histplot`:"
    ]
   },
   {
@@ -412,15 +721,84 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "iris = sns.load_dataset(\"iris\")\n",
-    "sns.pairplot(iris);"
+    "sns.jointplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Specifying the ``hue`` parameter automatically changes the histograms to KDE plots to facilitate comparisons between multiple distributions."
+    "Similar to :func:`displot`, setting a different ``kind=\"kde\"`` in :func:`jointplot` will change both the joint and marginal plots the use :func:`kdeplot`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.jointplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    ":func:`jointplot` is really just a convenient interface to :class:`JointGrid`, which offers more flexibility:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.JointGrid(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")\n",
+    "g.plot_joint(sns.scatterplot)\n",
+    "g.plot_marginals(sns.boxplot)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "A less-obtrusive way to show marginal distributions uses a \"rug\" plot, which adds a small tick on the edge of the plot to represent each individual observation. This is built into :func:`displot`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", rug=True)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "And the axes-level :func:`rugplot` function can be used to add rugs on the side of any other kind of plot:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.relplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")\n",
+    "sns.rugplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Plotting many distributions\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The :func:`pairplot` function offers a similar blend of joint and marginal distributions. Rather than focusing on a single relationship, however, :func:`pairplot` uses a \"small-multiple\" approach to visualize the univariate distribution of all variables in a dataset along with all of their pairwise relationships:"
    ]
   },
   {
@@ -429,14 +807,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.pairplot(iris, hue=\"species\");"
+    "sns.pairplot(penguins)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Much like the relationship between :func:`jointplot` and :class:`JointGrid`, the :func:`pairplot` function is built on top of a :class:`PairGrid` object, which can be used directly for more flexibility:"
+    "As with :func:`jointplot`/:class:`JointGrid`, using the underlying :class:`PairGrid` directly will afford more flexibility with only a bit more typing:"
    ]
   },
   {
@@ -445,9 +823,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "g = sns.PairGrid(iris)\n",
-    "g.map_diag(sns.kdeplot)\n",
-    "g.map_offdiag(sns.kdeplot, n_levels=6);"
+    "g = sns.PairGrid(penguins)\n",
+    "g.map_upper(sns.histplot)\n",
+    "g.map_lower(sns.kdeplot, fill=True)\n",
+    "g.map_diag(sns.histplot, kde=True)"
    ]
   },
   {
diff --git a/examples/distplot_options.py b/examples/distplot_options.py
deleted file mode 100644
index ef19215bba..0000000000
--- a/examples/distplot_options.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""
-Distribution plot options
-=========================
-
-"""
-import numpy as np
-import seaborn as sns
-import matplotlib.pyplot as plt
-
-sns.set(style="white", palette="muted", color_codes=True)
-rs = np.random.RandomState(10)
-
-# Set up the matplotlib figure
-f, axes = plt.subplots(2, 2, figsize=(7, 7), sharex=True)
-sns.despine(left=True)
-
-# Generate a random univariate dataset
-x = rs.normal(size=100)
-
-# Plot a simple histogram with binsize determined automatically
-sns.distplot(x=x, kde=False, color="b", ax=axes[0, 0])
-
-# Plot a kernel density estimate and rug plot
-sns.distplot(x=x, hist=False, rug=True, color="r", ax=axes[0, 1])
-
-# Plot a filled kernel density estimate
-sns.distplot(x=x, hist=False, color="g",
-             kde_kws={"shade": True}, ax=axes[1, 0])
-
-# Plot a histogram and kernel density estimate
-sns.distplot(x=x, color="m", ax=axes[1, 1])
-
-plt.setp(axes, yticks=[])
-f.tight_layout()
diff --git a/examples/faceted_histogram.py b/examples/faceted_histogram.py
index 995dbe47a7..4677ef3a1d 100644
--- a/examples/faceted_histogram.py
+++ b/examples/faceted_histogram.py
@@ -2,14 +2,13 @@
 Facetting histograms by subsets of data
 =======================================

-_thumb: .42, .57
+_thumb: .33, .57
 """
-import numpy as np
 import seaborn as sns
-import matplotlib.pyplot as plt
-sns.set(style="darkgrid")

-tips = sns.load_dataset("tips")
-g = sns.FacetGrid(tips, row="sex", col="time", margin_titles=True)
-bins = np.linspace(0, 60, 13)
-g.map(plt.hist, "total_bill", color="steelblue", bins=bins)
+sns.set(style="darkgrid")
+df = sns.load_dataset("penguins")
+sns.displot(
+    df, x="flipper_length_mm", col="species", row="sex",
+    binwidth=3, height=3, facet_kws=dict(margin_titles=True),
+)
diff --git a/examples/multiple_ecdf.py b/examples/multiple_ecdf.py
new file mode 100644
index 0000000000..3e33bd3d3e
--- /dev/null
+++ b/examples/multiple_ecdf.py
@@ -0,0 +1,17 @@
+"""
+Facetted ECDF plots
+===================
+
+_thumb: .30, .49
+"""
+import seaborn as sns
+sns.set(style="ticks")
+mpg = sns.load_dataset("mpg")
+
+colors = (250, 70, 50), (350, 70, 50)
+cmap = sns.blend_palette(colors, input="husl", as_cmap=True)
+sns.displot(
+    mpg,
+    x="displacement", col="origin", hue="model_year",
+    kind="ecdf", aspect=.75, linewidth=2, palette=cmap,
+)
diff --git a/examples/pair_grid_with_kde.py b/examples/pair_grid_with_kde.py
index b89b895b4a..b90caf095e 100644
--- a/examples/pair_grid_with_kde.py
+++ b/examples/pair_grid_with_kde.py
@@ -7,9 +7,9 @@
 import seaborn as sns
 sns.set(style="white")

-df = sns.load_dataset("iris")
+df = sns.load_dataset("penguins")

 g = sns.PairGrid(df, diag_sharey=False)
-g.map_upper(sns.scatterplot)
-g.map_lower(sns.kdeplot, colors="C0")
+g.map_upper(sns.scatterplot, s=15)
+g.map_lower(sns.kdeplot)
 g.map_diag(sns.kdeplot, lw=2)
diff --git a/examples/facet_projections.py b/examples/radial_facets.py
similarity index 100%
rename from examples/facet_projections.py
rename to examples/radial_facets.py
diff --git a/examples/three_variable_histogram.py b/examples/three_variable_histogram.py
new file mode 100644
index 0000000000..5e691f7dd7
--- /dev/null
+++ b/examples/three_variable_histogram.py
@@ -0,0 +1,15 @@
+"""
+Trivariate histogram with two categorical variables
+===================================================
+
+_thumb: .32, .55
+
+"""
+import seaborn as sns
+sns.set(style="dark")
+
+diamonds = sns.load_dataset("diamonds")
+sns.displot(
+    data=diamonds, x="price", y="color", col="clarity",
+    log_scale=(True, False), col_wrap=4, height=4, aspect=.7,
+)
diff --git a/seaborn/_core.py b/seaborn/_core.py
index dc1f7995c7..a224ff3b19 100644
--- a/seaborn/_core.py
+++ b/seaborn/_core.py
@@ -146,10 +146,17 @@ def _lookup_single(self, key):
         except KeyError:
             # Use the colormap to interpolate between existing datapoints
             # (e.g. in the context of making a continuous legend)
-            normed = self.norm(key)
-            if np.ma.is_masked(normed):
-                normed = np.nan
-            value = self.cmap(normed)
+            try:
+                normed = self.norm(key)
+            except TypeError as err:
+                if np.isnan(key):
+                    value = (0, 0, 0, 0)
+                else:
+                    raise err
+            else:
+                if np.ma.is_masked(normed):
+                    normed = np.nan
+                value = self.cmap(normed)
         return value

     def infer_map_type(self, palette, norm, input_format, var_type):
@@ -610,9 +617,12 @@ def __init__(self, data=None, variables={}):
             # Call the mapping function to initialize with default values
             getattr(self, f"map_{var}")()

+        self._var_levels = {}
+
     @classmethod
     def get_semantics(cls, kwargs, semantics=None):
         """Subset a dictionary` arguments with known semantic variables."""
+        # TODO this should be get_variables since we have included x and y
         if semantics is None:
             semantics = cls.semantics
         variables = {}
@@ -626,6 +636,27 @@ def has_xy_data(self):
         """Return True at least one of x or y is defined."""
         return bool({"x", "y"} & set(self.variables))

+    @property
+    def var_levels(self):
+        """Property interface to ordered list of variables levels.
+
+        Each time it's accessed, it updates the var_levels dictionary with the
+        list of levels in the current semantic mappers. But it also allows the
+        dictionary to persist, so it can be used to set levels by a key. This is
+        used to track the list of col/row levels using an attached FacetGrid
+        object, but it's kind of messy and ideally fixed by improving the
+        faceting logic so it interfaces better with the modern approach to
+        tracking plot variables.
+
+        """
+        for var in self.variables:
+            try:
+                map_obj = getattr(self, f"_{var}_map")
+                self._var_levels[var] = map_obj.levels
+            except AttributeError:
+                pass
+        return self._var_levels
+
     def assign_variables(self, data=None, variables={}):
         """Define plot variables, optionally using lookup from `data`."""
         x = variables.get("x", None)
@@ -858,14 +889,16 @@ def _assign_variables_longform(self, data=None, **kwargs):

         return plot_data, variables

-    def _semantic_subsets(
-        self, grouping_semantics, reverse=False, from_comp_data=False,
+    def iter_data(
+        self, grouping_vars=None, reverse=False, from_comp_data=False,
     ):
         """Generator for getting subsets of data defined by semantic variables.

+        Also injects "col" and "row" into grouping semantics.
+
         Parameters
         ----------
-        grouping_semantics : list of strings
+        grouping_vars : string or list of strings
             Semantic variables that define the subsets of data.
         reverse : bool, optional
             If True, reverse the order of iteration.
@@ -880,12 +913,24 @@ def _semantic_subsets(
             Subset of ``plot_data`` for this combination of semantic values.

         """
-        if isinstance(grouping_semantics, str):
-            grouping_semantics = [grouping_semantics]
+        # TODO should this default to using all (non x/y?) semantics?
+        # or define groupping vars somewhere?
+        if grouping_vars is None:
+            grouping_vars = []
+        elif isinstance(grouping_vars, str):
+            grouping_vars = [grouping_vars]
+        elif isinstance(grouping_vars, tuple):
+            grouping_vars = list(grouping_vars)
+
+        # Always insert faceting variables
+        facet_vars = {"col", "row"}
+        grouping_vars.extend(
+            facet_vars & set(self.variables) - set(grouping_vars)
+        )

         # Reduce to the semantics used in this plot
-        grouping_semantics = [
-            var for var in grouping_semantics if var in self.variables
+        grouping_vars = [
+            var for var in grouping_vars if var in self.variables
         ]

         if from_comp_data:
@@ -893,17 +938,15 @@ def _semantic_subsets(
         else:
             data = self.plot_data

-        if grouping_semantics:
+        if grouping_vars:

             grouped_data = data.groupby(
-                grouping_semantics, sort=False, as_index=False
+                grouping_vars, sort=False, as_index=False
             )

             grouping_keys = []
-            for var in grouping_semantics:
-                # TODO this is messy, add "semantic levels" property?
-                map_obj = getattr(self, f"_{var}_map")
-                grouping_keys.append(map_obj.levels)
+            for var in grouping_vars:
+                grouping_keys.append(self.var_levels.get(var, []))

             iter_keys = itertools.product(*grouping_keys)
             if reverse:
@@ -919,7 +962,9 @@ def _semantic_subsets(
                 except KeyError:
                     continue

-                yield dict(zip(grouping_semantics, key)), data_subset
+                sub_vars = dict(zip(grouping_vars, key))
+
+                yield sub_vars, data_subset

         else:

@@ -941,22 +986,50 @@ def comp_data(self):
             for var in "xy":
                 if var not in self.variables:
                     continue
-                axis = getattr(self.ax, f"{var}axis")
+
+                # Get a corresponding axis object so that we can convert the units
+                # to matplotlib's numeric representation, which we can compute on
+                # This is messy and it would probably be better for VectorPlotter
+                # to manage its own converters (using the matplotlib tools).
+                # XXX Currently does not support unshared categorical axes!
+                # (But see comment in _attach about how those don't exist)
+                if self.ax is None:
+                    ax = self.facets.axes.flat[0]
+                else:
+                    ax = self.ax
+                axis = getattr(ax, f"{var}axis")
+
                 comp_var = axis.convert_units(self.plot_data[var])
                 if axis.get_scale() == "log":
                     comp_var = np.log10(comp_var)
                 comp_data[var] = comp_var
+
             self._comp_data = comp_data

         return self._comp_data

-    def _attach(self, ax, allowed_types=None, log_scale=None):
-        """Associate the plotter with a matplotlib Axes and initialize its units.
+    def _get_axes(self, sub_vars):
+        """Return an Axes object based on existence of row/col variables."""
+        row = sub_vars.get("row", None)
+        col = sub_vars.get("col", None)
+        if row is not None and col is not None:
+            return self.facets.axes_dict[(row, col)]
+        elif row is not None:
+            return self.facets.axes_dict[row]
+        elif col is not None:
+            return self.facets.axes_dict[col]
+        elif self.ax is None:
+            return self.facets.ax
+        else:
+            return self.ax
+
+    def _attach(self, obj, allowed_types=None, log_scale=None):
+        """Associate the plotter with an Axes manager and initialize its units.

         Parameters
         ----------
-        ax : :class:`matplotlib.axes.Axes`
-            Axes object that we will eventually plot onto.
+        obj : :class:`matplotlib.axes.Axes` or :class:'FacetGrid`
+            Structural object that we will eventually plot onto.
         allowed_types : str or list of str
             If provided, raise when either the x or y variable does not have
             one of the declared seaborn types.
@@ -966,14 +1039,26 @@ def _attach(self, ax, allowed_types=None, log_scale=None):
             arguments for the x and y axes.

         """
+        from .axisgrid import FacetGrid
+        if isinstance(obj, FacetGrid):
+            self.ax = None
+            self.facets = obj
+            ax_list = obj.axes.flatten()
+            if obj.col_names is not None:
+                self.var_levels["col"] = obj.col_names
+            if obj.row_names is not None:
+                self.var_levels["row"] = obj.row_names
+        else:
+            self.ax = obj
+            self.facets = None
+            ax_list = [obj]
+
         if allowed_types is None:
-            # TODO should we define this default somewhere?
             allowed_types = ["numeric", "datetime", "categorical"]
         elif isinstance(allowed_types, str):
             allowed_types = [allowed_types]

         for var in set("xy").intersection(self.variables):
-
             # Check types of x/y variables
             var_type = self.var_types[var]
             if var_type not in allowed_types:
@@ -984,12 +1069,26 @@ def _attach(self, ax, allowed_types=None, log_scale=None):
                 raise TypeError(err)

             # Register with the matplotlib unit conversion machinery
-            # TODO do we want to warn or raise if mixing units?
-            axis = getattr(ax, f"{var}axis")
-            seed_data = self.plot_data[var]
-            if var_type == "categorical":
-                seed_data = categorical_order(seed_data)
-            axis.update_units(seed_data)
+            # Perhaps cleaner to manage our own transform objects?
+            # XXX Currently this does not allow "unshared" categorical axes
+            # We could add metadata to a FacetGrid and set units based on that.
+            # See also comment in comp_data, which only uses a single axes to do
+            # its mapping, meaning that it won't handle unshared axes well either.
+            for ax in ax_list:
+                axis = getattr(ax, f"{var}axis")
+                seed_data = self.plot_data[var]
+                if var_type == "categorical":
+                    seed_data = categorical_order(seed_data)
+                axis.update_units(seed_data)
+
+        # For categorical y, we want the "first" level to be at the top of the axis
+        if self.var_types.get("y", None) == "categorical":
+            for ax in ax_list:
+                try:
+                    ax.yaxis.set_inverted(True)
+                except AttributeError:  # mpl < 3.1
+                    if not ax.yaxis_inverted():
+                        ax.invert_yaxis()

         # Possibly log-scale one or both axes
         if log_scale is not None:
@@ -1002,13 +1101,29 @@ def _attach(self, ax, allowed_types=None, log_scale=None):

             for axis, scale in zip("xy", (scalex, scaley)):
                 if scale:
-                    set_scale = getattr(ax, f"set_{axis}scale")
-                    if scale is True:
-                        set_scale("log")
-                    else:
-                        set_scale("log", **{f"base{axis}": scale})
+                    for ax in ax_list:
+                        set_scale = getattr(ax, f"set_{axis}scale")
+                        if scale is True:
+                            set_scale("log")
+                        else:
+                            set_scale("log", **{f"base{axis}": scale})
+
+    def _log_scaled(self, axis):
+        """Return True if specified axis is log scaled on all attached axes."""
+        if self.ax is None:
+            axes_list = self.facets.axes.flatten()
+        else:
+            axes_list = [self.ax]
+
+        log_scaled = []
+        for ax in axes_list:
+            data_axis = getattr(ax, f"{axis}axis")
+            log_scaled.append(data_axis.get_scale() == "log")
+
+        if any(log_scaled) and not all(log_scaled):
+            raise RuntimeError("Axis scaling is not consistent")

-        self.ax = ax
+        return any(log_scaled)

     def _add_axis_labels(self, ax, default_x="", default_y=""):
         """Add axis labels from internal variable names if not already existing."""
diff --git a/seaborn/_docstrings.py b/seaborn/_docstrings.py
index a00bb79b04..f6f7e2bbc8 100644
--- a/seaborn/_docstrings.py
+++ b/seaborn/_docstrings.py
@@ -102,15 +102,23 @@ def from_function_params(cls, func):

 _core_returns = dict(
     ax="""
-ax : :class:`matplotlib.axes.Axes`
+:class:`matplotlib.axes.Axes`
     The matplotlib axes containing the plot.
     """,
+    facetgrid="""
+:class:`FacetGrid`
+    An object managing one or more subplots that correspond to conditional data
+    subsets with convenient methods for batch-setting of axes attributes.
+    """,
 )

 _seealso_blurbs = dict(

     # Distribution plots
+    displot="""
+displot : Figure-level interface to distribution plot functions.
+    """,
     histplot="""
 histplot : Plot a histogram of binned counts with optional normalization or smoothing.
     """,
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index 9b2fc2e0c4..3157babc2c 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -11,7 +11,6 @@
 from ._core import variable_type, categorical_order
 from . import utils
 from .palettes import color_palette, blend_palette
-from .distributions import histplot, kdeplot, _freedman_diaconis_bins
 from ._decorators import _deprecate_positional_args

@@ -51,13 +50,13 @@ def add_legend(self, legend_data=None, title=None, label_order=None,

         Parameters
         ----------
-        legend_data : dict, optional
+        legend_data : dict
             Dictionary mapping label names (or two-element tuples where the
             second element is a label name) to matplotlib artist handles. The
             default reads from ``self._legend_data``.
-        title : string, optional
+        title : string
             Title for the legend. The default reads from ``self._hue_var``.
-        label_order : list of labels, optional
+        label_order : list of labels
             The order that the legend entries should appear in. The default
             reads from ``self.hue_names``.
         kwargs : key, value pairings
@@ -199,8 +198,17 @@ def _get_palette(self, data, hue, hue_order, palette):
         Tidy ("long-form") dataframe where each column is a variable and each
         row is an observation.\
     """),
+    rowcol=dedent("""\
+    row, col : vectors or keys in ``data``
+        Variables that define subsets to plot on different facets.\
+    """),
+    rowcol_order=dedent("""\
+    {row,col}_order : vector of strings
+        Specify the order in which levels of the ``row`` and/or ``col`` variables
+        appear in the grid of subplots.\
+    """),
     col_wrap=dedent("""\
-    col_wrap : int, optional
+    col_wrap : int
         "Wrap" the column variable at this width, so that the column facets
         span multiple rows. Incompatible with a ``row`` facet.\
     """),
@@ -210,31 +218,35 @@ def _get_palette(self, data, hue, hue_order, palette):
         across rows.\
     """),
     height=dedent("""\
-    height : scalar, optional
+    height : scalar
         Height (in inches) of each facet. See also: ``aspect``.\
     """),
     aspect=dedent("""\
-    aspect : scalar, optional
+    aspect : scalar
         Aspect ratio of each facet, so that ``aspect * height`` gives the width
         of each facet in inches.\
     """),
     palette=dedent("""\
-    palette : palette name, list, or dict, optional
+    palette : palette name, list, or dict
         Colors to use for the different levels of the ``hue`` variable. Should
         be something that can be interpreted by :func:`color_palette`, or a
         dictionary mapping hue levels to matplotlib colors.\
     """),
     legend_out=dedent("""\
-    legend_out : bool, optional
+    legend_out : bool
         If ``True``, the figure size will be extended, and the legend will be
         drawn outside the plot on the center right.\
     """),
     margin_titles=dedent("""\
-    margin_titles : bool, optional
+    margin_titles : bool
         If ``True``, the titles for the row variable are drawn to the right of
         the last column. This option is experimental and may not work in all
         cases.\
     """),
+    facet_kws=dedent("""\
+    facet_kws : dict
+        Additional parameters passed to :class:`FacetGrid`.
+    """),
 )

@@ -459,7 +471,7 @@ def __init__(
         {height}
         {aspect}
         {palette}
-        {{row,col,hue}}_order : lists, optional
+        {{row,col,hue}}_order : lists
             Order for the levels of the faceting variables. By default, this
             will be the order that the levels appear in ``data`` or, if the
             variables are pandas categoricals, the category order.
@@ -468,16 +480,16 @@ def __init__(
             other plot attributes vary across levels of the hue variable (e.g.
             the markers in a scatterplot).
         {legend_out}
-        despine : boolean, optional
+        despine : boolean
             Remove the top and right spines from the plots.
         {margin_titles}
-        {{x, y}}lim: tuples, optional
+        {{x, y}}lim: tuples
             Limits for each of the axes on each facet (only relevant when
             share{{x, y}} is True).
-        subplot_kws : dict, optional
+        subplot_kws : dict
             Dictionary of keyword arguments passed to matplotlib subplot(s)
             methods.
-        gridspec_kws : dict, optional
+        gridspec_kws : dict
             Dictionary of keyword arguments passed to matplotlib's ``gridspec``
             module (via ``plt.subplots``). Ignored if ``col_wrap`` is not
             ``None``.
@@ -1180,7 +1192,7 @@ def __init__(
         data : DataFrame
             Tidy (long-form) dataframe where each column is a variable and
             each row is an observation.
-        hue : string (variable name), optional
+        hue : string (variable name)
             Variable in ``data`` to map plot aspects to different colors. This
             variable will be excluded from the default x and y variables.
         hue_order : list of strings
@@ -1192,24 +1204,24 @@ def __init__(
             Other keyword arguments to insert into the plotting call to let
             other plot attributes vary across levels of the hue variable (e.g.
             the markers in a scatterplot).
-        vars : list of variable names, optional
+        vars : list of variable names
             Variables within ``data`` to use, otherwise use every column with
             a numeric datatype.
-        {x, y}_vars : lists of variable names, optional
+        {x, y}_vars : lists of variable names
             Variables within ``data`` to use separately for the rows and
             columns of the figure; i.e. to make a non-square plot.
-        corner : bool, optional
+        corner : bool
             If True, don't add axes to the upper (off-diagonal) triangle of the
             grid, making this a "corner" plot.
-        height : scalar, optional
+        height : scalar
             Height (in inches) of each facet.
-        aspect : scalar, optional
+        aspect : scalar
             Aspect * height gives the width (in inches) of each facet.
-        layout_pad : scalar, optional
+        layout_pad : scalar
             Padding between axes; passed to ``fig.tight_layout``.
-        despine : boolean, optional
+        despine : boolean
             Remove the top and right spines from the plots.
-        dropna : boolean, optional
+        dropna : boolean
             Drop missing values from the data before plotting.

         See Also
@@ -1635,17 +1647,17 @@ def __init__(
         ----------
         x, y : strings or vectors
             Data or names of variables in ``data``.
-        data : DataFrame, optional
+        data : DataFrame
             DataFrame when ``x`` and ``y`` are variable names.
         height : numeric
             Size of each side of the figure in inches (it will be square).
         ratio : numeric
             Ratio of joint axes size to marginal axes height.
-        space : numeric, optional
+        space : numeric
             Space between the joint and marginal axes
-        dropna : bool, optional
+        dropna : bool
             If True, remove observations that are missing from `x` and `y`.
-        {x, y}lim : two-tuples, optional
+        {x, y}lim : two-tuples
             Axis limits to set before plotting.

         See Also
@@ -1901,13 +1913,13 @@ def annotate(self, func, template=None, stat=None, loc="best", **kwargs):
         func : callable
             Statistical function that maps the x, y vectors either to (val, p)
             or to val.
-        template : string format template, optional
+        template : string format template
             The template must have the format keys "stat" and "val";
             if `func` returns a p value, it should also have the key "p".
-        stat : string, optional
+        stat : string
             Name to use for the statistic in the annotation, by default it
             uses the name of `func`.
-        loc : string or int, optional
+        loc : string or int
             Matplotlib legend location code; used to place the annotation.
         kwargs : key, value mappings
             Other keyword arguments are passed to `ax.legend`, which formats
@@ -2012,39 +2024,39 @@ def pairplot(
     data : DataFrame
         Tidy (long-form) dataframe where each column is a variable and
         each row is an observation.
-    hue : string (variable name), optional
+    hue : string (variable name)
         Variable in ``data`` to map plot aspects to different colors.
     hue_order : list of strings
         Order for the levels of the hue variable in the palette
     palette : dict or seaborn color palette
         Set of colors for mapping the ``hue`` variable. If a dict, keys
         should be values  in the ``hue`` variable.
-    vars : list of variable names, optional
+    vars : list of variable names
         Variables within ``data`` to use, otherwise use every column with
         a numeric datatype.
-    {x, y}_vars : lists of variable names, optional
+    {x, y}_vars : lists of variable names
         Variables within ``data`` to use separately for the rows and
         columns of the figure; i.e. to make a non-square plot.
-    kind : {'scatter', 'reg'}, optional
+    kind : {'scatter', 'reg'}
         Kind of plot for the non-identity relationships.
-    diag_kind : {'auto', 'hist', 'kde', None}, optional
+    diag_kind : {'auto', 'hist', 'kde', None}
         Kind of plot for the diagonal subplots. The default depends on whether
         ``"hue"`` is used or not.
-    markers : single matplotlib marker code or list, optional
+    markers : single matplotlib marker code or list
         Either the marker to use for all datapoints or a list of markers with
         a length the same as the number of levels in the hue variable so that
         differently colored points will also have different scatterplot
         markers.
-    height : scalar, optional
+    height : scalar
         Height (in inches) of each facet.
-    aspect : scalar, optional
+    aspect : scalar
         Aspect * height gives the width (in inches) of each facet.
-    corner : bool, optional
+    corner : bool
         If True, don't add axes to the upper (off-diagonal) triangle of the
         grid, making this a "corner" plot.
-    dropna : boolean, optional
+    dropna : boolean
         Drop missing values from the data before plotting.
-    {plot, diag, grid}_kws : dicts, optional
+    {plot, diag, grid}_kws : dicts
         Dictionaries of keyword arguments. ``plot_kws`` are passed to the
         bivariate plotting function, ``diag_kws`` are passed to the univariate
         plotting function, and ``grid_kws`` are passed to the :class:`PairGrid`
@@ -2151,6 +2163,9 @@ def pairplot(
         ...                  diag_kws=dict(fill=True))

     """
+    # Avoid circular import
+    from .distributions import kdeplot  # TODO histplot
+
     # Handle deprecations
     if size is not None:
         height = size
@@ -2242,25 +2257,25 @@ def jointplot(
     ----------
     x, y : strings or vectors
         Data or names of variables in ``data``.
-    data : DataFrame, optional
+    data : DataFrame
         DataFrame when ``x`` and ``y`` are variable names.
-    kind : { "scatter" | "reg" | "resid" | "kde" | "hex" }, optional
+    kind : { "scatter" | "reg" | "resid" | "kde" | "hex" }
         Kind of plot to draw.
-    stat_func : callable or None, optional
+    stat_func : callable or None
         *Deprecated*
-    color : matplotlib color, optional
+    color : matplotlib color
         Color used for the plot elements.
-    height : numeric, optional
+    height : numeric
         Size of the figure (it will be square).
-    ratio : numeric, optional
+    ratio : numeric
         Ratio of joint axes height to marginal axes height.
-    space : numeric, optional
+    space : numeric
         Space between the joint and marginal axes
-    dropna : bool, optional
+    dropna : bool
         If True, remove observations that are missing from ``x`` and ``y``.
-    {x, y}lim : two-tuples, optional
+    {x, y}lim : two-tuples
         Axis limits to set before plotting.
-    {joint, marginal, annot}_kws : dicts, optional
+    {joint, marginal, annot}_kws : dicts
         Additional keyword arguments for the plot components.
     kwargs : key, value pairings
         Additional keyword arguments are passed to the function used to
@@ -2351,6 +2366,9 @@ def jointplot(
         ...                   s=40, edgecolor="w", linewidth=1)

     """
+    # Avoid circular import
+    from .distributions import histplot, kdeplot, _freedman_diaconis_bins
+
     # Handle deprecations
     if "size" in kwargs:
         height = kwargs.pop("size")
diff --git a/seaborn/distributions.py b/seaborn/distributions.py
index 54d06a9afe..48d4d8a302 100644
--- a/seaborn/distributions.py
+++ b/seaborn/distributions.py
@@ -20,11 +20,16 @@
     Histogram,
     ECDF,
 )
+from .axisgrid import (
+    FacetGrid,
+    _facet_docs,
+)
 from .utils import (
     remove_na,
     _kde_support,
     _normalize_kwargs,
     _check_argument,
+    _assign_default_kwargs,
 )
 from .external import husl
 from ._decorators import _deprecate_positional_args
@@ -34,7 +39,7 @@
 )

-__all__ = ["distplot", "histplot", "kdeplot", "ecdfplot", "rugplot"]
+__all__ = ["displot", "histplot", "kdeplot", "ecdfplot", "rugplot", "distplot"]

 # ==================================================================================== #
 # Module documentation
@@ -73,6 +78,7 @@

 _param_docs = DocstringComponents.from_nested_components(
     core=_core_docs["params"],
+    facets=DocstringComponents(_facet_docs),
     dist=DocstringComponents(_dist_params),
     kde=DocstringComponents.from_function_params(KDE.__init__),
     hist=DocstringComponents.from_function_params(Histogram.__init__),
@@ -125,10 +131,10 @@ def has_xy_data(self):

     def _add_legend(
         self,
-        ax, artist, fill, element, multiple, alpha, artist_kws, legend_kws,
+        ax_obj, artist, fill, element, multiple, alpha, artist_kws, legend_kws,
     ):
         """Add artists that reflect semantic mappings and put then in a legend."""
-        # TODO note that this doesn't handle numeric mappngs the way relational plots do
+        # TODO note that this doesn't handle numeric mappings like the relational plots
         handles = []
         labels = []
         for level in self._hue_map.levels:
@@ -140,7 +146,16 @@ def _add_legend(
             ))
             labels.append(level)

-        ax.legend(handles, labels, title=self.variables["hue"], **legend_kws)
+        if isinstance(ax_obj, mpl.axes.Axes):
+            ax_obj.legend(handles, labels, title=self.variables["hue"], **legend_kws)
+        else:  # i.e. a FacetGrid. TODO make this better
+            legend_data = dict(zip(labels, handles))
+            ax_obj.add_legend(
+                legend_data,
+                title=self.variables["hue"],
+                label_order=self.var_levels["hue"],
+                **legend_kws
+            )

     def _artist_kws(self, kws, fill, element, multiple, color, alpha):
         """Handle differences between artists in filled/unfilled plots."""
@@ -182,6 +197,16 @@ def _cmap_from_color(self, color):
         colors = np.clip([husl.husl_to_rgb(*hsl) for hsl in ramp], 0, 1)
         return mpl.colors.ListedColormap(colors)

+    def _default_discrete(self):
+        """Find default values for discrete hist esimation based on variable type."""
+        if self.univariate:
+            discrete = self.var_types[self.data_variable] == "categorical"
+        else:
+            discrete_x = self.var_types["x"] == "categorical"
+            discrete_y = self.var_types["y"] == "categorical"
+            discrete = discrete_x, discrete_y
+        return discrete
+
     def _resolve_multiple(
         self,
         curves,
@@ -195,17 +220,33 @@ def _resolve_multiple(
             # support grid / set of bin edges, so we can make a dataframe
             # Reverse the column order to plot from top to bottom
             curves = pd.DataFrame(curves).iloc[:, ::-1]
-            norm_constant = curves.sum(axis="columns")

-            # Take the cumulative sum to stack
-            curves = curves.cumsum(axis="columns")
+            # Find column groups that are nested within col/row variables
+            column_groups = {}
+            for i, keyd in enumerate(map(dict, curves.columns.tolist())):
+                facet_key = keyd.get("col", None), keyd.get("row", None)
+                column_groups.setdefault(facet_key, [])
+                column_groups[facet_key].append(i)
+
+            baselines = curves.copy()
+            for cols in column_groups.values():
+
+                norm_constant = curves.iloc[:, cols].sum(axis="columns")
+
+                # Take the cumulative sum to stack
+                curves.iloc[:, cols] = curves.iloc[:, cols].cumsum(axis="columns")

-            # Normalize by row sum to fill
-            if multiple == "fill":
-                curves = curves.div(norm_constant, axis="index")
+                # Normalize by row sum to fill
+                if multiple == "fill":
+                    curves.iloc[:, cols] = (curves
+                                            .iloc[:, cols]
+                                            .div(norm_constant, axis="index"))

-            # Define where each segment starts
-            baselines = curves.shift(1, axis=1).fillna(0)
+                # Define where each segment starts
+                baselines.iloc[:, cols] = (curves
+                                           .iloc[:, cols]
+                                           .shift(1, axis=1)
+                                           .fillna(0))

         else:

@@ -214,13 +255,15 @@ def _resolve_multiple(

         if multiple == "dodge":

-            n = len(curves)
-            for i, key in enumerate(curves):
-
+            # Account for the unique semantic (non-faceting) levels
+            # This will require rethiniking if we add other semantics!
+            hue_levels = self.var_levels["hue"]
+            n = len(hue_levels)
+            for key in curves:
+                level = dict(key)["hue"]
                 hist = curves[key].reset_index(name="heights")
-
                 hist["widths"] /= n
-                hist["edges"] += i * hist["widths"]
+                hist["edges"] += hue_levels.index(level) * hist["widths"]

                 curves[key] = hist.set_index(["edges", "widths"])["heights"]

@@ -242,28 +285,21 @@ def _compute_univariate_density(
         # Initialize the estimator object
         estimator = KDE(**estimate_kws)

-        cols = list(self.variables)
-        all_data = self.plot_data[cols].dropna()
-
-        if "hue" in self.variables:
-
-            # Access and clean the data
-            all_observations = self.comp_data[cols].dropna()
+        all_data = self.plot_data.dropna()

-            # Define a single grid of support for the PDFs
+        if set(self.variables) - {"x", "y"}:
             if common_grid:
+                all_observations = self.comp_data.dropna()
                 estimator.define_support(all_observations[data_variable])
-
         else:
-
             common_norm = False

         densities = {}

-        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+        for sub_vars, sub_data in self.iter_data("hue", from_comp_data=True):

             # Extract the data points from this sub set and remove nulls
-            sub_data = sub_data[cols].dropna()
+            sub_data = sub_data.dropna()
             observations = sub_data[data_variable]

             observation_variance = observations.var()
@@ -312,10 +348,14 @@ def plot_univariate_histogram(
         legend,
         line_kws,
         estimate_kws,
-        plot_kws,
-        ax,
+        **plot_kws,
     ):

+        # -- Default keyword dicts
+        kde_kws = {} if kde_kws is None else kde_kws.copy()
+        line_kws = {} if line_kws is None else line_kws.copy()
+        estimate_kws = {} if estimate_kws is None else estimate_kws.copy()
+
         # --  Input checking
         _check_argument("multiple", ["layer", "stack", "fill", "dodge"], multiple)
         _check_argument("element", ["bars", "step", "poly"], element)
@@ -337,10 +377,6 @@ def plot_univariate_histogram(
             warnings.warn(msg, UserWarning)
             estimate_kws["bins"] = 10

-        # Check for log scaling on the data axis
-        data_axis = getattr(ax, f"{self.data_variable}axis")
-        log_scale = data_axis.get_scale() == "log"
-
         # Simplify downstream code if we are not normalizing
         if estimate_kws["stat"] == "count":
             common_norm = False
@@ -349,16 +385,11 @@ def plot_univariate_histogram(
         estimator = Histogram(**estimate_kws)
         histograms = {}

-        # Define relevant columns
-        # Note that this works around an issue in core that we can fix, and
-        # then we won't need this messiness.
-        # https://github.com/mwaskom/seaborn/issues/2135
-        cols = list(self.variables)
-
         # Do pre-compute housekeeping related to multiple groups
-        if "hue" in self.variables:
+        # TODO best way to account for facet/semantic?
+        if set(self.variables) - {"x", "y"}:

-            all_data = self.comp_data[cols].dropna()
+            all_data = self.comp_data.dropna()

             if common_bins:
                 all_observations = all_data[self.data_variable]
@@ -368,13 +399,21 @@ def plot_univariate_histogram(
                 )

         else:
-            multiple = None
             common_norm = False

+        # Turn multiple off if no hue or if hue exists but is redundent with faceting
+        facet_vars = [self.variables.get(var, None) for var in ["row", "col"]]
+        if "hue" not in self.variables:
+            multiple = None
+        elif self.variables["hue"] in facet_vars:
+            multiple = None
+
         # Estimate the smoothed kernel densities, for use later
         if kde:
+            # TODO alternatively, clip at min/max bins?
             kde_kws.setdefault("cut", 0)
             kde_kws["cumulative"] = estimate_kws["cumulative"]
+            log_scale = self._log_scaled(self.data_variable)
             densities = self._compute_univariate_density(
                 self.data_variable,
                 common_norm,
@@ -384,11 +423,11 @@ def plot_univariate_histogram(
             )

         # First pass through the data to compute the histograms
-        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+        for sub_vars, sub_data in self.iter_data("hue", from_comp_data=True):

             # Prepare the relevant data
             key = tuple(sub_vars.items())
-            sub_data = sub_data[cols].dropna()
+            sub_data = sub_data.dropna()
             observations = sub_data[self.data_variable]

             if "weights" in self.variables:
@@ -409,7 +448,7 @@ def plot_univariate_histogram(
                 densities[key] *= hist_norm

             # Convert edges back to original units for plotting
-            if log_scale:
+            if self._log_scaled(self.data_variable):
                 edges = np.power(10, edges)

             # Pack the histogram data and metadata together
@@ -453,32 +492,36 @@ def plot_univariate_histogram(

         # Default color without a hue semantic should follow the color cycle
         # Note, this is fairly complicated and awkward, I'd like a better way
+        # TODO and now with the ax business, this is just super annoying FIX!!
         if "hue" not in self.variables:
-            if fill:
-                if self.var_types[self.data_variable] == "datetime":
-                    # Avoid drawing empty fill_between on date axis
-                    # https://github.com/matplotlib/matplotlib/issues/17586
-                    scout = None
-                    default_color = plot_kws.pop(
-                        "color", plot_kws.pop("facecolor", None)
-                    )
-                    if default_color is None:
-                        default_color = "C0"
+            if self.ax is None:
+                default_color = "C0" if color is None else color
+            else:
+                if fill:
+                    if self.var_types[self.data_variable] == "datetime":
+                        # Avoid drawing empty fill_between on date axis
+                        # https://github.com/matplotlib/matplotlib/issues/17586
+                        scout = None
+                        default_color = plot_kws.pop(
+                            "color", plot_kws.pop("facecolor", None)
+                        )
+                        if default_color is None:
+                            default_color = "C0"
+                    else:
+                        artist = mpl.patches.Rectangle
+                        plot_kws = _normalize_kwargs(plot_kws, artist)
+                        scout = self.ax.fill_between([], [], color=color, **plot_kws)
+                        default_color = tuple(scout.get_facecolor().squeeze())
+                        plot_kws.pop("color", None)
                 else:
-                    artist = mpl.patches.Rectangle
+                    artist = mpl.lines.Line2D
                     plot_kws = _normalize_kwargs(plot_kws, artist)
-                    scout = ax.fill_between([], [], **plot_kws)
-                    default_color = tuple(scout.get_facecolor().squeeze())
-                    plot_kws.pop("color", None)
-            else:
-                artist = mpl.lines.Line2D
-                plot_kws = _normalize_kwargs(plot_kws, artist)
-                scout, = ax.plot([], [], **plot_kws)
-                default_color = scout.get_color()
-            if scout is not None:
-                scout.remove()
-
-        # Defeat alpha should depend on other parameters
+                    scout, = self.ax.plot([], [], color=color, **plot_kws)
+                    default_color = scout.get_color()
+                if scout is not None:
+                    scout.remove()
+
+        # Default alpha should depend on other parameters
         if multiple == "layer":
             default_alpha = .5 if element == "bars" else .25
         elif kde:
@@ -490,12 +533,14 @@ def plot_univariate_histogram(
         hist_artists = []

         # Go back through the dataset and draw the plots
-        for sub_vars, _ in self._semantic_subsets("hue", reverse=True):
+        for sub_vars, _ in self.iter_data("hue", reverse=True):

             key = tuple(sub_vars.items())
             hist = histograms[key].rename("heights").reset_index()
             bottom = np.asarray(baselines[key])

+            ax = self._get_axes(sub_vars)
+
             # Define the matplotlib attributes that depend on semantic mapping
             if "hue" in self.variables:
                 color = self._hue_map(sub_vars["hue"])
@@ -604,50 +649,56 @@ def plot_univariate_histogram(

             # Now we handle linewidth, which depends on the scaling of the plot

-            # Needed in some cases to get valid transforms.
-            # Innocuous in other cases?
-            ax.autoscale_view()
+            # Loop through subsets based only on facet variables
+            for sub_vars, _ in self.iter_data():

-            # We will base everything on the minimum bin width
-            hist_metadata = [h.index.to_frame() for _, h in histograms.items()]
-            binwidth = min([
-                h["widths"].min() for h in hist_metadata
-            ])
+                ax = self._get_axes(sub_vars)

-            # Convert binwidtj from data coordinates to pixels
-            pts_x, pts_y = 72 / ax.figure.dpi * (
-                ax.transData.transform([binwidth, binwidth])
-                - ax.transData.transform([0, 0])
-            )
-            if self.data_variable == "x":
-                binwidth_points = pts_x
-            else:
-                binwidth_points = pts_y
+                # Needed in some cases to get valid transforms.
+                # Innocuous in other cases?
+                ax.autoscale_view()
+
+                # We will base everything on the minimum bin width
+                hist_metadata = [h.index.to_frame() for _, h in histograms.items()]
+                binwidth = min([
+                    h["widths"].min() for h in hist_metadata
+                ])

-            # The relative size of the lines depends on the appearance
-            # This is a provisional value and may need more tweaking
-            default_linewidth = .1 * binwidth_points
+                # Convert binwidtj from data coordinates to pixels
+                pts_x, pts_y = 72 / ax.figure.dpi * (
+                    ax.transData.transform([binwidth, binwidth])
+                    - ax.transData.transform([0, 0])
+                )
+                if self.data_variable == "x":
+                    binwidth_points = pts_x
+                else:
+                    binwidth_points = pts_y
+
+                # The relative size of the lines depends on the appearance
+                # This is a provisional value and may need more tweaking
+                default_linewidth = .1 * binwidth_points

-            # Set the attributes
-            for bar in hist_artists:
+                # Set the attributes
+                for bar in hist_artists:

-                # Don't let the lines get too thick
-                max_linewidth = bar.get_linewidth()
-                if not fill:
-                    max_linewidth *= 1.5
+                    # Don't let the lines get too thick
+                    max_linewidth = bar.get_linewidth()
+                    if not fill:
+                        max_linewidth *= 1.5

-                linewidth = min(default_linewidth, max_linewidth)
+                    linewidth = min(default_linewidth, max_linewidth)

-                # If not filling, don't let lines dissapear
-                if not fill:
-                    min_linewidth = .5
-                    linewidth = max(linewidth, min_linewidth)
+                    # If not filling, don't let lines dissapear
+                    if not fill:
+                        min_linewidth = .5
+                        linewidth = max(linewidth, min_linewidth)

-                bar.set_linewidth(linewidth)
+                    bar.set_linewidth(linewidth)

         # --- Finalize the plot ----

         # Axis labels
+        ax = self.ax if self.ax is not None else self.facets.axes.flat[0]
         default_x = default_y = ""
         if self.data_variable == "x":
             default_y = estimator.stat.capitalize()
@@ -663,51 +714,48 @@ def plot_univariate_histogram(
             else:
                 artist = partial(mpl.lines.Line2D, [], [])

+            ax_obj = self.ax if self.ax is not None else self.facets
             self._add_legend(
-                ax, artist, fill, element, multiple, alpha, plot_kws, {},
+                ax_obj, artist, fill, element, multiple, alpha, plot_kws, {},
             )

     def plot_bivariate_histogram(
         self,
-        common_bins,
-        common_norm,
-        thresh,
-        pthresh,
-        pmax,
-        color,
-        legend,
+        common_bins, common_norm,
+        thresh, pthresh, pmax,
+        color, legend,
         cbar, cbar_ax, cbar_kws,
         estimate_kws,
-        plot_kws,
-        ax,
+        **plot_kws,
     ):

-        # Check for log scaling on the data axis
-        log_scale = ax.xaxis.get_scale() == "log", ax.yaxis.get_scale() == "log"
+        # Default keyword dicts
+        cbar_kws = {} if cbar_kws is None else cbar_kws.copy()

         # Now initialize the Histogram estimator
         estimator = Histogram(**estimate_kws)

-        # None that we need to define cols because of some limitations in
-        # the core code, that are on track for resolution. (GH2135)
-        cols = list(self.variables)
-        all_data = self.comp_data[cols].dropna()
-        weights = all_data.get("weights", None)
-
         # Do pre-compute housekeeping related to multiple groups
-        if "hue" in self.variables:
+        if set(self.variables) - {"x", "y"}:
+            all_data = self.comp_data.dropna()
             if common_bins:
                 estimator.define_bin_edges(
                     all_data["x"],
                     all_data["y"],
-                    weights,
+                    all_data.get("weights", None),
                 )
         else:
             common_norm = False

         # -- Determine colormap threshold and norm based on the full data

-        full_heights, _ = estimator(all_data["x"], all_data["y"], weights)
+        full_heights = []
+        for _, sub_data in self.iter_data(from_comp_data=True):
+            sub_data = sub_data.dropna()
+            sub_heights, _ = estimator(
+                sub_data["x"], sub_data["y"], sub_data.get("weights", None)
+            )
+            full_heights.append(sub_heights)

         common_color_norm = "hue" not in self.variables or common_norm

@@ -719,23 +767,19 @@ def plot_bivariate_histogram(
             if pmax is not None:
                 vmax = self._quantile_to_level(full_heights, pmax)
             else:
-                vmax = plot_kws.pop("vmax", full_heights.max())
+                vmax = plot_kws.pop("vmax", np.max(full_heights))
         else:
             vmax = None

-        # pcolormesh is going to turn the grid off, but we want to keep it
-        # I'm not sure if there's a better way to get the grid state
-        x_grid = any([l.get_visible() for l in ax.xaxis.get_gridlines()])
-        y_grid = any([l.get_visible() for l in ax.yaxis.get_gridlines()])
-
         # Get a default color
+        # (We won't follow the color cycle here, as multiple plots are unlikely)
         if color is None:
             color = "C0"

         # --- Loop over data (subsets) and draw the histograms
-        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+        for sub_vars, sub_data in self.iter_data("hue", from_comp_data=True):

-            sub_data = sub_data[cols].dropna()
+            sub_data = sub_data.dropna()

             if sub_data.empty:
                 continue
@@ -747,9 +791,10 @@ def plot_bivariate_histogram(
                 weights=sub_data.get("weights", None),
             )

-            if log_scale[0]:
+            # Check for log scaling on the data axis
+            if self._log_scaled("x"):
                 x_edges = np.power(10, x_edges)
-            if log_scale[1]:
+            if self._log_scaled("y"):
                 y_edges = np.power(10, y_edges)

             # Apply scaling to normalize across groups
@@ -779,7 +824,14 @@ def plot_bivariate_histogram(
             if thresh is not None:
                 heights = np.ma.masked_less_equal(heights, thresh)

-            # Draw the plot
+            # Get the axes for this plot
+            ax = self._get_axes(sub_vars)
+
+            # pcolormesh is going to turn the grid off, but we want to keep it
+            # I'm not sure if there's a better way to get the grid state
+            x_grid = any([l.get_visible() for l in ax.xaxis.get_gridlines()])
+            y_grid = any([l.get_visible() for l in ax.yaxis.get_gridlines()])
+
             mesh = ax.pcolormesh(
                 x_edges,
                 y_edges,
@@ -787,6 +839,11 @@ def plot_bivariate_histogram(
                 **artist_kws,
             )

+            # pcolormesh sets sticky edges, but we only want them if not thresholding
+            if thresh is not None:
+                mesh.sticky_edges.x[:] = []
+                mesh.sticky_edges.y[:] = []
+
             # Add an optional colorbar
             # Note, we want to improve this. When hue is used, it will stack
             # multiple colorbars with redundant ticks in an ugly way.
@@ -795,12 +852,15 @@ def plot_bivariate_histogram(
             if cbar:
                 ax.figure.colorbar(mesh, cbar_ax, ax, **cbar_kws)

+            # Reset the grid state
+            if x_grid:
+                ax.grid(True, axis="x")
+            if y_grid:
+                ax.grid(True, axis="y")
+
         # --- Finalize the plot
-        if x_grid:
-            ax.grid(True, axis="x")
-        if y_grid:
-            ax.grid(True, axis="y")

+        ax = self.ax if self.ax is not None else self.facets.axes.flat[0]
         self._add_axis_labels(ax)

         if "hue" in self.variables and legend:
@@ -811,8 +871,9 @@ def plot_bivariate_histogram(

             artist_kws = {}
             artist = partial(mpl.patches.Patch)
+            ax_obj = self.ax if self.ax is not None else self.facets
             self._add_legend(
-                ax, artist, True, False, "layer", 1, artist_kws, {},
+                ax_obj, artist, True, False, "layer", 1, artist_kws, {},
             )

     def plot_univariate_density(
@@ -823,10 +884,13 @@ def plot_univariate_density(
         fill,
         legend,
         estimate_kws,
-        plot_kws,
-        ax,
+        **plot_kws,
     ):

+        # Handle conditional defaults
+        if fill is None:
+            fill = multiple in ("stack", "fill")
+
         # Preprocess the matplotlib keyword dictionaries
         if fill:
             artist = mpl.collections.PolyCollection
@@ -837,21 +901,21 @@ def plot_univariate_density(
         # Input checking
         _check_argument("multiple", ["layer", "stack", "fill"], multiple)

-        # Check for log scaling on the data axis
-        data_axis = getattr(ax, f"{self.data_variable}axis")
-        log_scale = data_axis.get_scale() == "log"
-
         # Always share the evaluation grid when stacking
-        if "hue" in self.variables and multiple in ("stack", "fill"):
+        subsets = bool(set(self.variables) - {"x", "y"})
+        if subsets and multiple in ("stack", "fill"):
             common_grid = True

+        # Check if the data axis is log scaled
+        log_scale = self._log_scaled(self.data_variable)
+
         # Do the computation
         densities = self._compute_univariate_density(
             self.data_variable,
             common_norm,
             common_grid,
             estimate_kws,
-            log_scale
+            log_scale,
         )

         # Note: raises when no hue and multiple != layer. A problem?
@@ -869,32 +933,37 @@ def plot_univariate_density(

         # Handle default visual attributes
         if "hue" not in self.variables:
-            if fill:
-                if self.var_types[self.data_variable] == "datetime":
-                    # Avoid drawing empty fill_between on date axis
-                    # https://github.com/matplotlib/matplotlib/issues/17586
-                    scout = None
-                    default_color = plot_kws.pop(
-                        "color", plot_kws.pop("facecolor", None)
-                    )
-                    if default_color is None:
-                        default_color = "C0"
-                else:
-                    scout = ax.fill_between([], [], **plot_kws)
-                    default_color = tuple(scout.get_facecolor().squeeze())
-                plot_kws.pop("color", None)
+            if self.ax is None:
+                default_color = plot_kws.pop("color", "C0")
             else:
-                scout, = ax.plot([], [], **plot_kws)
-                default_color = scout.get_color()
-            if scout is not None:
-                scout.remove()
+                if fill:
+                    if self.var_types[self.data_variable] == "datetime":
+                        # Avoid drawing empty fill_between on date axis
+                        # https://github.com/matplotlib/matplotlib/issues/17586
+                        scout = None
+                        default_color = plot_kws.pop(
+                            "color", plot_kws.pop("facecolor", None)
+                        )
+                        if default_color is None:
+                            default_color = "C0"
+                    else:
+                        scout = self.ax.fill_between([], [], **plot_kws)
+                        default_color = tuple(scout.get_facecolor().squeeze())
+                    plot_kws.pop("color", None)
+                else:
+                    scout, = self.ax.plot([], [], **plot_kws)
+                    default_color = scout.get_color()
+                if scout is not None:
+                    scout.remove()
+
+        plot_kws.pop("color", None)

         default_alpha = .25 if multiple == "layer" else .75
         alpha = plot_kws.pop("alpha", default_alpha)  # TODO make parameter?

         # Now iterate through the subsets and draw the densities
         # We go backwards so stacked densities read from top-to-bottom
-        for sub_vars, _ in self._semantic_subsets("hue", reverse=True):
+        for sub_vars, _ in self.iter_data("hue", reverse=True):

             # Extract the support grid and density curve for this level
             key = tuple(sub_vars.items())
@@ -905,6 +974,8 @@ def plot_univariate_density(
             support = density.index
             fill_from = baselines[key]

+            ax = self._get_axes(sub_vars)
+
             # Modify the matplotlib attributes from semantic mapping
             if "hue" in self.variables:
                 color = self._hue_map(sub_vars["hue"])
@@ -941,6 +1012,8 @@ def plot_univariate_density(
                 artist.sticky_edges.y[:] = sticky_support

         # --- Finalize the plot ----
+
+        ax = self.ax if self.ax is not None else self.facets.axes.flat[0]
         default_x = default_y = ""
         if self.data_variable == "x":
             default_y = "Density"
@@ -955,8 +1028,9 @@ def plot_univariate_density(
             else:
                 artist = partial(mpl.lines.Line2D, [], [])

+            ax_obj = self.ax if self.ax is not None else self.facets
             self._add_legend(
-                ax, artist, fill, False, multiple, alpha, plot_kws, {},
+                ax_obj, artist, fill, False, multiple, alpha, plot_kws, {},
             )

     def plot_bivariate_density(
@@ -971,8 +1045,7 @@ def plot_bivariate_density(
         cbar_ax,
         cbar_kws,
         estimate_kws,
-        contour_kws,
-        ax,
+        **contour_kws,
     ):

         contour_kws = contour_kws.copy()
@@ -982,22 +1055,15 @@ def plot_bivariate_density(
         if "hue" not in self.variables:
             common_norm = False

-        # See other notes about GH2135
-        cols = list(self.variables)
-        all_data = self.plot_data[cols].dropna()
-
-        # Check for log scaling on iether axis
-        scalex = ax.xaxis.get_scale() == "log"
-        scaley = ax.yaxis.get_scale() == "log"
-        log_scale = scalex, scaley
+        all_data = self.plot_data.dropna()

         # Loop through the subsets and estimate the KDEs
         densities, supports = {}, {}

-        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+        for sub_vars, sub_data in self.iter_data("hue", from_comp_data=True):

             # Extract the data points from this sub set and remove nulls
-            sub_data = sub_data[cols].dropna()
+            sub_data = sub_data.dropna()
             observations = sub_data[["x", "y"]]

             # Extract the weights for this subset of observations
@@ -1018,13 +1084,12 @@ def plot_bivariate_density(
             density, support = estimator(*observations, weights=weights)

             # Transform the support grid back to the original scale
-            if log_scale is not None:
-                xx, yy = support
-                if log_scale[0]:
-                    xx = np.power(10, xx)
-                if log_scale[1]:
-                    yy = np.power(10, yy)
-                support = xx, yy
+            xx, yy = support
+            if self._log_scaled("x"):
+                xx = np.power(10, xx)
+            if self._log_scaled("y"):
+                yy = np.power(10, yy)
+            support = xx, yy

             # Apply a scaling factor so that the integral over all subsets is 1
             if common_norm:
@@ -1041,7 +1106,7 @@ def plot_bivariate_density(
             if min(levels) < 0 or max(levels) > 1:
                 raise ValueError("levels must be in [0, 1]")

-        # Transfrom from iso-proportions to iso-densities
+        # Transform from iso-proportions to iso-densities
         if common_norm:
             common_levels = self._quantile_to_level(
                 list(densities.values()), levels,
@@ -1054,9 +1119,12 @@ def plot_bivariate_density(
             }

         # Get a default single color from the attribute cycle
-        scout, = ax.plot([], color=color)
-        default_color = scout.get_color()
-        scout.remove()
+        if self.ax is None:
+            default_color = "C0" if color is None else color
+        else:
+            scout, = self.ax.plot([], color=color)
+            default_color = scout.get_color()
+            scout.remove()

         # Define the coloring of the contours
         if "hue" in self.variables:
@@ -1073,16 +1141,8 @@ def plot_bivariate_density(
             if not fill and not coloring_given:
                 contour_kws["colors"] = [default_color]

-        # Choose the function to plot with
-        # TODO could add a pcolormesh based option as well
-        # Which would look something like element="raster"
-        if fill:
-            contour_func = ax.contourf
-        else:
-            contour_func = ax.contour
-
         # Loop through the subsets again and plot the data
-        for sub_vars, _ in self._semantic_subsets("hue"):
+        for sub_vars, _ in self.iter_data("hue"):

             if "hue" in sub_vars:
                 color = self._hue_map(sub_vars["hue"])
@@ -1091,6 +1151,16 @@ def plot_bivariate_density(
                 else:
                     contour_kws["colors"] = [color]

+            ax = self._get_axes(sub_vars)
+
+            # Choose the function to plot with
+            # TODO could add a pcolormesh based option as well
+            # Which would look something like element="raster"
+            if fill:
+                contour_func = ax.contourf
+            else:
+                contour_func = ax.contour
+
             key = tuple(sub_vars.items())
             if key not in densities:
                 continue
@@ -1116,6 +1186,7 @@ def plot_bivariate_density(
                 ax.figure.colorbar(cset, cbar_ax, ax, **cbar_kws)

         # --- Finalize the plot
+        ax = self.ax if self.ax is not None else self.facets.axes.flat[0]
         self._add_axis_labels(ax)

         if "hue" in self.variables and legend:
@@ -1130,14 +1201,12 @@ def plot_bivariate_density(
             else:
                 artist = partial(mpl.lines.Line2D, [], [])

+            ax_obj = self.ax if self.ax is not None else self.facets
             self._add_legend(
-                ax, artist, fill, False, "layer", 1, artist_kws, {},
+                ax_obj, artist, fill, False, "layer", 1, artist_kws, {},
             )

-    def plot_univariate_ecdf(self, estimate_kws, legend, plot_kws, ax):
-
-        # TODO see notes elsewhere about GH2135
-        cols = list(self.variables)
+    def plot_univariate_ecdf(self, estimate_kws, legend, **plot_kws):

         estimator = ECDF(**estimate_kws)

@@ -1146,18 +1215,18 @@ def plot_univariate_ecdf(self, estimate_kws, legend, plot_kws, ax):
         plot_kws["drawstyle"] = drawstyles[self.data_variable]

         # Loop through the subsets, transform and plot the data
-        for sub_vars, sub_data in self._semantic_subsets(
+        for sub_vars, sub_data in self.iter_data(
             "hue", reverse=True, from_comp_data=True,
         ):

             # Compute the ECDF
-            sub_data = sub_data[cols].dropna()
+            sub_data = sub_data.dropna()
             if sub_data.empty:
                 continue

             observations = sub_data[self.data_variable]
             weights = sub_data.get("weights", None)
-            stat, vals = estimator(observations, weights)
+            stat, vals = estimator(observations, weights=weights)

             # Assign attributes based on semantic mapping
             artist_kws = plot_kws.copy()
@@ -1178,11 +1247,13 @@ def plot_univariate_ecdf(self, estimate_kws, legend, plot_kws, ax):
                 top_edge = 1

             # Draw the line for this subset
+            ax = self._get_axes(sub_vars)
             artist, = ax.plot(*plot_args, **artist_kws)
             sticky_edges = getattr(artist.sticky_edges, stat_variable)
             sticky_edges[:] = 0, top_edge

         # --- Finalize the plot ----
+        ax = self.ax if self.ax is not None else self.facets.axes.flat[0]
         stat = estimator.stat.capitalize()
         default_x = default_y = ""
         if self.data_variable == "x":
@@ -1194,55 +1265,62 @@ def plot_univariate_ecdf(self, estimate_kws, legend, plot_kws, ax):
         if "hue" in self.variables and legend:
             artist = partial(mpl.lines.Line2D, [], [])
             alpha = plot_kws.get("alpha", 1)
+            ax_obj = self.ax if self.ax is not None else self.facets
             self._add_legend(
-                ax, artist, False, False, None, alpha, plot_kws, {},
+                ax_obj, artist, False, False, None, alpha, plot_kws, {},
             )

-    def plot_rug(self, height, expand_margins, legend, ax, kws):
+    def plot_rug(self, height, expand_margins, legend, **kws):

         kws = _normalize_kwargs(kws, mpl.lines.Line2D)

-        # TODO we need to abstract this logic
-        scout, = ax.plot([], [], **kws)
-        kws["color"] = kws.pop("color", scout.get_color())
-        scout.remove()
+        if self.ax is None:
+            kws["color"] = kws.pop("color", "C0")
+        else:
+            scout, = self.ax.plot([], [], **kws)
+            kws["color"] = kws.pop("color", scout.get_color())
+            scout.remove()

-        kws.setdefault("linewidth", 1)
+        for sub_vars, sub_data, in self.iter_data():

-        if expand_margins:
-            xmarg, ymarg = ax.margins()
-            if "x" in self.variables:
-                ymarg += height * 2
-            if "y" in self.variables:
-                xmarg += height * 2
-            ax.margins(x=xmarg, y=ymarg)
+            ax = self._get_axes(sub_vars)

-        if "hue" in self.variables:
-            kws.pop("c", None)
-            kws.pop("color", None)
+            kws.setdefault("linewidth", 1)

-        if "x" in self.variables:
-            self._plot_single_rug("x", height, ax, kws)
-        if "y" in self.variables:
-            self._plot_single_rug("y", height, ax, kws)
+            if expand_margins:
+                xmarg, ymarg = ax.margins()
+                if "x" in self.variables:
+                    ymarg += height * 2
+                if "y" in self.variables:
+                    xmarg += height * 2
+                ax.margins(x=xmarg, y=ymarg)

-        # --- Finalize the plot
-        self._add_axis_labels(ax)
-        if "hue" in self.variables and legend:
-            # TODO ideally i'd like the legend artist to look like a rug
-            legend_artist = partial(mpl.lines.Line2D, [], [])
-            self._add_legend(
-                ax, legend_artist, False, False, None, 1, {}, {},
-            )
+            if "hue" in self.variables:
+                kws.pop("c", None)
+                kws.pop("color", None)

-    def _plot_single_rug(self, var, height, ax, kws):
+            if "x" in self.variables:
+                self._plot_single_rug(sub_data, "x", height, ax, kws)
+            if "y" in self.variables:
+                self._plot_single_rug(sub_data, "y", height, ax, kws)
+
+            # --- Finalize the plot
+            self._add_axis_labels(ax)
+            if "hue" in self.variables and legend:
+                # TODO ideally i'd like the legend artist to look like a rug
+                legend_artist = partial(mpl.lines.Line2D, [], [])
+                self._add_legend(
+                    ax, legend_artist, False, False, None, 1, {}, {},
+                )
+
+    def _plot_single_rug(self, sub_data, var, height, ax, kws):
         """Draw a rugplot along one axis of the plot."""
-        vector = self.plot_data[var]
+        vector = sub_data[var]
         n = len(vector)

         # We'll always add a single collection with varying colors
         if "hue" in self.variables:
-            colors = self._hue_map(self.plot_data["hue"])
+            colors = self._hue_map(sub_data["hue"])
         else:
             colors = None

@@ -1270,6 +1348,11 @@ def _plot_single_rug(self, var, height, ax, kws):
         ax.autoscale_view(scalex=var == "x", scaley=var == "y")

+class _DistributionFacetPlotter(_DistributionPlotter):
+
+    semantics = _DistributionPlotter.semantics + ("col", "row")
+
+
 # ==================================================================================== #
 # External API
 # ==================================================================================== #
@@ -1305,16 +1388,6 @@ def histplot(
     if ax is None:
         ax = plt.gca()

-    # TODO move these defaults inside the plot functions
-    if kde_kws is None:
-        kde_kws = {}
-
-    if line_kws is None:
-        line_kws = {}
-
-    if cbar_kws is None:
-        cbar_kws = {}
-
     # Check for a specification that lacks x/y data and return early
     if not p.has_xy_data:
         return ax
@@ -1323,16 +1396,8 @@ def histplot(
     p._attach(ax, log_scale=log_scale)

     # Default to discrete bins for categorical variables
-    # Note that having this logic here may constrain plans for distplot
-    # It can move inside the plot_ functions, it will just need to modify
-    # the estimate_kws dictionary (I am not sure how we feel about that)
     if discrete is None:
-        if p.univariate:
-            discrete = p.var_types[p.data_variable] == "categorical"
-        else:
-            discrete_x = p.var_types["x"] == "categorical"
-            discrete_y = p.var_types["y"] == "categorical"
-            discrete = discrete_x, discrete_y
+        discrete = p._default_discrete()

     estimate_kws = dict(
         stat=stat,
@@ -1345,9 +1410,6 @@ def histplot(

     if p.univariate:

-        if "hue" not in p.variables:
-            kwargs["color"] = color
-
         p.plot_univariate_histogram(
             multiple=multiple,
             element=element,
@@ -1356,13 +1418,12 @@ def histplot(
             common_norm=common_norm,
             common_bins=common_bins,
             kde=kde,
-            kde_kws=kde_kws.copy(),
+            kde_kws=kde_kws,
             color=color,
             legend=legend,
-            estimate_kws=estimate_kws.copy(),
-            line_kws=line_kws.copy(),
-            plot_kws=kwargs,
-            ax=ax,
+            estimate_kws=estimate_kws,
+            line_kws=line_kws,
+            **kwargs,
         )

     else:
@@ -1379,8 +1440,7 @@ def histplot(
             cbar_ax=cbar_ax,
             cbar_kws=cbar_kws,
             estimate_kws=estimate_kws,
-            plot_kws=kwargs,
-            ax=ax,
+            **kwargs,
         )

     return ax
@@ -1397,7 +1457,7 @@ def histplot(
 frequency, density or probability mass, and it can add a smooth curve obtained
 using a kernel density estimate, similar to :func:`kdeplot`.

-More information is provided in the :ref:`user guide <userguide_hist>`.
+More information is provided in the :ref:`user guide <tutorial_hist>`.

 Parameters
 ----------
@@ -1481,11 +1541,11 @@ def histplot(

 See Also
 --------
+{seealso.displot}
 {seealso.kdeplot}
 {seealso.rugplot}
 {seealso.ecdfplot}
 {seealso.jointplot}
-distplot

 Notes
 -----
@@ -1506,6 +1566,9 @@ def histplot(
 Examples
 --------

+See the API documentation for the axes-level functions for more details
+about the breadth of options available for each plot kind.
+
 .. include:: ../docstrings/histplot.rst

 """.format(
@@ -1651,10 +1714,6 @@ def kdeplot(

     if p.univariate:

-        # Set defaults that depend on other parameters
-        if fill is None:
-            fill = multiple in ("stack", "fill")
-
         plot_kws = kwargs.copy()
         if color is not None:
             plot_kws["color"] = color
@@ -1666,8 +1725,7 @@ def kdeplot(
             fill=fill,
             legend=legend,
             estimate_kws=estimate_kws,
-            plot_kws=plot_kws,
-            ax=ax
+            **plot_kws,
         )

     else:
@@ -1683,8 +1741,7 @@ def kdeplot(
             cbar_ax=cbar_ax,
             cbar_kws=cbar_kws,
             estimate_kws=estimate_kws,
-            contour_kws=kwargs,
-            ax=ax,
+            **kwargs,
         )

     return ax
@@ -1698,7 +1755,7 @@ def kdeplot(
 represents the data using a continuous probability density curve in one or
 more dimensions.

-The approach is explained further in the :ref:`user guide <userguide_kde>`.
+The approach is explained further in the :ref:`user guide <tutorial_kde>`.

 Relative to a histogram, KDE can produce a plot that is less cluttered and
 more interpretable, especially when drawing multiple distributions. But it
@@ -1790,12 +1847,11 @@ def kdeplot(

 See Also
 --------
-{seealso.violinplot}
+{seealso.displot}
 {seealso.histplot}
 {seealso.ecdfplot}
-{seealso.rugplot}
 {seealso.jointplot}
-distplot
+{seealso.violinplot}

 Notes
 -----
@@ -1887,8 +1943,7 @@ def ecdfplot(
     p.plot_univariate_ecdf(
         estimate_kws=estimate_kws,
         legend=legend,
-        plot_kws=kwargs,
-        ax=ax,
+        **kwargs,
     )

     return ax
@@ -1906,7 +1961,7 @@ def ecdfplot(
 (such as its central tendency, variance, and the presence of any bimodality)
 may not be as intuitive.

-More information is provided in the :ref:`user guide <userguide_ecdf>`.
+More information is provided in the :ref:`user guide <tutorial_ecdf>`.

 Parameters
 ----------
@@ -1930,10 +1985,10 @@ def ecdfplot(

 See Also
 --------
+{seealso.displot}
 {seealso.histplot}
 {seealso.kdeplot}
 {seealso.rugplot}
-distplot

 Examples
 --------
@@ -2010,10 +2065,9 @@ def rugplot(

     if ax is None:
         ax = plt.gca()
-
     p._attach(ax)

-    p.plot_rug(height, expand_margins, legend, ax, kwargs)
+    p.plot_rug(height, expand_margins, legend, **kwargs)

     return ax

@@ -2066,6 +2120,259 @@ def rugplot(
 )

+def displot(
+    data=None, *,
+    # Vector variables
+    x=None, y=None, hue=None, row=None, col=None, weights=None,
+    # Other plot parameters
+    kind="hist", rug=False, rug_kws=None, log_scale=None, legend=True,
+    # Hue-mapping parameters
+    palette=None, hue_order=None, hue_norm=None, color=None,
+    # Faceting parameters
+    col_wrap=None, row_order=None, col_order=None,
+    height=5, aspect=1, facet_kws=None,
+    **kwargs,
+):
+
+    p = _DistributionFacetPlotter(
+        data=data,
+        variables=_DistributionFacetPlotter.get_semantics(locals())
+    )
+
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
+
+    _check_argument("kind", ["hist", "kde", "ecdf"], kind)
+
+    # --- Initialize the FacetGrid object
+
+    # Check for attempt to plot onto specific axes and warn
+    if "ax" in kwargs:
+        msg = (
+            "`displot` is a figure-level function and does not accept "
+            "the ax= paramter. You may wish to try {}plot.".format(kind)
+        )
+        warnings.warn(msg, UserWarning)
+        kwargs.pop("ax")
+
+    for var in ["row", "col"]:
+        # Handle faceting variables that lack name information
+        if var in p.variables and p.variables[var] is None:
+            p.variables[var] = f"_{var}_"
+
+    # Adapt the plot_data dataframe for use with FacetGrid
+    data = p.plot_data.rename(columns=p.variables)
+    data = data.loc[:, ~data.columns.duplicated()]
+
+    col_name = p.variables.get("col", None)
+    row_name = p.variables.get("row", None)
+
+    if facet_kws is None:
+        facet_kws = {}
+
+    g = FacetGrid(
+        data=data, row=row_name, col=col_name,
+        col_wrap=col_wrap, height=height, aspect=aspect,
+        **facet_kws,
+    )
+
+    # Now attach the axes object to the plotter object
+    if kind == "kde":
+        allowed_types = ["numeric", "datetime"]
+    else:
+        allowed_types = None
+    p._attach(g, allowed_types=allowed_types, log_scale=log_scale)
+
+    # Check for a specification that lacks x/y data and return early
+    if not p.has_xy_data:
+        return g
+
+    kwargs["legend"] = legend
+
+    # --- Draw the plots
+
+    if kind == "hist":
+
+        hist_kws = kwargs.copy()
+
+        # Extract the parameters that will go directly to Histogram
+        estimate_defaults = {}
+        _assign_default_kwargs(estimate_defaults, Histogram.__init__, histplot)
+
+        estimate_kws = {}
+        for key, default_val in estimate_defaults.items():
+            estimate_kws[key] = hist_kws.pop(key, default_val)
+
+        # Handle derivative defaults
+        if estimate_kws["discrete"] is None:
+            estimate_kws["discrete"] = p._default_discrete()
+
+        hist_kws["estimate_kws"] = estimate_kws
+
+        if p.univariate:
+
+            if "hue" not in p.variables:
+                hist_kws.setdefault("color", color)
+
+            _assign_default_kwargs(hist_kws, p.plot_univariate_histogram, histplot)
+            p.plot_univariate_histogram(**hist_kws)
+
+        else:
+
+            _assign_default_kwargs(hist_kws, p.plot_bivariate_histogram, histplot)
+            p.plot_bivariate_histogram(**hist_kws)
+
+    elif kind == "kde":
+
+        kde_kws = kwargs.copy()
+
+        # Extract the parameters that will go directly to KDE
+        estimate_defaults = {}
+        _assign_default_kwargs(estimate_defaults, KDE.__init__, kdeplot)
+
+        estimate_kws = {}
+        for key, default_val in estimate_defaults.items():
+            estimate_kws[key] = kde_kws.pop(key, default_val)
+
+        kde_kws["estimate_kws"] = estimate_kws
+        kde_kws["color"] = color
+
+        if p.univariate:
+
+            _assign_default_kwargs(kde_kws, p.plot_univariate_density, kdeplot)
+            p.plot_univariate_density(**kde_kws)
+
+        else:
+
+            _assign_default_kwargs(kde_kws, p.plot_bivariate_density, kdeplot)
+            p.plot_bivariate_density(**kde_kws)
+
+    elif kind == "ecdf":
+
+        ecdf_kws = kwargs.copy()
+
+        # Extract the parameters that will go directly to the estimator
+        estimate_kws = {}
+        estimate_defaults = {}
+        _assign_default_kwargs(estimate_defaults, ECDF.__init__, ecdfplot)
+        for key, default_val in estimate_defaults.items():
+            estimate_kws[key] = ecdf_kws.pop(key, default_val)
+
+        ecdf_kws["estimate_kws"] = estimate_kws
+        ecdf_kws["color"] = color
+
+        if p.univariate:
+
+            _assign_default_kwargs(ecdf_kws, p.plot_univariate_ecdf, ecdfplot)
+            p.plot_univariate_ecdf(**ecdf_kws)
+
+        else:
+
+            raise NotImplementedError("Bivariate ECDF plots are not implemented")
+
+    # All plot kinds can include a rug
+    if rug:
+        # TODO with expand_margins=True, each facet expands margins... annoying!
+        if rug_kws is None:
+            rug_kws = {}
+        _assign_default_kwargs(rug_kws, p.plot_rug, rugplot)
+        rug_kws["legend"] = False
+        if color is not None:
+            rug_kws["color"] = color
+        p.plot_rug(**rug_kws)
+
+    # Call FacetGrid annotation methods
+    # Note that the legend is currently set inside the plotting method
+    g.set_axis_labels(
+        x_var=p.variables.get("x", g.axes.flat[0].get_xlabel()),
+        y_var=p.variables.get("y", g.axes.flat[0].get_ylabel()),
+    )
+    g.set_titles()
+    g.tight_layout()
+
+    return g
+
+
+displot.__doc__ = """\
+Figure-level interface for drawing distribution plots onto a FacetGrid.
+
+This function provides access to several approaches for visualizing the
+univariate or bivariate distribution of data, including subsets of data
+defined by semantic mapping and faceting across multiple subplots. The
+``kind`` parameter selects the approach to use:
+
+- :func:`histplot` (with ``kind="hist"``; the default)
+- :func:`kdeplot` (with ``kind="kde"``)
+- :func:`ecdfplot` (with ``kind="ecdf"``; univariate-only)
+
+Additionally, a :func:`rugplot` can be added to any kind of plot to show
+individual observations.
+
+Extra keyword arguments are passed to the underlying function, so you should
+refer to the documentation for each to understand the complete set of options
+for making plots with this interface.
+
+More information about the relative strengths and weaknesses of each approach
+is provided in the :ref:`user guide <distribution_tutorial>`. The distinction
+between figure-level and axes-level functions is also explained further in the
+:ref:`user guide <userguide_function_types>`.
+
+Parameters
+----------
+{params.core.data}
+{params.core.xy}
+{params.core.hue}
+{params.facets.rowcol}
+kind : {{"hist", "kde", "ecdf"}}
+    Approach for visualizing the data. Selects the underlying plotting function
+    and determines the additional set of valid parameters.
+rug : bool
+    If True, show each observation with marginal ticks (as in :func:`rugplot`).
+rug_kws : dict
+    Parameters to control the appearance of the rug plot.
+{params.dist.log_scale}
+{params.dist.legend}
+{params.core.palette}
+{params.core.hue_order}
+{params.core.hue_norm}
+{params.core.color}
+{params.facets.col_wrap}
+{params.facets.rowcol_order}
+{params.facets.height}
+{params.facets.aspect}
+{params.facets.facet_kws}
+kwargs
+    Other keyword arguments are documented with the relevant axes-level function:
+
+    - :func:`histplot` (with ``kind="hist"``)
+    - :func:`kdeplot` (with ``kind="kde"``)
+    - :func:`ecdfplot` (with ``kind="ecdf"``)
+
+Returns
+-------
+{returns.facetgrid}
+
+See Also
+--------
+{seealso.histplot}
+{seealso.kdeplot}
+{seealso.rugplot}
+{seealso.ecdfplot}
+{seealso.jointplot}
+
+Examples
+--------
+
+.. include:: ../docstrings/displot.rst
+
+""".format(
+    params=_param_docs,
+    returns=_core_docs["returns"],
+    seealso=_core_docs["seealso"],
+)
+
+
+# =========================================================================== #
+# DEPRECATED FUNCTIONS LIVE BELOW HERE
 # =========================================================================== #

@@ -2083,16 +2390,20 @@ def _freedman_diaconis_bins(a):
         return int(np.ceil((a.max() - a.min()) / h))

-@_deprecate_positional_args
-def distplot(
-    *,
-    x=None,
-    bins=None, hist=True, kde=True, rug=False, fit=None,
-    hist_kws=None, kde_kws=None, rug_kws=None, fit_kws=None,
-    color=None, vertical=False, norm_hist=False, axlabel=None,
-    label=None, ax=None, a=None,
-):
-    """Flexibly plot a univariate distribution of observations.
+def distplot(a=None, bins=None, hist=True, kde=True, rug=False, fit=None,
+             hist_kws=None, kde_kws=None, rug_kws=None, fit_kws=None,
+             color=None, vertical=False, norm_hist=False, axlabel=None,
+             label=None, ax=None, x=None):
+    """DEPRECATED: Flexibly plot a univariate distribution of observations.
+
+    .. warning::
+       This function is deprecated and will be removed in a future version.
+       Please adapt your code to use one of two new functions:
+
+       - :func:`displot`, a figure-level function with a similar flexibility
+         over the kind of plot to draw
+       - :func:`histplot`, an axes-level function for plotting histograms,
+         including with kernel density smoothing

     This function combines the matplotlib ``hist`` function (with automatic
     calculation of a good default bin size) with the seaborn :func:`kdeplot`
@@ -2101,8 +2412,7 @@ def distplot(

     Parameters
     ----------
-
-    x : Series, 1d-array, or list.
+    a : Series, 1d-array, or list.
         Observed data. If this is a Series object with a ``name`` attribute,
         the name will be used to label the data axis.
     bins : argument for matplotlib hist(), or None, optional
@@ -2163,7 +2473,7 @@ def distplot(
         >>> import seaborn as sns, numpy as np
         >>> sns.set(); np.random.seed(0)
         >>> x = np.random.randn(100)
-        >>> ax = sns.distplot(x=x)
+        >>> ax = sns.distplot(x)

     Use Pandas objects to get an informative axis label:

@@ -2172,14 +2482,14 @@ def distplot(

         >>> import pandas as pd
         >>> x = pd.Series(x, name="x variable")
-        >>> ax = sns.distplot(x=x)
+        >>> ax = sns.distplot(x)

     Plot the distribution with a kernel density estimate and rug plot:

     .. plot::
         :context: close-figs

-        >>> ax = sns.distplot(x=x, rug=True, hist=False)
+        >>> ax = sns.distplot(x, rug=True, hist=False)

     Plot the distribution with a histogram and maximum likelihood gaussian
     distribution fit:
@@ -2188,42 +2498,51 @@ def distplot(
         :context: close-figs

         >>> from scipy.stats import norm
-        >>> ax = sns.distplot(x=x, fit=norm, kde=False)
+        >>> ax = sns.distplot(x, fit=norm, kde=False)

     Plot the distribution on the vertical axis:

     .. plot::
         :context: close-figs

-        >>> ax = sns.distplot(x=x, vertical=True)
+        >>> ax = sns.distplot(x, vertical=True)

     Change the color of all the plot elements:

     .. plot::
-        :context: close-figs

+        :context: close-figs
         >>> sns.set_color_codes()
-        >>> ax = sns.distplot(x=x, color="y")
+        >>> ax = sns.distplot(x, color="y")

     Pass specific parameters to the underlying plot functions:

     .. plot::
         :context: close-figs

-        >>> ax = sns.distplot(x=x, rug=True, rug_kws={"color": "g"},
+        >>> ax = sns.distplot(x, rug=True, rug_kws={"color": "g"},
         ...                   kde_kws={"color": "k", "lw": 3, "label": "KDE"},
         ...                   hist_kws={"histtype": "step", "linewidth": 3,
         ...                             "alpha": 1, "color": "g"})

     """
-    # Handle deprecation of ``a```
-    if a is not None:
-        msg = "The `a` parameter is now called `x`. Please update your code."
-        warnings.warn(msg)
+
+    if kde and not hist:
+        axes_level_suggestion = (
+            "`kdeplot` (an axes-level function for kernel density plots)."
+        )
     else:
-        a = x
+        axes_level_suggestion = (
+            "`histplot` (an axes-level function for histograms)."
+        )
+
+    msg = (
+        "`distplot` is a deprecated function and will be removed in a future version. "
+        "Please adapt your code to use either `displot` (a figure-level function with "
+        "similar flexibility) or " + axes_level_suggestion
+    )
+    warnings.warn(msg, FutureWarning)

-    # Default to drawing on the currently-active axes
     if ax is None:
         ax = plt.gca()

@@ -2234,6 +2553,10 @@ def distplot(
         if axlabel is not None:
             label_ax = True

+    # Support new-style API
+    if x is not None:
+        a = x
+
     # Make a a 1-d float array
     a = np.asarray(a, np.float)
     if a.ndim > 1:
@@ -2286,14 +2609,14 @@ def distplot(

     if kde:
         kde_color = kde_kws.pop("color", color)
-        kdeplot(x=a, vertical=vertical, ax=ax, color=kde_color, **kde_kws)
+        kdeplot(a, vertical=vertical, ax=ax, color=kde_color, **kde_kws)
         if kde_color != color:
             kde_kws["color"] = kde_color

     if rug:
         rug_color = rug_kws.pop("color", color)
         axis = "y" if vertical else "x"
-        rugplot(x=a, axis=axis, ax=ax, color=rug_color, **rug_kws)
+        rugplot(a, axis=axis, ax=ax, color=rug_color, **rug_kws)
         if rug_color != color:
             rug_kws["color"] = rug_color

diff --git a/seaborn/relational.py b/seaborn/relational.py
index 04e963cd69..1a60e693f0 100644
--- a/seaborn/relational.py
+++ b/seaborn/relational.py
@@ -287,10 +287,8 @@ def plot(self, ax, kws):
         ))

         # Loop over the semantic subsets and add to the plot
-        grouping_semantics = "hue", "size", "style"
-        for sub_vars, sub_data in self._semantic_subsets(
-            grouping_semantics, from_comp_data=True
-        ):
+        grouping_vars = "hue", "size", "style"
+        for sub_vars, sub_data in self.iter_data(grouping_vars, from_comp_data=True):

             if self.sort:
                 sort_vars = ["units", "x", "y"]
diff --git a/seaborn/utils.py b/seaborn/utils.py
index 6fd599fbf2..1903d831fe 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -1,8 +1,9 @@
 """Utility functions, mostly for internal use."""
 import os
 import re
-import colorsys
+import inspect
 import warnings
+import colorsys
 from urllib.request import urlopen, urlretrieve

 import numpy as np
@@ -508,6 +509,17 @@ def load_dataset(name, cache=True, data_home=None, **kws):
         df["class"] = pd.Categorical(df["class"], ["First", "Second", "Third"])
         df["deck"] = pd.Categorical(df["deck"], list("ABCDEFG"))

+    if name == "diamonds":
+        df["color"] = pd.Categorical(
+            df["color"], ["D", "E", "F", "G", "H", "I", "J"],
+        )
+        df["clarity"] = pd.Categorical(
+            df["clarity"], ["IF", "VVS1", "VVS2", "VS1", "VS2", "SI1", "SI2", "I1"],
+        )
+        df["cut"] = pd.Categorical(
+            df["cut"], ["Ideal", "Premium", "Very Good", "Good", "Fair"],
+        )
+
     return df

@@ -652,3 +664,22 @@ def _check_argument(param, options, value):
         raise ValueError(
             f"`{param}` must be one of {options}, but {value} was passed.`"
         )
+
+
+def _assign_default_kwargs(kws, call_func, source_func):
+    """Assign default kwargs for call_func using values from source_func."""
+    # This exists so that axes-level functions and figure-level functions can
+    # both call a Plotter method while having the default kwargs be defined in
+    # the signature of the axes-level function.
+    # An alternative would be to  have a decorator on the method that sets its
+    # defaults based on those defined in the axes-level function.
+    # Then the figuer-level function would not need to worry about defaults.
+    # I am not sure which is better.
+    needed = inspect.signature(call_func).parameters
+    defaults = inspect.signature(source_func).parameters
+
+    for param in needed:
+        if param in defaults and param not in kws:
+            kws[param] = defaults[param].default
+
+    return kws

diff --git a/seaborn/conftest.py b/seaborn/conftest.py
index 34d41d06e1..2630971da5 100644
--- a/seaborn/conftest.py
+++ b/seaborn/conftest.py
@@ -149,6 +149,7 @@ def long_df(rng):
     df = pd.DataFrame(dict(
         x=rng.uniform(0, 20, n).round().astype("int"),
         y=rng.normal(size=n),
+        z=rng.lognormal(size=n),
         a=rng.choice(list("abc"), n),
         b=rng.choice(list("mnop"), n),
         c=rng.choice([0, 1], n, [.3, .7]),
diff --git a/seaborn/tests/test_core.py b/seaborn/tests/test_core.py
index bf6ddbe80b..d7091c96a6 100644
--- a/seaborn/tests/test_core.py
+++ b/seaborn/tests/test_core.py
@@ -7,6 +7,7 @@
 from numpy.testing import assert_array_equal
 from pandas.testing import assert_frame_equal

+from ..axisgrid import FacetGrid
 from .._core import (
     SemanticMapping,
     HueMapping,
@@ -152,6 +153,10 @@ def test_hue_map_categorical(self, wide_df, long_df):
         expected_lookup_table = dict(zip(hue_levels, expected_colors))
         assert m.lookup_table == expected_lookup_table

+        # Test missing data
+        m = HueMapping(p)
+        assert m(np.nan) == (0, 0, 0, 0)
+
         # Test default palette with many levels
         x = y = np.arange(26)
         hue = pd.Series(list("abcdefghijklmnopqrstuvwxyz"))
@@ -594,13 +599,13 @@ def test_flat_variables(self, flat_data):
     # TODO note that most of the other tests that excercise the core
     # variable assignment code still live in test_relational

-    def test_semantic_subset_quantitites(self, long_df):
+    def test_iter_data_quantitites(self, long_df):

         p = VectorPlotter(
             data=long_df,
             variables=dict(x="x", y="y"),
         )
-        out = p._semantic_subsets("hue")
+        out = p.iter_data("hue")
         assert len(list(out)) == 1

         var = "a"
@@ -613,7 +618,7 @@ def test_semantic_subset_quantitites(self, long_df):
                 data=long_df,
                 variables={"x": "x", "y": "y", semantic: var},
             )
-            out = p._semantic_subsets(semantics)
+            out = p.iter_data(semantics)
             assert len(list(out)) == n_subsets

         var = "a"
@@ -623,12 +628,12 @@ def test_semantic_subset_quantitites(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var, style=var),
         )
-        out = p._semantic_subsets(semantics)
+        out = p.iter_data(semantics)
         assert len(list(out)) == n_subsets

         # --

-        out = p._semantic_subsets(semantics, reverse=True)
+        out = p.iter_data(semantics, reverse=True)
         assert len(list(out)) == n_subsets

         # --
@@ -641,7 +646,7 @@ def test_semantic_subset_quantitites(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var1, style=var2),
         )
-        out = p._semantic_subsets(["hue"])
+        out = p.iter_data(["hue"])
         assert len(list(out)) == n_subsets

         n_subsets = len(set(list(map(tuple, long_df[[var1, var2]].values))))
@@ -650,14 +655,14 @@ def test_semantic_subset_quantitites(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var1, style=var2),
         )
-        out = p._semantic_subsets(semantics)
+        out = p.iter_data(semantics)
         assert len(list(out)) == n_subsets

         p = VectorPlotter(
             data=long_df,
             variables=dict(x="x", y="y", hue=var1, size=var2, style=var1),
         )
-        out = p._semantic_subsets(semantics)
+        out = p.iter_data(semantics)
         assert len(list(out)) == n_subsets

         # --
@@ -670,10 +675,10 @@ def test_semantic_subset_quantitites(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var1, size=var2, style=var3),
         )
-        out = p._semantic_subsets(semantics)
+        out = p.iter_data(semantics)
         assert len(list(out)) == n_subsets

-    def test_semantic_subset_keys(self, long_df):
+    def test_iter_data_keys(self, long_df):

         semantics = ["hue", "size", "style"]

@@ -681,7 +686,7 @@ def test_semantic_subset_keys(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y"),
         )
-        for sub_vars, _ in p._semantic_subsets("hue"):
+        for sub_vars, _ in p.iter_data("hue"):
             assert sub_vars == {}

         # --
@@ -692,7 +697,7 @@ def test_semantic_subset_keys(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var),
         )
-        for sub_vars, _ in p._semantic_subsets("hue"):
+        for sub_vars, _ in p.iter_data("hue"):
             assert list(sub_vars) == ["hue"]
             assert sub_vars["hue"] in long_df[var].values

@@ -700,7 +705,7 @@ def test_semantic_subset_keys(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", size=var),
         )
-        for sub_vars, _ in p._semantic_subsets("size"):
+        for sub_vars, _ in p.iter_data("size"):
             assert list(sub_vars) == ["size"]
             assert sub_vars["size"] in long_df[var].values

@@ -708,7 +713,7 @@ def test_semantic_subset_keys(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var, style=var),
         )
-        for sub_vars, _ in p._semantic_subsets(semantics):
+        for sub_vars, _ in p.iter_data(semantics):
             assert list(sub_vars) == ["hue", "style"]
             assert sub_vars["hue"] in long_df[var].values
             assert sub_vars["style"] in long_df[var].values
@@ -720,12 +725,22 @@ def test_semantic_subset_keys(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue=var1, size=var2),
         )
-        for sub_vars, _ in p._semantic_subsets(semantics):
+        for sub_vars, _ in p.iter_data(semantics):
             assert list(sub_vars) == ["hue", "size"]
             assert sub_vars["hue"] in long_df[var1].values
             assert sub_vars["size"] in long_df[var2].values

-    def test_semantic_subset_values(self, long_df):
+        semantics = ["hue", "col", "row"]
+        p = VectorPlotter(
+            data=long_df,
+            variables=dict(x="x", y="y", hue=var1, col=var2),
+        )
+        for sub_vars, _ in p.iter_data("hue"):
+            assert list(sub_vars) == ["hue", "col"]
+            assert sub_vars["hue"] in long_df[var1].values
+            assert sub_vars["col"] in long_df[var2].values
+
+    def test_iter_data_values(self, long_df):

         p = VectorPlotter(
             data=long_df,
@@ -733,7 +748,7 @@ def test_semantic_subset_values(self, long_df):
         )

         p.sort = True
-        _, sub_data = next(p._semantic_subsets("hue"))
+        _, sub_data = next(p.iter_data("hue"))
         assert_frame_equal(sub_data, p.plot_data)

         p = VectorPlotter(
@@ -741,7 +756,7 @@ def test_semantic_subset_values(self, long_df):
             variables=dict(x="x", y="y", hue="a"),
         )

-        for sub_vars, sub_data in p._semantic_subsets("hue"):
+        for sub_vars, sub_data in p.iter_data("hue"):
             rows = p.plot_data["hue"] == sub_vars["hue"]
             assert_frame_equal(sub_data, p.plot_data[rows])

@@ -749,19 +764,19 @@ def test_semantic_subset_values(self, long_df):
             data=long_df,
             variables=dict(x="x", y="y", hue="a", size="s"),
         )
-        for sub_vars, sub_data in p._semantic_subsets(["hue", "size"]):
+        for sub_vars, sub_data in p.iter_data(["hue", "size"]):
             rows = p.plot_data["hue"] == sub_vars["hue"]
             rows &= p.plot_data["size"] == sub_vars["size"]
             assert_frame_equal(sub_data, p.plot_data[rows])

-    def test_semantic_subset_reverse(self, long_df):
+    def test_iter_data_reverse(self, long_df):

         reversed_order = categorical_order(long_df["a"])[::-1]
         p = VectorPlotter(
             data=long_df,
             variables=dict(x="x", y="y", hue="a")
         )
-        iterator = p._semantic_subsets("hue", reverse=True)
+        iterator = p.iter_data("hue", reverse=True)
         for i, (sub_vars, _) in enumerate(iterator):
             assert sub_vars["hue"] == reversed_order[i]

@@ -848,36 +863,48 @@ def test_attach_log_scale(self, long_df):
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
+        assert p._log_scaled("x")
+        assert not p._log_scaled("y")

         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x"})
         p._attach(ax, log_scale=2)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
+        assert p._log_scaled("x")
+        assert not p._log_scaled("y")

         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"y": "y"})
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "linear"
         assert ax.yaxis.get_scale() == "log"
+        assert not p._log_scaled("x")
+        assert p._log_scaled("y")

         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=True)
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "log"
+        assert p._log_scaled("x")
+        assert p._log_scaled("y")

         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=(True, False))
         assert ax.xaxis.get_scale() == "log"
         assert ax.yaxis.get_scale() == "linear"
+        assert p._log_scaled("x")
+        assert not p._log_scaled("y")

         _, ax = plt.subplots()
         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
         p._attach(ax, log_scale=(False, 2))
         assert ax.xaxis.get_scale() == "linear"
         assert ax.yaxis.get_scale() == "log"
+        assert not p._log_scaled("x")
+        assert p._log_scaled("y")

     def test_attach_converters(self, long_df):

@@ -893,6 +920,35 @@ def test_attach_converters(self, long_df):
         assert isinstance(ax.xaxis.converter, mpl.category.StrCategoryConverter)
         assert ax.yaxis.converter is None

+    def test_attach_facets(self, long_df):
+
+        g = FacetGrid(long_df, col="a")
+        p = VectorPlotter(data=long_df, variables={"x": "x", "col": "a"})
+        p._attach(g)
+        assert p.ax is None
+        assert p.facets == g
+
+    def test_get_axes_single(self, long_df):
+
+        ax = plt.figure().subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "hue": "a"})
+        p._attach(ax)
+        assert p._get_axes({"hue": "a"}) is ax
+
+    def test_get_axes_facets(self, long_df):
+
+        g = FacetGrid(long_df, col="a")
+        p = VectorPlotter(data=long_df, variables={"x": "x", "col": "a"})
+        p._attach(g)
+        assert p._get_axes({"col": "b"}) is g.axes_dict["b"]
+
+        g = FacetGrid(long_df, col="a", row="c")
+        p = VectorPlotter(
+            data=long_df, variables={"x": "x", "col": "a", "row": "c"}
+        )
+        p._attach(g)
+        assert p._get_axes({"row": 1, "col": "b"}) is g.axes_dict[(1, "b")]
+
     def test_comp_data(self, long_df):

         p = VectorPlotter(data=long_df, variables={"x": "x", "y": "t"})
@@ -943,6 +999,17 @@ def test_comp_data_category_order(self):
             [2, 0, 1, 2],
         )

+    def test_var_order(self, long_df):
+
+        order = ["c", "b", "a"]
+        for var in ["hue", "size", "style"]:
+            p = VectorPlotter(data=long_df, variables={"x": "x", var: "a"})
+
+            mapper = getattr(p, f"map_{var}")
+            mapper(order=order)
+
+            assert p.var_levels[var] == order
+

 class TestCoreFunc:

diff --git a/seaborn/tests/test_distributions.py b/seaborn/tests/test_distributions.py
index 2593eca382..2e475f2c2a 100644
--- a/seaborn/tests/test_distributions.py
+++ b/seaborn/tests/test_distributions.py
@@ -25,11 +25,14 @@
 )
 from ..distributions import (
     _DistributionPlotter,
+    displot,
+    distplot,
     histplot,
     ecdfplot,
     kdeplot,
     rugplot,
 )
+from ..axisgrid import FacetGrid

 class TestDistPlot(object):
@@ -39,58 +42,60 @@ class TestDistPlot(object):

     def test_hist_bins(self):

-        try:
-            fd_edges = np.histogram_bin_edges(self.x, "fd")
-        except AttributeError:
-            pytest.skip("Requires numpy >= 1.15")
-        ax = dist.distplot(x=self.x)
+        fd_edges = np.histogram_bin_edges(self.x, "fd")
+        with pytest.warns(FutureWarning):
+            ax = distplot(self.x)
         for edge, bar in zip(fd_edges, ax.patches):
             assert pytest.approx(edge) == bar.get_x()

         plt.close(ax.figure)
         n = 25
         n_edges = np.histogram_bin_edges(self.x, n)
-        ax = dist.distplot(x=self.x, bins=n)
+        with pytest.warns(FutureWarning):
+            ax = distplot(self.x, bins=n)
         for edge, bar in zip(n_edges, ax.patches):
             assert pytest.approx(edge) == bar.get_x()

     def test_elements(self):

-        n = 10
-        ax = dist.distplot(x=self.x, bins=n,
-                           hist=True, kde=False, rug=False, fit=None)
-        assert len(ax.patches) == 10
-        assert len(ax.lines) == 0
-        assert len(ax.collections) == 0
-
-        plt.close(ax.figure)
-        ax = dist.distplot(x=self.x,
-                           hist=False, kde=True, rug=False, fit=None)
-        assert len(ax.patches) == 0
-        assert len(ax.lines) == 1
-        assert len(ax.collections) == 0
-
-        plt.close(ax.figure)
-        ax = dist.distplot(x=self.x,
-                           hist=False, kde=False, rug=True, fit=None)
-        assert len(ax.patches) == 0
-        assert len(ax.lines) == 0
-        assert len(ax.collections) == 1
+        with pytest.warns(FutureWarning):

-        plt.close(ax.figure)
-        ax = dist.distplot(x=self.x,
-                           hist=False, kde=False, rug=False, fit=stats.norm)
-        assert len(ax.patches) == 0
-        assert len(ax.lines) == 1
-        assert len(ax.collections) == 0
+            n = 10
+            ax = distplot(self.x, bins=n,
+                          hist=True, kde=False, rug=False, fit=None)
+            assert len(ax.patches) == 10
+            assert len(ax.lines) == 0
+            assert len(ax.collections) == 0
+
+            plt.close(ax.figure)
+            ax = distplot(self.x,
+                          hist=False, kde=True, rug=False, fit=None)
+            assert len(ax.patches) == 0
+            assert len(ax.lines) == 1
+            assert len(ax.collections) == 0
+
+            plt.close(ax.figure)
+            ax = distplot(self.x,
+                          hist=False, kde=False, rug=True, fit=None)
+            assert len(ax.patches) == 0
+            assert len(ax.lines) == 0
+            assert len(ax.collections) == 1
+
+            plt.close(ax.figure)
+            ax = distplot(self.x,
+                          hist=False, kde=False, rug=False, fit=stats.norm)
+            assert len(ax.patches) == 0
+            assert len(ax.lines) == 1
+            assert len(ax.collections) == 0

     def test_distplot_with_nans(self):

         f, (ax1, ax2) = plt.subplots(2)
         x_null = np.append(self.x, [np.nan])

-        dist.distplot(x=self.x, ax=ax1)
-        dist.distplot(x=x_null, ax=ax2)
+        with pytest.warns(FutureWarning):
+            distplot(self.x, ax=ax1)
+            distplot(x_null, ax=ax2)

         line1 = ax1.lines[0]
         line2 = ax2.lines[0]
@@ -100,13 +105,6 @@ def test_distplot_with_nans(self):
             assert bar1.get_xy() == bar2.get_xy()
             assert bar1.get_height() == bar2.get_height()

-    def test_a_parameter_deprecation(self):
-
-        n = 10
-        with pytest.warns(UserWarning):
-            ax = dist.distplot(a=self.x, bins=n)
-        assert len(ax.patches) == n
-

 class TestRugPlot:

@@ -1227,7 +1225,7 @@ def test_unique_bins(self, wide_df):

     def test_weights_with_missing(self, missing_df):

-        ax = histplot(missing_df, x="x", weights="s")
+        ax = histplot(missing_df, x="x", weights="s", bins=5)

         bar_heights = [bar.get_height() for bar in ax.patches]
         total_weight = missing_df[["x", "s"]].dropna()["s"].sum()
@@ -1251,6 +1249,12 @@ def test_discrete_categorical_default(self, long_df):
         for i, bar in enumerate(ax.patches):
             assert bar.get_width() == 1

+    def test_categorical_yaxis_inversion(self, long_df):
+
+        ax = histplot(long_df, y="a")
+        ymax, ymin = ax.get_ylim()
+        assert ymax > ymin
+
     def test_discrete_requires_bars(self, long_df):

         with pytest.raises(ValueError, match="`element` must be 'bars'"):
@@ -1651,6 +1655,19 @@ def test_mesh_thresh(self, long_df):
         assert_array_equal(mesh_data.data, counts.T.flat)
         assert_array_equal(mesh_data.mask, (counts <= thresh).T.flat)

+    def test_mesh_sticky_edges(self, long_df):
+
+        ax = histplot(long_df, x="x", y="y", thresh=None)
+        mesh = ax.collections[0]
+        assert mesh.sticky_edges.x == [long_df["x"].min(), long_df["x"].max()]
+        assert mesh.sticky_edges.y == [long_df["y"].min(), long_df["y"].max()]
+
+        ax.clear()
+        ax = histplot(long_df, x="x", y="y")
+        mesh = ax.collections[0]
+        assert not mesh.sticky_edges.x
+        assert not mesh.sticky_edges.y
+
     def test_mesh_common_norm(self, long_df):

         stat = "density"
@@ -1881,7 +1898,273 @@ def test_proportion_count(self, flat_series, data_var, stat_var):
         sticky_edges = getattr(ax.lines[0].sticky_edges, stat_var)
         assert sticky_edges[:] == [0, n]

+    def test_weights(self):
+
+        ax = ecdfplot(x=[1, 2, 3], weights=[1, 1, 2])
+        y = ax.lines[0].get_ydata()
+        assert_array_equal(y, [0, .25, .5, 1])
+
     def test_bivariate_error(self, long_df):

         with pytest.raises(NotImplementedError, match="Bivariate ECDF plots"):
             ecdfplot(data=long_df, x="x", y="y")
+
+
+class TestDisPlot:
+
+    # TODO probably good to move these utility attributes/methods somewhere else
+    bar_props = [
+        "alpha",
+        "edgecolor",
+        "facecolor",
+        "fill",
+        "hatch",
+        "height",
+        "linestyle",
+        "linewidth",
+        "xy",
+        "zorder",
+    ]
+
+    line_props = [
+        "alpha",
+        "color",
+        "linewidth",
+        "linestyle",
+        "xydata",
+        "zorder",
+    ]
+
+    collection_props = [
+        "alpha",
+        "edgecolor",
+        "facecolor",
+        "fill",
+        "hatch",
+        "linestyle",
+        "linewidth",
+        "paths",
+        "zorder",
+    ]
+
+    def assert_artists_equal(self, list1, list2, properties):
+
+        for a1, a2 in zip(list1, list2):
+            prop1 = a1.properties()
+            prop2 = a2.properties()
+            for key in properties:
+                v1 = prop1[key]
+                v2 = prop2[key]
+                if key == "paths":
+                    for p1, p2 in zip(v1, v2):
+                        assert_array_equal(p1.vertices, p2.vertices)
+                        assert_array_equal(p1.codes, p2.codes)
+                elif isinstance(v1, np.ndarray):
+                    assert_array_equal(v1, v2)
+                else:
+                    assert v1 == v2
+
+    def assert_plots_equal(self, ax1, ax2):
+
+        self.assert_artists_equal(ax1.patches, ax2.patches, self.bar_props)
+        self.assert_artists_equal(ax1.lines, ax2.lines, self.line_props)
+
+        poly1 = ax1.findobj(mpl.collections.PolyCollection)
+        poly2 = ax2.findobj(mpl.collections.PolyCollection)
+        self.assert_artists_equal(poly1, poly2, self.collection_props)
+
+        assert ax1.get_xlabel() == ax2.get_xlabel()
+        assert ax1.get_ylabel() == ax2.get_ylabel()
+
+    def assert_legends_equal(self, leg1, leg2):
+
+        assert leg1.get_title().get_text() == leg2.get_title().get_text()
+        for t1, t2 in zip(leg1.get_texts(), leg2.get_texts()):
+            assert t1.get_text() == t2.get_text()
+
+        self.assert_artists_equal(
+            leg1.get_patches(), leg2.get_patches(), self.bar_props,
+        )
+        self.assert_artists_equal(
+            leg1.get_lines(), leg2.get_lines(), self.line_props,
+        )
+
+    @pytest.mark.parametrize(
+        "kwargs", [
+            dict(),
+            dict(x="x"),
+            dict(x="t"),
+            dict(x="a"),
+            dict(x="z", log_scale=True),
+            dict(x="x", binwidth=4),
+            dict(x="x", weights="f", bins=5),
+            dict(x="x", color="green", linewidth=2, binwidth=4),
+            dict(x="x", hue="a", fill=False),
+            dict(x="y", hue="a", fill=False),
+            dict(x="x", hue="a", multiple="stack"),
+            dict(x="x", hue="a", element="step"),
+            dict(x="x", hue="a", palette="muted"),
+            dict(x="x", hue="a", kde=True),
+            dict(x="x", hue="a", stat="density", common_norm=False),
+            dict(x="x", y="y"),
+        ],
+    )
+    def test_versus_single_histplot(self, long_df, kwargs):
+
+        ax = histplot(long_df, **kwargs)
+        g = displot(long_df, **kwargs)
+        self.assert_plots_equal(ax, g.ax)
+
+        if ax.legend_ is not None:
+            self.assert_legends_equal(ax.legend_, g._legend)
+
+        long_df["_"] = "_"
+        g2 = displot(long_df, col="_", **kwargs)
+        self.assert_plots_equal(ax, g2.ax)
+
+    @pytest.mark.parametrize(
+        "kwargs", [
+            dict(),
+            dict(x="x"),
+            dict(x="t"),
+            dict(x="z", log_scale=True),
+            dict(x="x", bw_adjust=.5),
+            dict(x="x", weights="f"),
+            dict(x="x", color="green", linewidth=2),
+            dict(x="x", hue="a", multiple="stack"),
+            dict(x="x", hue="a", fill=True),
+            dict(x="y", hue="a", fill=False),
+            dict(x="x", hue="a", palette="muted"),
+            dict(x="x", y="y"),
+        ],
+    )
+    def test_versus_single_kdeplot(self, long_df, kwargs):
+
+        if "weights" in kwargs and LooseVersion(scipy.__version__) < "1.2":
+            pytest.skip("Weights require scipy >= 1.2")
+
+        ax = kdeplot(data=long_df, **kwargs)
+        g = displot(long_df, kind="kde", **kwargs)
+        self.assert_plots_equal(ax, g.ax)
+
+        if ax.legend_ is not None:
+            self.assert_legends_equal(ax.legend_, g._legend)
+
+        long_df["_"] = "_"
+        g2 = displot(long_df, kind="kde", col="_", **kwargs)
+        self.assert_plots_equal(ax, g2.ax)
+
+    @pytest.mark.parametrize(
+        "kwargs", [
+            dict(),
+            dict(x="x"),
+            dict(x="t"),
+            dict(x="z", log_scale=True),
+            dict(x="x", weights="f"),
+            dict(y="x"),
+            dict(x="x", color="green", linewidth=2),
+            dict(x="x", hue="a", complementary=True),
+            dict(x="x", hue="a", stat="count"),
+            dict(x="x", hue="a", palette="muted"),
+        ],
+    )
+    def test_versus_single_ecdfplot(self, long_df, kwargs):
+
+        ax = ecdfplot(data=long_df, **kwargs)
+        g = displot(long_df, kind="ecdf", **kwargs)
+        self.assert_plots_equal(ax, g.ax)
+
+        if ax.legend_ is not None:
+            self.assert_legends_equal(ax.legend_, g._legend)
+
+        long_df["_"] = "_"
+        g2 = displot(long_df, kind="ecdf", col="_", **kwargs)
+        self.assert_plots_equal(ax, g2.ax)
+
+    @pytest.mark.parametrize(
+        "kwargs", [
+            dict(x="x"),
+            dict(x="x", y="y"),
+            dict(x="x", hue="a"),
+        ]
+    )
+    def test_with_rug(self, long_df, kwargs):
+
+        ax = rugplot(data=long_df, **kwargs)
+        g = displot(long_df, rug=True, **kwargs)
+
+        rug1 = ax.findobj(mpl.collections.LineCollection)
+        rug2 = g.ax.findobj(mpl.collections.LineCollection)
+        self.assert_artists_equal(rug1, rug2, self.collection_props)
+
+        long_df["_"] = "_"
+        g2 = displot(long_df, col="_", **kwargs)
+        rug3 = g2.ax.findobj(mpl.collections.LineCollection)
+        self.assert_artists_equal(rug1, rug3, self.collection_props)
+
+    @pytest.mark.parametrize(
+        "facet_var", ["col", "row"],
+    )
+    def test_facets(self, long_df, facet_var):
+
+        kwargs = {facet_var: "a"}
+        ax = kdeplot(data=long_df, x="x", hue="a")
+        g = displot(long_df, x="x", kind="kde", **kwargs)
+
+        legend_texts = ax.legend_.get_texts()
+
+        for i, line in enumerate(ax.lines[::-1]):
+            facet_ax = g.axes.flat[i]
+            facet_line = facet_ax.lines[0]
+            assert_array_equal(line.get_xydata(), facet_line.get_xydata())
+
+            text = legend_texts[i].get_text()
+            assert text in facet_ax.get_title()
+
+    @pytest.mark.parametrize("multiple", ["dodge", "stack", "fill"])
+    def test_facet_multiple(self, long_df, multiple):
+
+        bins = np.linspace(0, 20, 5)
+        ax = histplot(
+            data=long_df[long_df["c"] == 0],
+            x="x", hue="a", hue_order=["a", "b", "c"],
+            multiple=multiple, bins=bins,
+        )
+
+        g = displot(
+            data=long_df, x="x", hue="a", col="c", hue_order=["a", "b", "c"],
+            multiple=multiple, bins=bins,
+        )
+
+        self.assert_plots_equal(ax, g.axes_dict[0])
+
+    def test_ax_warning(self, long_df):
+
+        ax = plt.figure().subplots()
+        with pytest.warns(UserWarning, match="`displot` is a figure-level"):
+            displot(long_df, x="x", ax=ax)
+
+    @pytest.mark.parametrize("key", ["col", "row"])
+    def test_array_faceting(self, long_df, key):
+
+        a = np.asarray(long_df["a"])  # .to_numpy on pandas 0.24
+        vals = categorical_order(a)
+        g = displot(long_df, x="x", **{key: a})
+        assert len(g.axes.flat) == len(vals)
+        for ax, val in zip(g.axes.flat, vals):
+            assert val in ax.get_title()
+
+    def test_legend(self, long_df):
+
+        g = displot(long_df, x="x", hue="a")
+        assert g._legend is not None
+
+    def test_empty(self):
+
+        g = displot(x=[], y=[])
+        assert isinstance(g, FacetGrid)
+
+    def test_bivariate_ecdf_error(self, long_df):
+
+        with pytest.raises(NotImplementedError):
+            displot(long_df, x="x", y="y", kind="ecdf")
diff --git a/seaborn/tests/test_utils.py b/seaborn/tests/test_utils.py
index f866a64b19..4bfbffcb3e 100644
--- a/seaborn/tests/test_utils.py
+++ b/seaborn/tests/test_utils.py
@@ -26,6 +26,7 @@
     get_color_cycle,
     remove_na,
     load_dataset,
+    _assign_default_kwargs
 )

@@ -472,3 +473,17 @@ def test_remove_na():
     a_series = pd.Series([1, 2, np.nan, 3])
     a_series_rm = remove_na(a_series)
     assert_series_equal(a_series_rm, pd.Series([1., 2, 3], [0, 1, 3]))
+
+
+def test_assign_default_kwargs():
+
+    def f(a, b, c, d):
+        pass
+
+    def g(c=1, d=2):
+        pass
+
+    kws = {"c": 3}
+
+    kws = _assign_default_kwargs(kws, f, g)
+    assert kws == {"c": 3, "d": 2}

--- /dev/null
+++ b/doc/docstrings/displot.ipynb
@@ -0,0 +1,44 @@
+The default plot kind is a histogram:
+import seaborn as sns; sns.set(style="ticks")
+
+penguins = sns.load_dataset("penguins")
+
+sns.displot(data=penguins, x="flipper_length_mm")
+Use the ``kind`` parameter to select a different representation:
+sns.displot(data=penguins, x="flipper_length_mm", kind="kde")
+There are three main plot kinds; in addition to histograms and kernel density estimates (KDEs), you can also draw empirical cumulative distribution functions (ECDFs):
+sns.displot(data=penguins, x="flipper_length_mm", kind="ecdf")
+While in histogram mode, it is also possible to add a KDE curve:
+sns.displot(data=penguins, x="flipper_length_mm", kde=True)
+To draw a bivariate plot, assign both ``x`` and ``y``:
+sns.displot(data=penguins, x="flipper_length_mm", y="culmen_length_mm")
+Currently, bivariate plots are available only for histograms and KDEs:
+sns.displot(data=penguins, x="flipper_length_mm", y="culmen_length_mm", kind="kde")
+For each kind of plot, you can also show individual observations with a marginal "rug":
+g = sns.displot(data=penguins, x="flipper_length_mm", y="culmen_length_mm", kind="kde", rug=True)
+Each kind of plot can be drawn separately for subsets of data using ``hue`` mapping:
+sns.displot(data=penguins, x="flipper_length_mm", hue="species", kind="kde")
+Additional keyword arguments are passed to the appropriate underlying plotting function, allowing for further customization:
+sns.displot(data=penguins, x="flipper_length_mm", hue="species", multiple="stack")
+The figure is constructed using a :class:`FacetGrid`, meaning that you can also show subsets on distinct subplots, or "facets":
+sns.displot(data=penguins, x="flipper_length_mm", hue="species", col="sex", kind="kde")
+Because the figure is drawn with a :class:`FacetGrid`, you control its size and shape with the ``height`` and ``aspect`` parameters:
+sns.displot(
+
+    data=penguins, y="flipper_length_mm", hue="sex", col="species",
+
+    kind="ecdf", height=4, aspect=.7,
+
+)
+The function returns the :class:`FacetGrid` object with the plot, and you can use the methods on this object to customize it further:
+g = sns.displot(
+
+    data=penguins, y="flipper_length_mm", hue="sex", col="species",
+
+    kind="kde", height=4, aspect=.7,
+
+)
+
+g.set_axis_labels("Density (a.u.)", "Flipper length (mm)")
+
+g.set_titles("{col_name} penguins")

--- a/doc/docstrings/histplot.ipynb
+++ b/doc/docstrings/histplot.ipynb
@@ -13,9 +13,7 @@
 Add a kernel density estimate to smooth the histogram, providing complementary information about the shape of the distribution:
 sns.histplot(data=penguins, x="flipper_length_mm", kde=True)
 If neither `x` nor `y` is assigned, the dataset is treated as wide-form, and a histogram is drawn for each numeric column:
-iris = sns.load_dataset("iris")
-
-sns.histplot(data=iris)
+sns.histplot(data=penguins)
 You can otherwise draw multiple histograms from a long-form dataset with hue mapping:
 sns.histplot(data=penguins, x="flipper_length_mm", hue="species")
 The default approach to plotting multiple distributions is to "layer" them, but you can also "stack" them:

--- a/doc/tutorial/distributions.ipynb
+++ b/doc/tutorial/distributions.ipynb
@@ -3,9 +3,9 @@

 .. currentmodule:: seaborn
-Visualizing the distribution of a dataset
-
-=========================================
+Visualizing distributions of data
+
+==================================

@@ -15,192 +15,233 @@

    <div class=col-md-9>

-When dealing with a set of data, often the first thing you'll want to do is get a sense for how the variables are distributed. This chapter of the tutorial will give a brief introduction to some of the tools in seaborn for examining univariate and bivariate distributions. You may also want to look at the :ref:`categorical plots <categorical_tutorial>` chapter for examples of functions that make it easy to compare the distribution of a variable across levels of other variables.
-import numpy as np
-
-import pandas as pd
-
-import seaborn as sns
-
-import matplotlib.pyplot as plt
-
-from scipy import stats
-sns.set(color_codes=True)
+
+
+The first step of any effort to analyze or model data should be to understand how the variables are distributed. Techniques for distribution visualization can provide quick answers to many important questions. What range do the observations cover? What is their central tendency? Are they heavily skewed in one direction? Is there evidence for bimodality? Are there significant outliers? Do the answers to these questions vary across subsets defined by other variables?
+
+
+
+The :ref:`distributions module <distributions_api>` contains several functions designed to answer questions such as these. The axes-level functions are :func:`histplot`, :func:`kdeplot`, :func:`ecdfplot`, and :func:`rugplot`. They are grouped together within the figure-level :func:`displot`, :func`jointplot`, and :func:`pairplot` functions.
+
+
+
+There are several different approaches to visualizing a distribution, and each has its relative advantages and drawbacks. It is important to understand theses factors so that you can choose the best approach for your particular aim.
 %matplotlib inline

-np.random.seed(sum(map(ord, "distributions")))
-Plotting univariate distributions
-
----------------------------------
-
-
-
-The most convenient way to take a quick look at a univariate distribution in seaborn is the :func:`distplot` function. By default, this will draw a `histogram <https://en.wikipedia.org/wiki/Histogram>`_ and fit a `kernel density estimate <https://en.wikipedia.org/wiki/Kernel_density_estimation>`_ (KDE).
-x = np.random.normal(size=100)
-
-sns.distplot(x=x);
-Histograms
-
-^^^^^^^^^^
-
-
-
-Histograms are likely familiar, and a ``hist`` function already exists in matplotlib. A histogram represents the distribution of data by forming bins along the range of the data and then drawing bars to show the number of observations that fall in each bin.
-
-
-
-To illustrate this, let's remove the density curve and add a rug plot, which draws a small vertical tick at each observation. You can make the rug plot itself with the :func:`rugplot` function, but it is also available in :func:`distplot`:
-sns.distplot(x=x, kde=False, rug=True);
-When drawing histograms, the main choice you have is the number of bins to use and where to place them. :func:`distplot` uses a simple rule to make a good guess for what the right number is by default, but trying more or fewer bins might reveal other features in the data:
-sns.distplot(x=x, bins=20, kde=False, rug=True);
-.. _userguide_kde:
+import seaborn as sns; sns.set()
+.. _tutorial_hist:
+
+
+
+Plotting univariate histograms
+
+------------------------------
+
+
+
+Perhaps the most common approach to visualizing a distribution is the *histogram*. This is the default approach in :func:`displot`, which uses the same underlying code as :func:`histplot`. A histogram is a bar plot where the axis representing the data variable is divided into a set of discrete bins and the count of observations falling within each bin is shown using the height of the corresponding bar:
+penguins = sns.load_dataset("penguins")
+
+sns.displot(penguins, x="flipper_length_mm")
+This plot immediately affords a few insights about the ``flipper_length_mm`` variable. For instance, we can see that the most common flipper length is about 195 mm, but the distribution appears bimodal, so this one number does not represent the data well.
+
+
+
+Choosing the bin size
+
+^^^^^^^^^^^^^^^^^^^^^
+
+
+
+The size of the bins is an important parameter, and using the wrong bin size can mislead by obscuring important features of the data or by creating apparent features out of random variability. By default, :func:`displot`/:func:`histplot` choose a default bin size based on the variance of the data and the number of observations. But you should not be over-reliant on such automatic approaches, because they depend on particular assumptions about the structure of your data. It is always advisable to check that your impressions of the distribution are consistent across different bin sizes. To choose the size directly, set the `binwidth` parameter:
+sns.displot(penguins, x="flipper_length_mm", binwidth=3)
+In other circumstances, it may make more sense to specify the *number* of bins, rather than their size:
+sns.displot(penguins, x="flipper_length_mm", bins=20)
+One example of a situation where defaults fail is when the variable takes a relatively small number of integer values. In that case, the default bin width may be too small, creating awkward gaps in the distribution:
+tips = sns.load_dataset("tips")
+
+sns.displot(tips, x="size")
+One approach would be to specify the precise bin breaks by passing an array to ``bins``:
+sns.displot(tips, x="size", bins=[1, 2, 3, 4, 5, 6, 7])
+This can also be accomplished by setting ``discrete=True``, which chooses bin breaks that represent the unique values in a dataset with bars that are centered on their corresponding value.
+sns.displot(tips, x="size", discrete=True)
+It's also possible to visualize the distribution of a categorical variable using the logic of a histogram. Discrete bins are automatically set for categorical variables, but it may also be helpful to "shrink" the bars slightly to emphasize the categorical nature of the axis:
+sns.displot(tips, x="day", shrink=.8)
+Conditioning on other variables
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+Once you understand the distribution of a variable, the next step is often to ask whether features of that distribution differ across other variables in the dataset. For example, what accounts for the bimodal distribution of flipper lengths that we saw above? :func:`displot` and :func:`histplot` provide support for conditional subsetting via the ``hue``semantic. Assigning a variable to ``hue`` will draw a separate histogram for each of its unique values and distinguish them by color:
+sns.displot(penguins, x="flipper_length_mm", hue="species")
+By default, the different histograms are "layered" on top of each other and, in some cases, they may be difficult to distinguish. One option is to change the visual representation of the histogram from a bar plot to a "step" plot:
+sns.displot(penguins, x="flipper_length_mm", hue="species", element="step")
+Alternatively, instead of layering each bar, they can be "stacked", or moved vertically. In this plot, the outline of the full histogram will match the plot with only a single variable:
+sns.displot(penguins, x="flipper_length_mm", hue="species", multiple="stack")
+The stacked histogram emphasizes the part-whole relationship between the variables, but it can obscure other features (for example, it is difficult to determine the mode of the Adelie distribution. Another option is "dodge" the bars, which moves them horizontally and reduces their width. This ensures that there are no overlaps and that the bars remain comparable in terms of height. But it only works well when the categorical variable has a small number of levels:
+sns.displot(penguins, x="flipper_length_mm", hue="sex", multiple="dodge")
+Because :func:`displot` is a figure-level function and is drawn onto a :class:`FacetGrid`, it is also possible to draw each individual distribution in a separate subplot by assigning the second variable to ``col`` or ``row`` rather than (or in addition to) ``hue``. This represents the distribution of each subset well, but it makes it more difficult to draw direct comparisons:
+sns.displot(penguins, x="flipper_length_mm", col="sex", multiple="dodge")
+None of these approaches are perfect, and  we will soon see some alternatives to a histogram that are better-suited to the task of comparison.
+
+
+
+Normalized histogram statistics
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+Before we do, another point to note is that, when the subsets have unequal numbers of observations, comparing their distributions in terms of counts may not be ideal. One solution is to *normalize* the counts using the ``stat`` parameter:
+sns.displot(penguins, x="flipper_length_mm", hue="species", stat="density")
+By default, however, the normalization is applied to the entire distribution, so this simply rescales the height of the bars. By setting ``common_norm=False``, each subset will be normalized independently:
+sns.displot(penguins, x="flipper_length_mm", hue="species", stat="density", common_norm=False)
+Density normalization scales the bars so that their *areas* sum to 1. As a result, the density axis is not directly interpretable. Another option is to normalize the bars to that their *heights* sum to 1. This makes most sense when the variable is discrete, but  it is an option for all histograms:
+sns.displot(penguins, x="flipper_length_mm", hue="species", stat="probability")
+.. _tutorial_kde:

 Kernel density estimation

-^^^^^^^^^^^^^^^^^^^^^^^^^
-
-
-
-The kernel density estimate may be less familiar, but it can be a useful tool for plotting the shape of a distribution. Like the histogram, the KDE plots encode the density of observations on one axis with height along the other axis:
-sns.distplot(x=x, hist=False, rug=True);
-Drawing a KDE is more computationally involved than drawing a histogram. What happens is that each observation is first replaced with a normal (Gaussian) curve centered at that value:
-x = np.random.normal(0, 1, size=30)
-
-bandwidth = 1.06 * x.std() * x.size ** (-1 / 5.)
-
-support = np.linspace(-4, 4, 200)
-
-
-
-kernels = []
-
-for x_i in x:
-
-
-
-    kernel = stats.norm(x_i, bandwidth).pdf(support)
-
-    kernels.append(kernel)
-
-    plt.plot(support, kernel, color="r")
-
-
-
-sns.rugplot(x=x, color=".2", linewidth=3);
-Next, these curves are summed to compute the value of the density at each point in the support grid. The resulting curve is then normalized so that the area under it is equal to 1:
-from scipy.integrate import trapz
-
-density = np.sum(kernels, axis=0)
-
-density /= trapz(density, support)
-
-plt.plot(support, density);
-We can see that if we use the :func:`kdeplot` function in seaborn, we get the same curve. This function is used by :func:`distplot`, but it provides a more direct interface with easier access to other options when you just want the density estimate:
-sns.kdeplot(x=x, fill=True);
-The bandwidth (``bw``) parameter of the KDE controls how tightly the estimation is fit to the data, much like the bin size in a histogram. It corresponds to the width of the kernels we plotted above. The default behavior tries to guess a good value using a common reference rule, but it may be helpful to try larger or smaller values:
-sns.kdeplot(x=x)
-
-sns.kdeplot(x=x, bw=.2, label="bw: 0.2")
-
-sns.kdeplot(x=x, bw=2, label="bw: 2")
-
-plt.legend();
-As you can see above, the nature of the Gaussian KDE process means that estimation extends past the largest and smallest values in the dataset. It's possible to control how far past the extreme values the curve is drawn with the ``cut`` parameter; however, this only influences how the curve is drawn and not how it is fit:
-sns.kdeplot(x=x, fill=True, cut=0)
-
-sns.rugplot(x=x);
-Fitting parametric distributions
+-------------------------
+
+
+
+A histogram aims to approximate the underlying probability density function that generated the data by binning and counting observations. Kernel density estimation (KDE) presents a different solution to the same problem. Rather than using discrete bins, a KDE plot smooths the observations with a Gaussian kernel, producing a continuous density estimate:
+sns.displot(penguins, x="flipper_length_mm", kind="kde")
+Choosing the smoothing bandwidth

 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

-You can also use :func:`distplot` to fit a parametric distribution to a dataset and visually evaluate how closely it corresponds to the observed data:
-x = np.random.gamma(6, size=200)
-
-sns.distplot(x=x, kde=False, fit=stats.gamma);
-Plotting bivariate distributions
-
---------------------------------
-
-
-
-It can also be useful to visualize a bivariate distribution of two variables. The easiest way to do this in seaborn is to just use the :func:`jointplot` function, which creates a multi-panel figure that shows both the bivariate (or joint) relationship between two variables along with the univariate (or marginal) distribution of each on separate axes.
-mean, cov = [0, 1], [(1, .5), (.5, 1)]
-
-data = np.random.multivariate_normal(mean, cov, 200)
-
-df = pd.DataFrame(data, columns=["x", "y"])
-Scatterplots
-
-^^^^^^^^^^^^
-
-
-
-The most familiar way to visualize a bivariate distribution is a scatterplot, where each observation is shown with point at the *x* and *y* values. This is analogous to a rug plot on two dimensions. You can draw a scatterplot with :func:`scatterplot`, and it is also the default kind of plot shown by the :func:`jointplot` function:
-sns.jointplot(x="x", y="y", data=df);
-Hexbin plots
-
-^^^^^^^^^^^^
-
-
-
-A bivariate analogue of a histogram is known as a "hexbin" plot, because it shows the counts of observations that fall within hexagonal bins. This plot works best with relatively large datasets. It's available through in matplotlib as :meth:`matplotlib.axes.Axes.hexbin` and as a style in :func:`jointplot`. It looks best with a white background:
-x, y = np.random.multivariate_normal(mean, cov, 1000).T
-
-with sns.axes_style("white"):
-
-    sns.jointplot(x=x, y=y, kind="hex", color="k");
-Kernel density estimation
-
-^^^^^^^^^^^^^^^^^^^^^^^^^
-
-
-
-It is also possible to use the kernel density estimation procedure described above to visualize a bivariate distribution. In seaborn, this kind of plot is shown with a contour plot and is available as a style in :func:`jointplot`:
-sns.jointplot(x="x", y="y", data=df, kind="kde");
-You can also draw a two-dimensional kernel density plot with the :func:`kdeplot` function. This allows you to draw this kind of plot onto a specific (and possibly already existing) matplotlib axes, whereas the :func:`jointplot` function manages its own figure:
-f, ax = plt.subplots(figsize=(6, 6))
-
-sns.kdeplot(x=df.x, y=df.y, ax=ax)
-
-sns.rugplot(x=df.x, color="g", ax=ax)
-
-sns.rugplot(x=df.y, vertical=True, ax=ax);
-If you wish to show the bivariate density more continuously, you can simply increase the number of contour levels:
-f, ax = plt.subplots(figsize=(6, 6))
-
-cmap = sns.cubehelix_palette(as_cmap=True, dark=0, light=1, reverse=True)
-
-sns.kdeplot(data=df, x="x", y="y", cmap=cmap, levels=60, fill=True);
-The :func:`jointplot` function uses a :class:`JointGrid` to manage the figure. For more flexibility, you may want to draw your figure by using :class:`JointGrid` directly. :func:`jointplot` returns the :class:`JointGrid` object after plotting, which you can use to add more layers or to tweak other aspects of the visualization:
-g = sns.jointplot(x="x", y="y", data=df, kind="kde", color="m")
-
-g.plot_joint(plt.scatter, c="w", s=30, linewidth=1, marker="+")
-
-g.ax_joint.collections[0].set_alpha(0)
-
-g.set_axis_labels("$X$", "$Y$");
-Visualizing pairwise relationships in a dataset
-
------------------------------------------------
-
-
-
-To plot multiple pairwise bivariate distributions in a dataset, you can use the :func:`pairplot` function. This creates a matrix of axes and shows the relationship for each pair of columns in a DataFrame. By default, it also draws the univariate distribution of each variable on the diagonal Axes:
-iris = sns.load_dataset("iris")
-
-sns.pairplot(iris);
-Specifying the ``hue`` parameter automatically changes the histograms to KDE plots to facilitate comparisons between multiple distributions.
-sns.pairplot(iris, hue="species");
-Much like the relationship between :func:`jointplot` and :class:`JointGrid`, the :func:`pairplot` function is built on top of a :class:`PairGrid` object, which can be used directly for more flexibility:
-g = sns.PairGrid(iris)
-
-g.map_diag(sns.kdeplot)
-
-g.map_offdiag(sns.kdeplot, n_levels=6);
+Much like with the bin size in the histogram, the ability of the KDE to accurately represent the data depends on the choice of smoothing bandwidth. An over-smoothed estimate might erase meaningful features, but an under-smoothed estimate can obscure the true shape within random noise. The easiest way to check the robustness of the estimate is to adjust the default bandwidth:
+sns.displot(penguins, x="flipper_length_mm", kind="kde", bw_adjust=.25)
+Note how the narrow bandwidth makes the bimodality much more apparent, but the curve is much less smooth. In contrast, a larger bandwidth obscures the bimodality almost completely:
+sns.displot(penguins, x="flipper_length_mm", kind="kde", bw_adjust=2)
+Conditioning on other variables
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+As with histograms, if you assign a ``hue`` variable, a separate density estimate will be computed for each level of that variable:
+sns.displot(penguins, x="flipper_length_mm", hue="species", kind="kde")
+In many cases, the layered KDE is easier to interpret than the layered histogram, so it is often a good choice for the task of comparison. Many of the same options for resolving multiple distributions apply to the KDE as well, however:
+sns.displot(penguins, x="flipper_length_mm", hue="species", kind="kde", multiple="stack")
+Note how the stacked plot filled in the area between each curve by default. It is also possible to fill in the curves for single or layered densities, although the default alpha value (opacity) will be different, so that the individual densities are easier to resolve.
+sns.displot(penguins, x="flipper_length_mm", hue="species", kind="kde", fill=True)
+Kernel density estimation pitfalls
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+KDE plots have many advantages. Important features of the data are easy to discern (central tendency, bimodality, skew), and they afford easy comparisons between subsets. But there are also situations where KDE poorly represents the underlying data. This is because the logic of KDE assumes that the underlying distribution is smooth and unbounded. One way this assumption can fail is when a varible reflects a quantity that is naturally bounded. If there are observations lying close to the bound (for example, small values of a variable that cannot be negative), the KDE curve may extend to unrealistic values:
+sns.displot(tips, x="total_bill", kind="kde")
+This can be partially avoided with the ``cut`` parameter, which specifies how far the curve should extend beyond the extreme datapoints. But this influences only where the curve is drawn; the density estimate will still smooth over the range where no data can exist, causing it to be artifically low at the extremes of the distribution:
+sns.displot(tips, x="total_bill", kind="kde", cut=0)
+The KDE approach also fails for discrete data or when data are naturally continuous but specific values are over-represented. The important thing to keep in mind is that the KDE will *always show you a smooth curve*, even when the data themselves are not smooth. For example, consider this distribution of diamond weights:
+diamonds = sns.load_dataset("diamonds")
+
+sns.displot(diamonds, x="carat", kind="kde")
+While the KDE suggests that there are peaks around specific values, the histogram reveals a much more jagged distribution:
+sns.displot(diamonds, x="carat")
+As a compromise, it is possible to combine these two approaches. While in histogram mode, :func:`displot` (as with :func:`histplot`) has the option of including the smoothed KDE curve (note ``kde=True``, not ``kind="kde"``):
+sns.displot(diamonds, x="carat", kde=True)
+.. _tutorial_ecdf:
+
+
+
+Empirical cumulative distributions
+
+----------------------------------
+
+
+
+A third option for visualizing distributions computes the "empirical cumulative distribution function" (ECDF). This plot draws a monotonically-increasing curve through each datapoint such that the height of the curve reflects the proportion of observations with a smaller value:
+sns.displot(penguins, x="flipper_length_mm", kind="ecdf")
+The ECDF plot has two key advantages. Unlike the histogram or KDE, it directly represents each datapoint. That means there is no bin size or smoothing parameter to consider. Additionally, because the curve is monotonically increasing, it is well-suited for comparing multiple distributions:
+sns.displot(penguins, x="flipper_length_mm", hue="species", kind="ecdf")
+The major downside to the ECDF plot is that it represents the shape of the distribution less intuitively than a histogram or density curve. Consider how the bimodality of flipper lengths is immediately apparent in the histogram, but to see it in the ECDF plot, you must look for varying slopes. Nevertheless, with practice, you can learn to answer all of the important questions about a distribution by examining the ECDF, and doing so can be a powerful approach.
+Visualizing bivariate distributions
+
+-----------------------------------
+
+
+
+All of the examples so far have considered *univariate* distributions: distributions of a single variable, perhaps conditional on a second variable assigned to ``hue``. Assigning a second variable to ``y``, however, will plot a *bivariate* distribution:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm")
+A bivariate histogram bins the data within rectangles that tile the plot and then shows the count of observations within each rectangle with the fill color (analagous to a :func:`heatmap`). Similarly, a bivariate KDE plot smoothes the (x, y) observations with a 2D Gaussian. The default representation then shows the *contours* of the 2D density:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", kind="kde")
+Assigning a ``hue`` variable will plot multiple heatmaps or contour sets using different colors. For bivariate histograms, this will only work well if there is minimal overlap between the conditional distributions:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", hue="species")
+The contour approach of the bivariate KDE plot lends itself better to evaluating overlap, although a plot with too many contours can get busy:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", hue="species", kind="kde")
+Just as with univariate plots, the choice of bin size or smoothing bandwidth will determine how well the plot represents the underlying bivariate distribution. The same parameters apply, but they can be tuned for each variable by passing a pair of values:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", binwidth=(2, .5))
+To aid interpretation of the heatmap, add a colorbar to show the mapping between counts and color intensity:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", binwidth=(2, .5), cbar=True)
+The meaning of the bivariate density contours is less straightforward. Because the density is not directly interpretable, the contours are drawn at *iso-proportions* of the density, meaning that each curve shows a level set such that some proportion *p* of the density lies below it. The *p* values are evenly spaced, with the lowest level contolled by the ``thresh`` parameter and the number controlled by ``levels``:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", kind="kde", thresh=.2, levels=4)
+The ``levels`` parameter also accepts a list of values, for more control:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", kind="kde", levels=[.01, .05, .1, .8])
+The bivariate histogram allows one or both variables to be discrete. Plotting one discrete and one continuous variable offers another way to compare conditional univariate distributions:
+sns.displot(diamonds, x="price", y="clarity", log_scale=(True, False))
+In contrast, plotting two discrete variables is an easy to way show the cross-tabulation of the observations:
+sns.displot(diamonds, x="color", y="clarity")
+Distribution visualization in other settings
+
+--------------------------------------------
+
+
+
+Several other figure-level plotting functions in seaborn make use of the :func:`histplot` and :func:`kdeplot` functions.
+
+
+
+
+
+Plotting joint and marginal distributions
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+The first is :func:`jointplot`, which augments a bivariate relatonal or distribution plot with the marginal distributions of the two variables. By default, :func:`jointplot` represents the bivariate distribution using :func:`scatterplot` and the marginal distributions using :func:`histplot`:
+sns.jointplot(data=penguins, x="culmen_length_mm", y="culmen_depth_mm")
+Similar to :func:`displot`, setting a different ``kind="kde"`` in :func:`jointplot` will change both the joint and marginal plots the use :func:`kdeplot`:
+sns.jointplot(data=penguins, x="culmen_length_mm", y="culmen_depth_mm", kind="kde")
+:func:`jointplot` is really just a convenient interface to :class:`JointGrid`, which offers more flexibility:
+g = sns.JointGrid(data=penguins, x="culmen_length_mm", y="culmen_depth_mm")
+
+g.plot_joint(sns.scatterplot)
+
+g.plot_marginals(sns.boxplot)
+A less-obtrusive way to show marginal distributions uses a "rug" plot, which adds a small tick on the edge of the plot to represent each individual observation. This is built into :func:`displot`:
+sns.displot(penguins, x="culmen_length_mm", y="culmen_depth_mm", kind="kde", rug=True)
+And the axes-level :func:`rugplot` function can be used to add rugs on the side of any other kind of plot:
+sns.relplot(data=penguins, x="culmen_length_mm", y="culmen_depth_mm")
+
+sns.rugplot(data=penguins, x="culmen_length_mm", y="culmen_depth_mm")
+Plotting many distributions
+
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+
+The :func:`pairplot` function offers a similar blend of joint and marginal distributions. Rather than focusing on a single relationship, however, :func:`pairplot` uses a "small-multiple" approach to visualize the univariate distribution of all variables in a dataset along with all of their pairwise relationships:
+sns.pairplot(penguins)
+As with :func:`jointplot`/:class:`JointGrid`, using the underlying :class:`PairGrid` directly will afford more flexibility with only a bit more typing:
+g = sns.PairGrid(penguins)
+
+g.map_upper(sns.histplot)
+
+g.map_lower(sns.kdeplot, fill=True)
+
+g.map_diag(sns.histplot, kde=True)
 .. raw:: html

diff --git a/doc/api.rst b/doc/api.rst
index 563559c5..86b34784 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -43,11 +43,12 @@ Distribution plots
 .. autosummary::
     :toctree: generated/

-    distplot
+    displot
     histplot
-    ecdfplot
     kdeplot
+    ecdfplot
     rugplot
+    distplot

 .. _regression_api:

diff --git a/doc/docstrings/displot.ipynb b/doc/docstrings/displot.ipynb
new file mode 100644
index 00000000..4bcd1b7d
--- /dev/null
+++ b/doc/docstrings/displot.ipynb
@@ -0,0 +1,234 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default plot kind is a histogram:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import seaborn as sns; sns.set(style=\"ticks\")\n",
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.displot(data=penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Use the ``kind`` parameter to select a different representation:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "There are three main plot kinds; in addition to histograms and kernel density estimates (KDEs), you can also draw empirical cumulative distribution functions (ECDFs):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kind=\"ecdf\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "While in histogram mode, it is also possible to add a KDE curve:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", kde=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To draw a bivariate plot, assign both ``x`` and ``y``:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Currently, bivariate plots are available only for histograms and KDEs:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For each kind of plot, you can also show individual observations with a marginal \"rug\":"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(data=penguins, x=\"flipper_length_mm\", y=\"culmen_length_mm\", kind=\"kde\", rug=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Each kind of plot can be drawn separately for subsets of data using ``hue`` mapping:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Additional keyword arguments are passed to the appropriate underlying plotting function, allowing for further customization:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The figure is constructed using a :class:`FacetGrid`, meaning that you can also show subsets on distinct subplots, or \"facets\":"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", col=\"sex\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Because the figure is drawn with a :class:`FacetGrid`, you control its size and shape with the ``height`` and ``aspect`` parameters:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(\n",
+    "    data=penguins, y=\"flipper_length_mm\", hue=\"sex\", col=\"species\",\n",
+    "    kind=\"ecdf\", height=4, aspect=.7,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The function returns the :class:`FacetGrid` object with the plot, and you can use the methods on this object to customize it further:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    data=penguins, y=\"flipper_length_mm\", hue=\"sex\", col=\"species\",\n",
+    "    kind=\"kde\", height=4, aspect=.7,\n",
+    ")\n",
+    "g.set_axis_labels(\"Density (a.u.)\", \"Flipper length (mm)\")\n",
+    "g.set_titles(\"{col_name} penguins\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-py38-latest",
+   "language": "python",
+   "name": "seaborn-py38-latest"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}

diff --git a/doc/docstrings/histplot.ipynb b/doc/docstrings/histplot.ipynb
index 8e8fc5f7..66bf0fe8 100644
--- a/doc/docstrings/histplot.ipynb
+++ b/doc/docstrings/histplot.ipynb
@@ -95,8 +95,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "iris = sns.load_dataset(\"iris\")\n",
-    "sns.histplot(data=iris)"
+    "sns.histplot(data=penguins)"
    ]
   },
   {

diff --git a/doc/releases/v0.11.0.txt b/doc/releases/v0.11.0.txt
index 3e5449d8..232abb45 100644
--- a/doc/releases/v0.11.0.txt
+++ b/doc/releases/v0.11.0.txt
@@ -2,14 +2,21 @@
 v0.11.0 (Unreleased)
 --------------------

-- TODO stub for explaining improvements to variable specificiation. Make this good! (:issue:`2052`).
+- TODO stub for explaining improvements to variable specification. Make this good! (:issue:`2052`).

 - TODO stub for enforced keyword-only arguments for most parameters of most functions and classes (:issue:`2052,2081`).

 Modernization of distribution functions
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

-First, two new functions, :func:`histplot` and :func:`ecdfplot` has been added (:pr:`2125`).
+The distribution module has been completely overhauled, modernizing the API and introducing several new functions and features within existing functions. Some new features are explained here; the :ref:`tutorial documentation <distribution_tutorial>` has also been rewritten and serves as a good introduction to the functions.
+
+New functions
+^^^^^^^^^^^^^
+
+First, three new functions, :func:`displot`, :func:`histplot` and :func:`ecdfplot` has been added.
+
+:func:`displot` is a figure-level interface to the various distribution plot functions (analogous to :func:`relplot` or :func:`catplot`). It can draw univariate or bivariate histograms, density curves, ECDFs, and rug plots on a :class:`FacetGrid`.

 :func:`histplot` draws univariate or bivariate histograms with a number of features, including:

@@ -21,7 +28,12 @@ First, two new functions, :func:`histplot` and :func:`ecdfplot` has been added (

 :func:`ecdfplot` draws univariate empirical cumulative distribution functions, using a similar interface.

-Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled (see :pr:`2060,2104`). Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+Changes to existing functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled (see :pr:`2060,2104`).
+
+Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.

 The overhauled functions now share a common API with the rest of seaborn, they can show conditional distributions by mapping a third variable with a ``hue`` semantic, and have been improved in numerous other ways. The github pull request (:pr:`2104`) has a longer explanation of the changes and the motivation behind them.

@@ -39,20 +51,24 @@ Other new features include:
 - log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
 - "bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)

+Deprecations
+^^^^^^^^^^^^
+
+Finally, the :func:`distplot` function is now formally deprecated. Its features have been subsumed by :func:`displot` and :func:`histplot`. Some effort was made to gradually transition :func:`distplot` by adding the features in :func:`displot` and handling backwards compatibility, but this proved to be too difficult. The similarity in the names will likely cause some confusion during the transition, which is regrettable.

 Other
 ~~~~~

-- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type (:pr:`2084`). Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.
+- Changed how functions that use different representations for numeric and categorical data handle vectors with an ``object`` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data (:pr:`2084`).

-- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default (:pr:`2075`). Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_.
+- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Previously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_ (:pr:`2075`).

-- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. For more information see :pr:`2086`.
+- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. ``k_depth="tukey"`` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. ``k_depth=6``) or just plot boxes that will cover most of the data points (with ``k_depth="full"``). Added a new parameter ``trust_alpha`` to control the number of boxes when ``k_depth="trustworthy"``. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. (:pr:`2086`).

 - Added a ``tight_layout`` method to :class:`FacetGrid` and :class:`PairGrid`, which runs the :func:`matplotlib.pyplot.tight_layout` algorithm without interference from the external legend (:pr:`2073`).

-- Changed how :func:`scatterplot` sets the default linewidth for the edges of the scatter points (:pr:`2078`). New behaviot is to scale with the point sizes themselves (on a plot-wise, not point-wise basis). This change also slightly reduces the default width when point sizes are not varied. Set ``linewidth=0.75`` to repoduce the previous behavior.
+- Changed how :func:`scatterplot` sets the default linewidth for the edges of the scatter points. New behavior is to scale with the point sizes themselves (on a plot-wise, not point-wise basis). This change also slightly reduces the default width when point sizes are not varied. Set ``linewidth=0.75`` to reproduce the previous behavior. (:pr:`2708`).

 - Adapted to a change in matplotlib that prevented passing vectors of literal values to ``c`` and ``s`` in :func:`scatterplot` (:pr:`2079`).

diff --git a/doc/tutorial.rst b/doc/tutorial.rst
index 72c2ee20..630c4fe7 100644
--- a/doc/tutorial.rst
+++ b/doc/tutorial.rst
@@ -52,7 +52,7 @@ Official seaborn tutorial

-
+

diff --git a/doc/tutorial/distributions.ipynb b/doc/tutorial/distributions.ipynb
index e2aeb609..be1d986d 100644
--- a/doc/tutorial/distributions.ipynb
+++ b/doc/tutorial/distributions.ipynb
@@ -13,19 +13,44 @@
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Visualizing the distribution of a dataset\n",
-    "=========================================\n",
+    "Visualizing distributions of data\n",
+    "==================================\n",
     "\n",
     ".. raw:: html\n",
     "\n",
-    "
\n"
+    "
\n",
+    "\n",
+    "The first step of any effort to analyze or model data should be to understand how the variables are distributed. Techniques for distribution visualization can provide quick answers to many important questions. What range do the observations cover? What is their central tendency? Are they heavily skewed in one direction? Is there evidence for bimodality? Are there significant outliers? Do the answers to these questions vary across subsets defined by other variables?\n",
+    "\n",
+    "The :ref:`distributions module <distributions_api>` contains several functions designed to answer questions such as these. The axes-level functions are :func:`histplot`, :func:`kdeplot`, :func:`ecdfplot`, and :func:`rugplot`. They are grouped together within the figure-level :func:`displot`, :func`jointplot`, and :func:`pairplot` functions.\n",
+    "\n",
+    "There are several different approaches to visualizing a distribution, and each has its relative advantages and drawbacks. It is important to understand theses factors so that you can choose the best approach for your particular aim."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": [
+     "hide"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "%matplotlib inline\n",
+    "import seaborn as sns; sns.set()"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "When dealing with a set of data, often the first thing you'll want to do is get a sense for how the variables are distributed. This chapter of the tutorial will give a brief introduction to some of the tools in seaborn for examining univariate and bivariate distributions. You may also want to look at the :ref:`categorical plots <categorical_tutorial>` chapter for examples of functions that make it easy to compare the distribution of a variable across levels of other variables."
+    ".. _tutorial_hist:\n",
+    "\n",
+    "Plotting univariate histograms\n",
+    "------------------------------\n",
+    "\n",
+    "Perhaps the most common approach to visualizing a distribution is the *histogram*. This is the default approach in :func:`displot`, which uses the same underlying code as :func:`histplot`. A histogram is a bar plot where the axis representing the data variable is divided into a set of discrete bins and the count of observations falling within each bin is shown using the height of the corresponding bar:"
    ]
   },
   {
@@ -34,11 +59,20 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import numpy as np\n",
-    "import pandas as pd\n",
-    "import seaborn as sns\n",
-    "import matplotlib.pyplot as plt\n",
-    "from scipy import stats"
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.displot(penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "This plot immediately affords a few insights about the ``flipper_length_mm`` variable. For instance, we can see that the most common flipper length is about 195 mm, but the distribution appears bimodal, so this one number does not represent the data well.\n",
+    "\n",
+    "Choosing the bin size\n",
+    "^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The size of the bins is an important parameter, and using the wrong bin size can mislead by obscuring important features of the data or by creating apparent features out of random variability. By default, :func:`displot`/:func:`histplot` choose a default bin size based on the variance of the data and the number of observations. But you should not be over-reliant on such automatic approaches, because they depend on particular assumptions about the structure of your data. It is always advisable to check that your impressions of the distribution are consistent across different bin sizes. To choose the size directly, set the `binwidth` parameter:"
    ]
   },
   {
@@ -47,31 +81,130 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.set(color_codes=True)"
+    "sns.displot(penguins, x=\"flipper_length_mm\", binwidth=3)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In other circumstances, it may make more sense to specify the *number* of bins, rather than their size:"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "tags": [
-     "hide"
-    ]
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
-    "%matplotlib inline\n",
-    "np.random.seed(sum(map(ord, \"distributions\")))"
+    "sns.displot(penguins, x=\"flipper_length_mm\", bins=20)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "One example of a situation where defaults fail is when the variable takes a relatively small number of integer values. In that case, the default bin width may be too small, creating awkward gaps in the distribution:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tips = sns.load_dataset(\"tips\")\n",
+    "sns.displot(tips, x=\"size\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "One approach would be to specify the precise bin breaks by passing an array to ``bins``:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"size\", bins=[1, 2, 3, 4, 5, 6, 7])"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "This can also be accomplished by setting ``discrete=True``, which chooses bin breaks that represent the unique values in a dataset with bars that are centered on their corresponding value."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"size\", discrete=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Plotting univariate distributions\n",
-    "---------------------------------\n",
+    "It's also possible to visualize the distribution of a categorical variable using the logic of a histogram. Discrete bins are automatically set for categorical variables, but it may also be helpful to \"shrink\" the bars slightly to emphasize the categorical nature of the axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"day\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Conditioning on other variables\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "The most convenient way to take a quick look at a univariate distribution in seaborn is the :func:`distplot` function. By default, this will draw a `histogram <https://en.wikipedia.org/wiki/Histogram>`_ and fit a `kernel density estimate <https://en.wikipedia.org/wiki/Kernel_density_estimation>`_ (KDE). "
+    "Once you understand the distribution of a variable, the next step is often to ask whether features of that distribution differ across other variables in the dataset. For example, what accounts for the bimodal distribution of flipper lengths that we saw above? :func:`displot` and :func:`histplot` provide support for conditional subsetting via the ``hue``semantic. Assigning a variable to ``hue`` will draw a separate histogram for each of its unique values and distinguish them by color:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "By default, the different histograms are \"layered\" on top of each other and, in some cases, they may be difficult to distinguish. One option is to change the visual representation of the histogram from a bar plot to a \"step\" plot:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"step\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Alternatively, instead of layering each bar, they can be \"stacked\", or moved vertically. In this plot, the outline of the full histogram will match the plot with only a single variable:"
    ]
   },
   {
@@ -80,20 +213,51 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.normal(size=100)\n",
-    "sns.distplot(x=x);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Histograms\n",
-    "^^^^^^^^^^\n",
+    "The stacked histogram emphasizes the part-whole relationship between the variables, but it can obscure other features (for example, it is difficult to determine the mode of the Adelie distribution. Another option is \"dodge\" the bars, which moves them horizontally and reduces their width. This ensures that there are no overlaps and that the bars remain comparable in terms of height. But it only works well when the categorical variable has a small number of levels:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"sex\", multiple=\"dodge\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Because :func:`displot` is a figure-level function and is drawn onto a :class:`FacetGrid`, it is also possible to draw each individual distribution in a separate subplot by assigning the second variable to ``col`` or ``row`` rather than (or in addition to) ``hue``. This represents the distribution of each subset well, but it makes it more difficult to draw direct comparisons:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", col=\"sex\", multiple=\"dodge\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "None of these approaches are perfect, and  we will soon see some alternatives to a histogram that are better-suited to the task of comparison.\n",
     "\n",
-    "Histograms are likely familiar, and a ``hist`` function already exists in matplotlib. A histogram represents the distribution of data by forming bins along the range of the data and then drawing bars to show the number of observations that fall in each bin.\n",
+    "Normalized histogram statistics\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "To illustrate this, let's remove the density curve and add a rug plot, which draws a small vertical tick at each observation. You can make the rug plot itself with the :func:`rugplot` function, but it is also available in :func:`distplot`:"
+    "Before we do, another point to note is that, when the subsets have unequal numbers of observations, comparing their distributions in terms of counts may not be ideal. One solution is to *normalize* the counts using the ``stat`` parameter:"
    ]
   },
   {
@@ -102,14 +266,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, kde=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"density\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "When drawing histograms, the main choice you have is the number of bins to use and where to place them. :func:`distplot` uses a simple rule to make a good guess for what the right number is by default, but trying more or fewer bins might reveal other features in the data:"
+    "By default, however, the normalization is applied to the entire distribution, so this simply rescales the height of the bars. By setting ``common_norm=False``, each subset will be normalized independently:"
    ]
   },
   {
@@ -118,19 +282,35 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, bins=20, kde=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"density\", common_norm=False)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    ".. _userguide_kde:\n",
+    "Density normalization scales the bars so that their *areas* sum to 1. As a result, the density axis is not directly interpretable. Another option is to normalize the bars to that their *heights* sum to 1. This makes most sense when the variable is discrete, but  it is an option for all histograms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", stat=\"probability\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    ".. _tutorial_kde:\n",
     "\n",
     "Kernel density estimation\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "-------------------------\n",
     "\n",
-    "The kernel density estimate may be less familiar, but it can be a useful tool for plotting the shape of a distribution. Like the histogram, the KDE plots encode the density of observations on one axis with height along the other axis:"
+    "A histogram aims to approximate the underlying probability density function that generated the data by binning and counting observations. Kernel density estimation (KDE) presents a different solution to the same problem. Rather than using discrete bins, a KDE plot smooths the observations with a Gaussian kernel, producing a continuous density estimate:"
    ]
   },
   {
@@ -139,14 +319,17 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.distplot(x=x, hist=False, rug=True);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Drawing a KDE is more computationally involved than drawing a histogram. What happens is that each observation is first replaced with a normal (Gaussian) curve centered at that value:"
+    "Choosing the smoothing bandwidth\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "Much like with the bin size in the histogram, the ability of the KDE to accurately represent the data depends on the choice of smoothing bandwidth. An over-smoothed estimate might erase meaningful features, but an under-smoothed estimate can obscure the true shape within random noise. The easiest way to check the robustness of the estimate is to adjust the default bandwidth:"
    ]
   },
   {
@@ -155,25 +338,100 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.normal(0, 1, size=30)\n",
-    "bandwidth = 1.06 * x.std() * x.size ** (-1 / 5.)\n",
-    "support = np.linspace(-4, 4, 200)\n",
-    "\n",
-    "kernels = []\n",
-    "for x_i in x:\n",
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\", bw_adjust=.25)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Note how the narrow bandwidth makes the bimodality much more apparent, but the curve is much less smooth. In contrast, a larger bandwidth obscures the bimodality almost completely:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"kde\", bw_adjust=2)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Conditioning on other variables\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "    kernel = stats.norm(x_i, bandwidth).pdf(support)\n",
-    "    kernels.append(kernel)\n",
-    "    plt.plot(support, kernel, color=\"r\")\n",
+    "As with histograms, if you assign a ``hue`` variable, a separate density estimate will be computed for each level of that variable:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In many cases, the layered KDE is easier to interpret than the layered histogram, so it is often a good choice for the task of comparison. Many of the same options for resolving multiple distributions apply to the KDE as well, however:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Note how the stacked plot filled in the area between each curve by default. It is also possible to fill in the curves for single or layered densities, although the default alpha value (opacity) will be different, so that the individual densities are easier to resolve."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"kde\", fill=True)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Kernel density estimation pitfalls\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
     "\n",
-    "sns.rugplot(x=x, color=\".2\", linewidth=3);"
+    "KDE plots have many advantages. Important features of the data are easy to discern (central tendency, bimodality, skew), and they afford easy comparisons between subsets. But there are also situations where KDE poorly represents the underlying data. This is because the logic of KDE assumes that the underlying distribution is smooth and unbounded. One way this assumption can fail is when a varible reflects a quantity that is naturally bounded. If there are observations lying close to the bound (for example, small values of a variable that cannot be negative), the KDE curve may extend to unrealistic values:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(tips, x=\"total_bill\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Next, these curves are summed to compute the value of the density at each point in the support grid. The resulting curve is then normalized so that the area under it is equal to 1:"
+    "This can be partially avoided with the ``cut`` parameter, which specifies how far the curve should extend beyond the extreme datapoints. But this influences only where the curve is drawn; the density estimate will still smooth over the range where no data can exist, causing it to be artifically low at the extremes of the distribution:"
    ]
   },
   {
@@ -182,17 +440,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from scipy.integrate import trapz\n",
-    "density = np.sum(kernels, axis=0)\n",
-    "density /= trapz(density, support)\n",
-    "plt.plot(support, density);"
+    "sns.displot(tips, x=\"total_bill\", kind=\"kde\", cut=0)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "We can see that if we use the :func:`kdeplot` function in seaborn, we get the same curve. This function is used by :func:`distplot`, but it provides a more direct interface with easier access to other options when you just want the density estimate:"
+    "The KDE approach also fails for discrete data or when data are naturally continuous but specific values are over-represented. The important thing to keep in mind is that the KDE will *always show you a smooth curve*, even when the data themselves are not smooth. For example, consider this distribution of diamond weights:"
    ]
   },
   {
@@ -201,14 +456,15 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x, fill=True);"
+    "diamonds = sns.load_dataset(\"diamonds\")\n",
+    "sns.displot(diamonds, x=\"carat\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "The bandwidth (``bw``) parameter of the KDE controls how tightly the estimation is fit to the data, much like the bin size in a histogram. It corresponds to the width of the kernels we plotted above. The default behavior tries to guess a good value using a common reference rule, but it may be helpful to try larger or smaller values:"
+    "While the KDE suggests that there are peaks around specific values, the histogram reveals a much more jagged distribution:"
    ]
   },
   {
@@ -217,17 +473,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x)\n",
-    "sns.kdeplot(x=x, bw=.2, label=\"bw: 0.2\")\n",
-    "sns.kdeplot(x=x, bw=2, label=\"bw: 2\")\n",
-    "plt.legend();"
+    "sns.displot(diamonds, x=\"carat\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "As you can see above, the nature of the Gaussian KDE process means that estimation extends past the largest and smallest values in the dataset. It's possible to control how far past the extreme values the curve is drawn with the ``cut`` parameter; however, this only influences how the curve is drawn and not how it is fit:"
+    "As a compromise, it is possible to combine these two approaches. While in histogram mode, :func:`displot` (as with :func:`histplot`) has the option of including the smoothed KDE curve (note ``kde=True``, not ``kind=\"kde\"``):"
    ]
   },
   {
@@ -236,18 +489,19 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.kdeplot(x=x, fill=True, cut=0)\n",
-    "sns.rugplot(x=x);"
+    "sns.displot(diamonds, x=\"carat\", kde=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Fitting parametric distributions\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    ".. _tutorial_ecdf:\n",
+    "\n",
+    "Empirical cumulative distributions\n",
+    "----------------------------------\n",
     "\n",
-    "You can also use :func:`distplot` to fit a parametric distribution to a dataset and visually evaluate how closely it corresponds to the observed data:"
+    "A third option for visualizing distributions computes the \"empirical cumulative distribution function\" (ECDF). This plot draws a monotonically-increasing curve through each datapoint such that the height of the curve reflects the proportion of observations with a smaller value:"
    ]
   },
   {
@@ -256,18 +510,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x = np.random.gamma(6, size=200)\n",
-    "sns.distplot(x=x, kde=False, fit=stats.gamma);"
+    "sns.displot(penguins, x=\"flipper_length_mm\", kind=\"ecdf\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Plotting bivariate distributions\n",
-    "--------------------------------\n",
-    "\n",
-    "It can also be useful to visualize a bivariate distribution of two variables. The easiest way to do this in seaborn is to just use the :func:`jointplot` function, which creates a multi-panel figure that shows both the bivariate (or joint) relationship between two variables along with the univariate (or marginal) distribution of each on separate axes."
+    "The ECDF plot has two key advantages. Unlike the histogram or KDE, it directly represents each datapoint. That means there is no bin size or smoothing parameter to consider. Additionally, because the curve is monotonically increasing, it is well-suited for comparing multiple distributions:"
    ]
   },
   {
@@ -276,19 +526,24 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "mean, cov = [0, 1], [(1, .5), (.5, 1)]\n",
-    "data = np.random.multivariate_normal(mean, cov, 200)\n",
-    "df = pd.DataFrame(data, columns=[\"x\", \"y\"])"
+    "sns.displot(penguins, x=\"flipper_length_mm\", hue=\"species\", kind=\"ecdf\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Scatterplots\n",
-    "^^^^^^^^^^^^\n",
+    "The major downside to the ECDF plot is that it represents the shape of the distribution less intuitively than a histogram or density curve. Consider how the bimodality of flipper lengths is immediately apparent in the histogram, but to see it in the ECDF plot, you must look for varying slopes. Nevertheless, with practice, you can learn to answer all of the important questions about a distribution by examining the ECDF, and doing so can be a powerful approach."
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Visualizing bivariate distributions\n",
+    "-----------------------------------\n",
     "\n",
-    "The most familiar way to visualize a bivariate distribution is a scatterplot, where each observation is shown with point at the *x* and *y* values. This is analogous to a rug plot on two dimensions. You can draw a scatterplot with :func:`scatterplot`, and it is also the default kind of plot shown by the :func:`jointplot` function:"
+    "All of the examples so far have considered *univariate* distributions: distributions of a single variable, perhaps conditional on a second variable assigned to ``hue``. Assigning a second variable to ``y``, however, will plot a *bivariate* distribution:"
    ]
   },
   {
@@ -297,17 +552,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.jointplot(x=\"x\", y=\"y\", data=df);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Hexbin plots\n",
-    "^^^^^^^^^^^^\n",
-    "\n",
-    "A bivariate analogue of a histogram is known as a \"hexbin\" plot, because it shows the counts of observations that fall within hexagonal bins. This plot works best with relatively large datasets. It's available through in matplotlib as :meth:`matplotlib.axes.Axes.hexbin` and as a style in :func:`jointplot`. It looks best with a white background:"
+    "A bivariate histogram bins the data within rectangles that tile the plot and then shows the count of observations within each rectangle with the fill color (analagous to a :func:`heatmap`). Similarly, a bivariate KDE plot smoothes the (x, y) observations with a 2D Gaussian. The default representation then shows the *contours* of the 2D density:"
    ]
   },
   {
@@ -316,19 +568,30 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "x, y = np.random.multivariate_normal(mean, cov, 1000).T\n",
-    "with sns.axes_style(\"white\"):\n",
-    "    sns.jointplot(x=x, y=y, kind=\"hex\", color=\"k\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Kernel density estimation\n",
-    "^^^^^^^^^^^^^^^^^^^^^^^^^\n",
-    "\n",
-    "It is also possible to use the kernel density estimation procedure described above to visualize a bivariate distribution. In seaborn, this kind of plot is shown with a contour plot and is available as a style in :func:`jointplot`:"
+    "Assigning a ``hue`` variable will plot multiple heatmaps or contour sets using different colors. For bivariate histograms, this will only work well if there is minimal overlap between the conditional distributions:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The contour approach of the bivariate KDE plot lends itself better to evaluating overlap, although a plot with too many contours can get busy:"
    ]
   },
   {
@@ -337,14 +600,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.jointplot(x=\"x\", y=\"y\", data=df, kind=\"kde\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", hue=\"species\", kind=\"kde\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "You can also draw a two-dimensional kernel density plot with the :func:`kdeplot` function. This allows you to draw this kind of plot onto a specific (and possibly already existing) matplotlib axes, whereas the :func:`jointplot` function manages its own figure:"
+    "Just as with univariate plots, the choice of bin size or smoothing bandwidth will determine how well the plot represents the underlying bivariate distribution. The same parameters apply, but they can be tuned for each variable by passing a pair of values:"
    ]
   },
   {
@@ -353,17 +616,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "f, ax = plt.subplots(figsize=(6, 6))\n",
-    "sns.kdeplot(x=df.x, y=df.y, ax=ax)\n",
-    "sns.rugplot(x=df.x, color=\"g\", ax=ax)\n",
-    "sns.rugplot(x=df.y, vertical=True, ax=ax);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", binwidth=(2, .5))"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "If you wish to show the bivariate density more continuously, you can simply increase the number of contour levels:"
+    "To aid interpretation of the heatmap, add a colorbar to show the mapping between counts and color intensity:"
    ]
   },
   {
@@ -372,16 +632,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "f, ax = plt.subplots(figsize=(6, 6))\n",
-    "cmap = sns.cubehelix_palette(as_cmap=True, dark=0, light=1, reverse=True)\n",
-    "sns.kdeplot(data=df, x=\"x\", y=\"y\", cmap=cmap, levels=60, fill=True);"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", binwidth=(2, .5), cbar=True)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "The :func:`jointplot` function uses a :class:`JointGrid` to manage the figure. For more flexibility, you may want to draw your figure by using :class:`JointGrid` directly. :func:`jointplot` returns the :class:`JointGrid` object after plotting, which you can use to add more layers or to tweak other aspects of the visualization:"
+    "The meaning of the bivariate density contours is less straightforward. Because the density is not directly interpretable, the contours are drawn at *iso-proportions* of the density, meaning that each curve shows a level set such that some proportion *p* of the density lies below it. The *p* values are evenly spaced, with the lowest level contolled by the ``thresh`` parameter and the number controlled by ``levels``:"
    ]
   },
   {
@@ -390,20 +648,71 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "g = sns.jointplot(x=\"x\", y=\"y\", data=df, kind=\"kde\", color=\"m\")\n",
-    "g.plot_joint(plt.scatter, c=\"w\", s=30, linewidth=1, marker=\"+\")\n",
-    "g.ax_joint.collections[0].set_alpha(0)\n",
-    "g.set_axis_labels(\"$X$\", \"$Y$\");"
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", thresh=.2, levels=4)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Visualizing pairwise relationships in a dataset\n",
-    "-----------------------------------------------\n",
+    "The ``levels`` parameter also accepts a list of values, for more control:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", levels=[.01, .05, .1, .8])"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "The bivariate histogram allows one or both variables to be discrete. Plotting one discrete and one continuous variable offers another way to compare conditional univariate distributions:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(diamonds, x=\"price\", y=\"clarity\", log_scale=(True, False))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "In contrast, plotting two discrete variables is an easy to way show the cross-tabulation of the observations:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(diamonds, x=\"color\", y=\"clarity\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Distribution visualization in other settings\n",
+    "--------------------------------------------\n",
+    "\n",
+    "Several other figure-level plotting functions in seaborn make use of the :func:`histplot` and :func:`kdeplot` functions.\n",
     "\n",
-    "To plot multiple pairwise bivariate distributions in a dataset, you can use the :func:`pairplot` function. This creates a matrix of axes and shows the relationship for each pair of columns in a DataFrame. By default, it also draws the univariate distribution of each variable on the diagonal Axes:"
+    "\n",
+    "Plotting joint and marginal distributions\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The first is :func:`jointplot`, which augments a bivariate relatonal or distribution plot with the marginal distributions of the two variables. By default, :func:`jointplot` represents the bivariate distribution using :func:`scatterplot` and the marginal distributions using :func:`histplot`:"
    ]
   },
   {
@@ -412,15 +721,84 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "iris = sns.load_dataset(\"iris\")\n",
-    "sns.pairplot(iris);"
+    "sns.jointplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Specifying the ``hue`` parameter automatically changes the histograms to KDE plots to facilitate comparisons between multiple distributions."
+    "Similar to :func:`displot`, setting a different ``kind=\"kde\"`` in :func:`jointplot` will change both the joint and marginal plots the use :func:`kdeplot`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.jointplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    ":func:`jointplot` is really just a convenient interface to :class:`JointGrid`, which offers more flexibility:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.JointGrid(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")\n",
+    "g.plot_joint(sns.scatterplot)\n",
+    "g.plot_marginals(sns.boxplot)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "A less-obtrusive way to show marginal distributions uses a \"rug\" plot, which adds a small tick on the edge of the plot to represent each individual observation. This is built into :func:`displot`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.displot(penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\", kind=\"kde\", rug=True)"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "And the axes-level :func:`rugplot` function can be used to add rugs on the side of any other kind of plot:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.relplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")\n",
+    "sns.rugplot(data=penguins, x=\"culmen_length_mm\", y=\"culmen_depth_mm\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "metadata": {},
+   "source": [
+    "Plotting many distributions\n",
+    "^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+    "\n",
+    "The :func:`pairplot` function offers a similar blend of joint and marginal distributions. Rather than focusing on a single relationship, however, :func:`pairplot` uses a \"small-multiple\" approach to visualize the univariate distribution of all variables in a dataset along with all of their pairwise relationships:"
    ]
   },
   {
@@ -429,14 +807,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "sns.pairplot(iris, hue=\"species\");"
+    "sns.pairplot(penguins)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "Much like the relationship between :func:`jointplot` and :class:`JointGrid`, the :func:`pairplot` function is built on top of a :class:`PairGrid` object, which can be used directly for more flexibility:"
+    "As with :func:`jointplot`/:class:`JointGrid`, using the underlying :class:`PairGrid` directly will afford more flexibility with only a bit more typing:"
    ]
   },
   {
@@ -445,9 +823,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "g = sns.PairGrid(iris)\n",
-    "g.map_diag(sns.kdeplot)\n",
-    "g.map_offdiag(sns.kdeplot, n_levels=6);"
+    "g = sns.PairGrid(penguins)\n",
+    "g.map_upper(sns.histplot)\n",
+    "g.map_lower(sns.kdeplot, fill=True)\n",
+    "g.map_diag(sns.histplot, kde=True)"
    ]
   },
   {

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'axisgrid'}, {'type': 'function', 'name': 'iter_data'}, {'type': 'function', 'name': 'var_levels'}, {'type': 'function', 'name': '_get_axes'}, {'type': 'field', 'name': '_assign_default_kwargs'}, {'type': 'field', 'name': 'axes_dict'}, {'type': 'field', 'name': 'axisgrid'}, {'type': 'field', 'name': 'var_levels'}, {'type': 'function', 'name': '_log_scaled'}, {'type': 'field', 'name': '_log_scaled'}, {'type': 'field', 'name': 'var_levels'}, {'type': 'field', 'name': '_get_axes'}, {'type': 'file', 'name': 'examples/multiple_ecdf.py'}, {'type': 'file', 'name': 'examples/three_variable_histogram.py'}, {'type': 'field', 'name': 'iter_data'}, {'type': 'field', 'name': 'iter_data'}, {'type': 'function', 'name': '_assign_default_kwargs'}]