Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] pivot_wider() and pivot_longer()
# Brief Description

Pandas has several functions for pivoting. The `melt` function takes a 'wide' dataframe and makes it 'long', and `pivot_table` takes a 'long' dataframe and makes it 'wide'. Similarly, `stack` and `unstack` perform pivot operations using multilevel indices. Tom Augspurger explains all of the pandas pivoting functions in his [Modern Pandas guide](https://tomaugspurger.github.io/modern-5-tidy). If you look through his guide, you can see that the API for these four functions are all different and difficult to remember.

I would like to propose `pivot_wider` and `pivot_longer` functions inspired by functions in the R [tidyr package](https://tidyr.tidyverse.org/dev/articles/pivot.html). These functions are pure syntactic sugar around pandas `melt` and `unpivot`. They should have consistent APIs, and they should be symmetric. That is, you should be able to take a 'wide' dataframe, pass it through `pivot_longer`, then pass it through `pivot_wider` to get back to the exact same 'wide' dataframe that we started with.

# Example API

Imagine that we have a 'wide' table of heartrate data for patients treated under two different drugs, *a* and *b*.

| name | a | b |
| --- | --- | --- |
| Wilbur | 67 | 56 |
| Petunia | 80 | 90 |
| Gregory | 64 | 50 |

Here's how we would convert this to a 'long' table.

```python
df.pivot_longer(column_names=['a', 'b'], names_to=['drug'], values_to=['heartrate'])
```

The output would be a 'long' table that look like this.

| name | drug | heartrate |
|---|---|---|
| Wilbur | a   |     67  |
| Petunia |   a  |      80 |
| Gregory |   a  |      64 |
| Wilbur  |   b   |    56 |
| Petunia |    b  |    90 |
| Gregory  |  b   |     50 | 

Now we'll take the above table and transform it back into a 'wide' table using `pivot_wider`.

```python
df.pivot_wider(names_from=['drug'], values_from=['heartrate'])
```

And now we're back to our 'wide' table:

| name | a | b |
| --- | --- | --- |
| Wilbur | 67 | 56 |
| Petunia | 80 | 90 |
| Gregory | 64 | 50 |

I would love some feedback on the API before I start implementing this in code!


Discussion:
I’m also happy to work on this issue!
I think this is a great idea! `value_name` and `value_vars` sometimes gets confusing and I think this is more clear. However the best part is that in Pandas these are external functions from the `DataFrame`; they're module-level (`pd.melt`, `pd.pivot_table`). This would be great to add to the method-chaining syntax.

Also, to see the difference, below is the same code above implemented in standard Pandas:

```python

import pandas as pd

name = ['Wilbur', 'Petunia', 'Gregory']
a = [67, 80, 64]
b = [56, 90, 50]

dat = pd.DataFrame({'name': name, 'a': a, 'b': b})

long_dat = pd.melt(dat.reset_index(), id_vars='name', value_vars=['a', 'b'], value_name='heartrate').rename(columns={'variable': 'drug'})

wide_dat = pd.pivot_table(long_dat.reset_index(), values='heartrate', index=['name'], columns=['drug']).reset_index()
```

The automatic column renaming definitely nice and can get annoying using the standard version.

One thing to think about, though, is how indicies will work. I did a `reset_index()` which is probably what you want, but still something to think about. Similarly, when using `pd.pivot_table` the index gets named to `drug`. Not sure what you think about having a named index.
I'm in agreement with @szuckerman here. This looks good, @benjaminjack! Don't forget to add tests and docstring :smile:.
Thanks for all the feedback everyone! I took a first pass at implementation, docs, and tests for `pivot_longer` here: https://github.com/benjaminjack/pyjanitor/commit/e3df817903c20dd21634461c8a92aec137963ed0

A few questions:
1. How should we handle indexes? Right now I'm just resetting the index before `pd.melt`. This may cause problems with multilevel indexes though.
2. Any guidance on using Hypothesis for unit testing? Is it necessary here? I have not used Hypothesis before.
3. What's best practice for error-handling? Catch any errors from the underlying `pd.melt` and generate more user-friendly errors? Or leave as is?

Also, feel free to assign me this issue, since I am working on it :)
Whoa, @benjaminjack, it looks like I let this ball drop. Many apologies! I didn't mean to, I think I opened up the issue, read it, but didn't have enough time to think carefully about your questions, and then left to do something else.

Let me try addressing the questions, having read your branch's latest commit.

> How should we handle indexes? Right now I'm just resetting the index before pd.melt. This may cause problems with multilevel indexes though.

In this case, I am quite sure that resetting the index is the correct thing to do. I'm not sure how to explain my reasoning, though, as I'm struggling to find the words to describe it, so I'm right now being guided by intuition. 

If you're feeling unsure about it, I think it's not an issue to add a `reset_index=True` kwarg. I am tempted to add that for the `.shuffle()` function, and leave it on by default, because again, that intuitively sounds like the right thing to do.

> Any guidance on using Hypothesis for unit testing? Is it necessary here? I have not used Hypothesis before.

Hypothesis is usually the "next step up" from example-based testing. I want to keep the contribution barrier low, so I have never historically required a Hypothesis-generated test. If you're feeling adventurous, feel free to do so, but do know that an example-based test is sufficient.

> What's best practice for error-handling? Catch any errors from the underlying pd.melt and generate more user-friendly errors? Or leave as is?

I think user-friendly errors are great! They help not only end-users, but also us maintainers, as it's quite explicit and clear as to what is being caught. That said, don't feel pressured to handle it all; we can always leave this as a new issue that a newcomer can tackle.

-----

Aside from that, @benjaminjack, I'd encourage you to put in the PR even if it's not done! You can put a [WIP] in the PR title, and that will signal to me that it's not ready for merging (I also have a WIP bot that serves as a second check.) You can continually update the PR with new commits; they automatically get tacked on. 

If you put in the PR, that'll also keep the issue visible in my mind, as I check the PR tab more than the issue tracker 😂.
@benjaminjack given the inactivity here, I will release this issue back into the pool of “available for hacking” issues. Hope you’re ok with that! If you’d like to take it on later, please feel free to ping back here! :smile:
@benjaminjack I took a second look at the code that you had written. It's got very good documentation, and I would be loathe to claim that work as my own. Would you be interested in re-opening the PR? 

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -37,8 +37,10 @@
     _clean_accounting_column,
     _complete_groupings,
     _computations_pivot_longer,
+    _computations_pivot_wider,
     _currency_column_to_numeric,
     _data_checks_pivot_longer,
+    _data_checks_pivot_wider,
     _grid_computation,
     _pivot_longer_pattern_match,
     _replace_empty_string_with_none,
@@ -5009,3 +5011,193 @@ def pivot_longer(
     )
 
     return df
+
+
+@pf.register_dataframe_method
+def pivot_wider(
+    df: pd.DataFrame,
+    index: Optional[Union[List, str]] = None,
+    names_from: Optional[Union[List, str]] = None,
+    values_from: Optional[Union[List, str]] = None,
+    names_sort: Optional[bool] = False,
+    flatten_levels: Optional[bool] = True,
+    values_from_first: Optional[bool] = True,
+    names_prefix: Optional[str] = None,
+    names_sep: Optional[str] = "_",
+    fill_value: Optional[Union[int, float, str]] = None,
+) -> pd.DataFrame:
+    """
+    Reshapes data from long to wide form. The number of columns are
+    increased, while decreasing the number of rows. It is the inverse
+    of the `pivot_longer` method, and is a wrapper around
+    `pd.DataFrame.unstack`
+    method.
+    This method does not mutate the original DataFrame.
+    Reshaping to wide form :
+    .. code-block:: python
+             name variable  value
+        0   Alice      wk1      5
+        1   Alice      wk2      9
+        2   Alice      wk3     20
+        3   Alice      wk4     22
+        4     Bob      wk1      7
+        5     Bob      wk2     11
+        6     Bob      wk3     17
+        7     Bob      wk4     33
+        8   Carla      wk1      6
+        9   Carla      wk2     13
+        10  Carla      wk3     39
+        11  Carla      wk4     40
+        df = (
+            pd.DataFrame(...)
+            .pivot_wider(
+                index="name",
+                names_from="variable",
+                values_from="value"
+            )
+             name    wk1   wk2   wk3   wk4
+        0    Alice     5     9    20    22
+        1    Bob       7    11    17    33
+        2    Carla     6    13    39    40
+    Pivoting on multiple columns is possible :
+    .. code-block:: python
+            name    n  pct
+        0     1  10.0  0.1
+        1     2  20.0  0.2
+        2     3  30.0  0.3
+        df = (
+            pd.DataFrame(...)
+            .assign(num=0)
+            .pivot_wider(
+                index='num',
+                names_from="name",
+                values_from=["n", "pct"]
+             )
+         )
+            num n_1  n_2  n_3  pct_1  pct_2  pct_3
+        0   0   10   20   30   0.1    0.2    0.3
+    .. note:: You may choose not to collapse the levels by passing `False`
+        to the ``collapse_levels`` argument.
+    .. note:: An error is raised if the index is not unique.
+    Functional usage syntax:
+    .. code-block:: python
+        import pandas as pd
+        import janitor as jn
+        df = pd.DataFrame(...)
+        df = jn.pivot_wider(
+            df = df,
+            index = [column1, column2, ...],
+            names_from = [column3, column4, ...],
+            value_from = [column5, column6, ...],
+            names_sort = True/False,
+            names_prefix = string,
+            names_sep = string,
+            flatten_levels=True/False,
+            values_from_first=True/False,
+            fill_value=fill_value
+        )
+    Method chaining syntax:
+    .. code-block:: python
+        df = (
+            pd.DataFrame(...)
+            .pivot_wider(
+                index = [column1, column2, ...],
+                names_from = [column3, column4, ...],
+                value_from = [column5, column6, ...],
+                names_sort = True/False,
+                names_prefix = string,
+                names_sep = string,
+                flatten_levels=True/False,
+                values_from_first=True/False,
+                fill_value=fill_value
+                )
+        )
+    :param df: A pandas dataframe.
+    :param index: Name(s) of columns to use as identifier variables.
+        Should be either a single column name, or a list of column names.
+        If `index` is not provided, the current frame's index is used.
+    :param names_from: Name(s) of columns to pivot. Should be either
+        a single column name, or a list of column names. A label or labels
+        must be provided for ``names_from``.
+    :param values_from: Name of column that will be used for populating new
+        frame's values. Should be either a single column name, or a list of
+        column names. By default, if ``values_from`` is a list, the value
+        will be added to the front of the output column; you can turn this
+        off with the `values_from_first` argument. If ``values_from`` is not
+        specified, all remaining columns will be used.
+    :param names_sort: Default is `False`. Sorts columns by order of
+        appearance. Applicable only if ``flatten_levels`` is `True`.
+    :param flatten_levels: Default is `True`. Determines if the reshaped
+        dataframe stays as a MultiIndex.
+    :param values_from_first: Determines if the values in ``values_from`` will
+        be at the front of the output column. This applies if ``values_from``
+        is a list, and the levels are flattened. Default is True.
+    :param names_prefix: String to be added to the front of each output column.
+        Can be handy if the values in ``names_from`` are numeric data types.
+        Applicable only if the levels are flattened.
+    :param names_sep: If ``names_from`` or ``values_from`` contain multiple
+        variables, this will be used to join their values into a single string
+        to use as a column name. Default is ``_``. Applicable only if the
+        levels are flattened.
+    :param fill_value: Value to replace missing values with (after pivoting).
+        It can be a number, string, or a dictionary, where the keys are the
+        column_names, while the values are the values to replace the missing
+        values with.
+    :returns: A pandas DataFrame that has been unpivoted from long to wide
+        form.
+    :raises TypeError: if `index` or `names_from` is not a string, or a list of
+        strings.
+    :raises ValueError: if `names_from` is None.
+    :raises TypeError: if `names_sep` is not a string.
+    :raises TypeError: if `values_from` is not a string or a list of strings.
+    :raises TypeError: if `names_sort` is not a boolean.
+    :raises TypeError: if `flatten_levels` is not a boolean.
+    :raises ValueError: if values in `index` or `names_from` or `values_from`
+        do not exist in the dataframe.
+    :raises ValueError: if the combination of `index` and `names_from` is not
+        unique.
+
+
+    .. # noqa: DAR402
+    """
+
+    df = df.copy()
+
+    (
+        df,
+        index,
+        names_from,
+        values_from,
+        names_sort,
+        flatten_levels,
+        values_from_first,
+        names_prefix,
+        names_sep,
+        fill_value,
+    ) = _data_checks_pivot_wider(
+        df,
+        index,
+        names_from,
+        values_from,
+        names_sort,
+        flatten_levels,
+        values_from_first,
+        names_prefix,
+        names_sep,
+        fill_value,
+    )
+
+    df = _computations_pivot_wider(
+        df,
+        index,
+        names_from,
+        values_from,
+        names_sort,
+        flatten_levels,
+        values_from_first,
+        names_prefix,
+        names_sep,
+        fill_value,
+    )
+
+    return df
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -9,6 +9,7 @@
 
 import numpy as np
 import pandas as pd
+from pandas.api.types import CategoricalDtype
 
 from .errors import JanitorError
 
@@ -957,3 +958,182 @@ def _computations_pivot_longer(
             df = df.astype(dtypes)
 
         return df
+
+
+def _data_checks_pivot_wider(
+    df,
+    index,
+    names_from,
+    values_from,
+    names_sort,
+    flatten_levels,
+    values_from_first,
+    names_prefix,
+    names_sep,
+    fill_value,
+):
+
+    """
+    This function raises errors if the arguments have the wrong
+    python type, or if the column does not exist in the dataframe.
+    This function is executed before proceeding to the computation phase.
+    Type annotations are not provided because this function is where type
+    checking happens.
+    """
+
+    if index is not None:
+        if isinstance(index, str):
+            index = [index]
+        check("index", index, [list])
+        check_column(df, index, present=True)
+
+    if names_from is None:
+        raise ValueError(
+            "pivot_wider() missing 1 required argument: 'names_from'"
+        )
+
+    if names_from is not None:
+        if isinstance(names_from, str):
+            names_from = [names_from]
+        check("names_from", names_from, [list])
+        check_column(df, names_from, present=True)
+
+    if values_from is not None:
+        check("values_from", values_from, [list, str])
+        if isinstance(values_from, str):
+            check_column(df, [values_from], present=True)
+        else:
+            check_column(df, values_from, present=True)
+
+    if values_from_first is not None:
+        check("values_from_first", values_from_first, [bool])
+
+    check("names_sort", names_sort, [bool])
+
+    check("flatten_levels", flatten_levels, [bool])
+
+    if names_prefix is not None:
+        check("names_prefix", names_prefix, [str])
+
+    if names_sep is not None:
+        check("names_sep", names_sep, [str])
+
+    if fill_value is not None:
+        check("fill_value", fill_value, [int, float, str])
+
+    return (
+        df,
+        index,
+        names_from,
+        values_from,
+        names_sort,
+        flatten_levels,
+        values_from_first,
+        names_prefix,
+        names_sep,
+        fill_value,
+    )
+
+
+def _computations_pivot_wider(
+    df: pd.DataFrame,
+    index: Optional[Union[List, str]] = None,
+    names_from: Optional[Union[List, str]] = None,
+    values_from: Optional[Union[List, str]] = None,
+    names_sort: Optional[bool] = False,
+    flatten_levels: Optional[bool] = True,
+    values_from_first: Optional[bool] = True,
+    names_prefix: Optional[str] = None,
+    names_sep: Optional[str] = "_",
+    fill_value: Optional[Union[int, float, str]] = None,
+) -> pd.DataFrame:
+    """
+    This is the main workhorse of the `pivot_wider` function.
+    If `values_from` is a list, then every item in `values_from`
+    will be added to the front of each output column. This option
+    can be turned off with the `values_from_first` argument, in
+    which case, the `names_from` variables (or `names_prefix`, if
+    present) start each column.
+    The `unstack` method is used here, and not `pivot`; multiple
+    labels in the `index` or `names_from` are supported in the
+    `pivot` function for Pandas 1.1 and above. Also, the
+    `pivot_table` function is not used, because it is quite slow,
+    compared to the `pivot` function and `unstack`.
+    The columns are sorted in the order of appearance from the
+    source data. This only occurs if `flatten_levels` is True.
+    """
+
+    # TODO : include an aggfunc argument
+    if values_from is None:
+        if index:
+            values_from = [
+                col for col in df.columns if col not in (index + names_from)
+            ]
+        else:
+            values_from = [col for col in df.columns if col not in names_from]
+
+    dtypes = None
+    before = None
+    # ensure `names_sort` is in combination with `flatten_levels`
+    if all((names_sort is False, flatten_levels)):
+        # dtypes only needed for names_from
+        # since that is what will become the new column names
+        dtypes = {
+            column_name: CategoricalDtype(
+                categories=column.unique(), ordered=True
+            )
+            for column_name, column in df.filter(names_from).items()
+        }
+        if index is not None:
+            before = df.filter(index)
+            if before.duplicated().any():
+                before = before.drop_duplicates()
+
+        df = df.astype(dtypes)
+
+    if index is None:  # use existing index
+        df = df.set_index(names_from, append=True)
+    else:
+        df = df.set_index(index + names_from)
+
+    if not df.index.is_unique:
+        raise ValueError(
+            """
+            There are non-unique values in your combination
+            of `index` and `names_from`. Kindly provide a
+            unique identifier for each row.
+            """
+        )
+
+    df = df.loc[:, values_from]
+    df = df.unstack(names_from, fill_value=fill_value)  # noqa: PD010
+    if flatten_levels:
+        if isinstance(values_from, list):
+            df.columns = df.columns.set_names(level=0, names="values_from")
+            if not values_from_first:
+                df = df.reorder_levels(names_from + ["values_from"], axis=1)
+        if df.columns.nlevels > 1:
+            df.columns = [names_sep.join(entry) for entry in df]
+        if names_prefix:
+            df = df.add_prefix(names_prefix)
+        df.columns = list(
+            df.columns
+        )  # blanket approach that covers categories
+
+        if index:
+            # this way we avoid having to convert index
+            # from category to original dtype
+            # while still maintaining order of appearance
+            if names_sort is False:
+                df = before.merge(
+                    df, how="left", left_on=index, right_index=True
+                ).reset_index(drop=True)
+            else:
+                df = df.reset_index()
+        else:
+            # remove default index, since it is irrelevant
+            df = df.reset_index().iloc[:, 1:]
+
+        return df
+
+    return df


Test patch:
diff --git a/tests/functions/test_pivot_wider.py b/tests/functions/test_pivot_wider.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_pivot_wider.py
@@ -0,0 +1,526 @@
+import numpy as np
+import pandas as pd
+import pytest
+from pandas.testing import assert_frame_equal
+
+
+@pytest.fixture
+def df_checks_output():
+    return pd.DataFrame(
+        {
+            "geoid": [1, 1, 13, 13],
+            "name": ["Alabama", "Alabama", "Georgia", "Georgia"],
+            "variable": [
+                "pop_renter",
+                "median_rent",
+                "pop_renter",
+                "median_rent",
+            ],
+            "estimate": [1434765, 747, 3592422, 927],
+            "error": [16736, 3, 33385, 3],
+        }
+    )
+
+
+combinations = [
+    (
+        pd.DataFrame(
+            {
+                "geoid": [1, 1, 1, 1, 13, 13, 13, 13],
+                "name": [
+                    "Alabama",
+                    "Alabama",
+                    "Alabama",
+                    "Alabama",
+                    "Georgia",
+                    "Georgia",
+                    "Georgia",
+                    "Georgia",
+                ],
+                "variable": [
+                    "pop_renter",
+                    "pop_renter",
+                    "median_rent",
+                    "median_rent",
+                    "pop_renter",
+                    "pop_renter",
+                    "median_rent",
+                    "median_rent",
+                ],
+                "measure": [
+                    "estimate",
+                    "error",
+                    "estimate",
+                    "error",
+                    "estimate",
+                    "error",
+                    "estimate",
+                    "error",
+                ],
+                "value": [1434765, 16736, 747, 3, 3592422, 33385, 927, 3],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "geoid": [1, 13],
+                "name": ["Alabama", "Georgia"],
+                "pop_renter_estimate": [1434765, 3592422],
+                "pop_renter_error": [16736, 33385],
+                "median_rent_estimate": [747, 927],
+                "median_rent_error": [3, 3],
+            }
+        ),
+        ["geoid", "name"],
+        ["variable", "measure"],
+        "value",
+        None,
+        True,
+    ),
+    (
+        pd.DataFrame(
+            {
+                "family": ["Kelly", "Kelly", "Quin", "Quin"],
+                "name": ["Mark", "Scott", "Tegan", "Sara"],
+                "n": [1, 2, 1, 2],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "family": ["Kelly", "Quin"],
+                1: ["Mark", "Tegan"],
+                2: ["Scott", "Sara"],
+            }
+        ),
+        "family",
+        "n",
+        "name",
+        None,
+        True,
+    ),
+    (
+        pd.DataFrame(
+            {
+                "family": ["Kelly", "Kelly", "Quin", "Quin"],
+                "name": ["Mark", "Scott", "Tegan", "Sara"],
+                "n": [1, 2, 1, 2],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "family": ["Kelly", "Quin"],
+                "name1": ["Mark", "Tegan"],
+                "name2": ["Scott", "Sara"],
+            }
+        ),
+        "family",
+        "n",
+        "name",
+        "name",
+        True,
+    ),
+    (
+        pd.DataFrame(
+            {
+                "geoid": [1, 1, 13, 13],
+                "name": ["Alabama", "Alabama", "Georgia", "Georgia"],
+                "variable": [
+                    "pop_renter",
+                    "median_rent",
+                    "pop_renter",
+                    "median_rent",
+                ],
+                "estimate": [1434765, 747, 3592422, 927],
+                "error": [16736, 3, 33385, 3],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "geoid": [1, 13],
+                "name": ["Alabama", "Georgia"],
+                "estimate_pop_renter": [1434765, 3592422],
+                "estimate_median_rent": [747, 927],
+                "error_pop_renter": [16736, 33385],
+                "error_median_rent": [3, 3],
+            }
+        ),
+        ["geoid", "name"],
+        "variable",
+        ["estimate", "error"],
+        None,
+        True,
+    ),
+    (
+        pd.DataFrame(
+            {
+                "geoid": [1, 1, 13, 13],
+                "name": ["Alabama", "Alabama", "Georgia", "Georgia"],
+                "variable": [
+                    "pop_renter",
+                    "median_rent",
+                    "pop_renter",
+                    "median_rent",
+                ],
+                "estimate": [1434765, 747, 3592422, 927],
+                "error": [16736, 3, 33385, 3],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "geoid": {0: 1, 1: 13},
+                "name": {0: "Alabama", 1: "Georgia"},
+                "pop_renter_estimate": {0: 1434765, 1: 3592422},
+                "median_rent_estimate": {0: 747, 1: 927},
+                "pop_renter_error": {0: 16736, 1: 33385},
+                "median_rent_error": {0: 3, 1: 3},
+            }
+        ),
+        ["geoid", "name"],
+        "variable",
+        ["estimate", "error"],
+        None,
+        False,
+    ),
+]
+
+
+def test_type_index1(df_checks_output):
+    "Raise TypeError if wrong type is provided for the `index`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(index={"geoid"}, names_from="variable")
+
+
+def test_type_index2(df_checks_output):
+    "Raise TypeError if wrong type is provided for the `index`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index=("geoid", "name"), names_from="variable"
+        )
+
+
+def test_type_names_from1(df_checks_output):
+    "Raise TypeError if wrong type is provided for `names_from`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(index="geoid", names_from={"variable"})
+
+
+def test_type_names_from2(df_checks_output):
+    "Raise TypeError if wrong type is provided for `names_from`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(index="geoid", names_from=("variable",))
+
+
+def test_names_from_None(df_checks_output):
+    "Raise ValueError if no value is provided for ``names_from``."
+    with pytest.raises(ValueError):
+        df_checks_output.pivot_wider(index="geoid", names_from=None)
+
+
+def test_presence_index1(df_checks_output):
+    "Raise ValueError if labels in `index` do not exist."
+    with pytest.raises(ValueError):
+        df_checks_output.pivot_wider(index="geo", names_from="variable")
+
+
+def test_presence_index2(df_checks_output):
+    "Raise ValueError if labels in `index` do not exist."
+    with pytest.raises(ValueError):
+        df_checks_output.pivot_wider(
+            index=["geoid", "Name"], names_from="variable"
+        )
+
+
+def test_presence_names_from1(df_checks_output):
+    "Raise ValueError if labels in `names_from` do not exist."
+    with pytest.raises(ValueError):
+        df_checks_output.pivot_wider(index="geoid", names_from="estmt")
+
+
+def test_presence_names_from2(df_checks_output):
+    "Raise ValueError if labels in `names_from` do not exist."
+    with pytest.raises(ValueError):
+        df_checks_output.pivot_wider(index="geoid", names_from=["estimat"])
+
+
+def test_values_from_first_wrong_type(df_checks_output):
+    "Raise TypeError if the wrong type is provided for `values_from_first`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name",
+            names_from=["estimate", "variable"],
+            values_from_first=2,
+        )
+
+
+def test_names_sort_wrong_type(df_checks_output):
+    "Raise TypeError if the wrong type is provided for `names_sort`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name", names_from=["estimate", "variable"], names_sort=2,
+        )
+
+
+def test_flatten_levels_wrong_type(df_checks_output,):
+    "Raise TypeError if the wrong type is provided for `flatten_levels`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name",
+            names_from=["estimate", "variable"],
+            flatten_levels=2,
+        )
+
+
+def test_name_prefix_wrong_type(df_checks_output):
+    "Raise TypeError if the wrong type is provided for `names_prefix`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name", names_from=["estimate", "variable"], names_prefix=1
+        )
+
+
+def test_name_sep_wrong_type(df_checks_output):
+    "Raise TypeError if the wrong type is provided for `names_sep`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name", names_from=["estimate", "variable"], names_sep=1
+        )
+
+
+def test_fill_value_wrong_type(df_checks_output):
+    "Raise TypeError if the wrong type is provided for `fill_value`."
+    with pytest.raises(TypeError):
+        df_checks_output.pivot_wider(
+            index="name", names_from=["estimate", "variable"], fill_value={2}
+        )
+
+
+def test_non_unique_index_names_from_combination():
+    """
+    Raise ValueError for non-unique combination of
+    `index` and `names_from`.
+    """
+    df = pd.DataFrame(
+        {"A": ["A", "A", "A"], "L": ["L", "L", "L"], "numbers": [30, 54, 25]}
+    )
+    with pytest.raises(ValueError):
+        df.pivot_wider(index="A", names_from="L")
+
+
+def test_pivot_long_wide_long():
+    """
+    Test transformation from long to wide and back to long form.
+    """
+
+    df_in = pd.DataFrame(
+        [
+            {
+                "a": 1,
+                "b": 2,
+                "name": "ben",
+                "points": 22,
+                "marks": 5,
+                "sets": 13,
+            },
+            {
+                "a": 1,
+                "b": 2,
+                "name": "dave",
+                "points": 23,
+                "marks": 4,
+                "sets": 11,
+            },
+        ]
+    )
+
+    result = df_in.pivot_wider(
+        index=["a", "b"], names_from="name", values_from_first=False
+    )
+
+    result = result.pivot_longer(
+        index=["a", "b"], names_to=("name", ".value"), names_sep="_"
+    )
+    assert_frame_equal(result, df_in)
+
+
+def pivot_wide_long_wide():
+    """
+    Test that transformation from pivot_longer to wider and
+    back to longer returns the same source dataframe.
+    """
+    df = pd.DataFrame(
+        {
+            "name": ["Wilbur", "Petunia", "Gregory"],
+            "a": [67, 80, 64],
+            "b": [56, 90, 50],
+        }
+    )
+
+    result = df.pivot_longer(
+        column_names=["a", "b"], names_to="drug", values_to="heartrate"
+    )
+
+    result = result.pivot_wider(
+        index="name", names_from="drug", values_from="heartrate"
+    )
+
+    assert_frame_equal(result, df)
+
+
+@pytest.mark.parametrize(
+    """
+    df_in,df_out,index,names_from,
+    values_from, names_prefix,values_from_first
+    """,
+    combinations,
+)
+def test_pivot_wider_various(
+    df_in,
+    df_out,
+    index,
+    names_from,
+    values_from,
+    names_prefix,
+    values_from_first,
+):
+    """
+    Test `pivot_wider` function with various combinations.
+    """
+    result = df_in.pivot_wider(
+        index=index,
+        names_from=names_from,
+        values_from=values_from,
+        names_prefix=names_prefix,
+        values_from_first=values_from_first,
+    )
+    assert_frame_equal(result, df_out)
+
+
+def test_flatten_levels_false():
+    "Test output if `flatten_levels` is False."
+
+    df_collapse = pd.DataFrame(
+        {
+            "foo": ["one", "one", "one", "two", "two", "two"],
+            "bar": ["A", "B", "C", "A", "B", "C"],
+            "baz": [1, 2, 3, 4, 5, 6],
+            "zoo": ["x", "y", "z", "q", "w", "t"],
+        }
+    )
+
+    result = df_collapse.pivot_wider(
+        index="foo",
+        names_from="bar",
+        values_from=["baz", "zoo"],
+        flatten_levels=False,
+        names_sort=True,
+    )
+
+    expected_output = df_collapse.pivot(  # noqa: PD010
+        index="foo", columns="bar", values=["baz", "zoo"]
+    )
+
+    assert_frame_equal(
+        result, expected_output, check_dtype=False,
+    )
+
+
+def test_fill_values():
+    "Test output if `fill_value` is provided."
+
+    df_fill_value = pd.DataFrame(
+        {
+            "lev1": [1, 1, 1, 2, 2, 2],
+            "lev2": [1, 1, 2, 1, 1, 2],
+            "lev3": [1, 2, 1, 2, 1, 2],
+            "lev4": [1, 2, 3, 4, 5, 6],
+            "values": [0, 1, 2, 3, 4, 5],
+        }
+    )
+
+    result = df_fill_value.pivot_wider(
+        index=["lev1", "lev2"],
+        names_from=["lev3"],
+        values_from="values",
+        flatten_levels=False,
+        fill_value=0,
+        names_sort=True,
+    )
+
+    expected_output = pd.DataFrame(
+        {
+            1: {(1, 1): 0, (1, 2): 2, (2, 1): 4, (2, 2): 0},
+            2: {(1, 1): 1, (1, 2): 0, (2, 1): 3, (2, 2): 5},
+        },
+        index=pd.MultiIndex.from_tuples(
+            [(1, 1), (1, 2), (2, 1), (2, 2)], names=["lev1", "lev2"]
+        ),
+        columns=pd.Int64Index([1, 2], dtype="int64", name="lev3"),
+    )
+    assert_frame_equal(result, expected_output)
+
+
+def test_no_index():
+    "Test output if no `index` is supplied."
+    df_in = pd.DataFrame(
+        {
+            "gender": ["Male", "Female", "Female", "Male", "Male"],
+            "contVar": [22379, 24523, 23421, 23831, 29234],
+        },
+        index=pd.Int64Index([0, 0, 1, 1, 2], dtype="int64"),
+    )
+
+    expected_output = pd.DataFrame(
+        {
+            "contVar_Male": [22379.0, 23831.0, 29234.0],
+            "contVar_Female": [24523.0, 23421.0, np.nan],
+        }
+    )
+
+    result = df_in.pivot_wider(names_from="gender")
+
+    assert_frame_equal(result, expected_output)
+
+
+def test_no_index_names_sort_True():
+    "Test output if no `index` is supplied and `names_sort` is True."
+    df_in = pd.DataFrame(
+        {
+            "gender": ["Male", "Female", "Female", "Male", "Male"],
+            "contVar": [22379, 24523, 23421, 23831, 29234],
+        },
+        index=pd.Int64Index([0, 0, 1, 1, 2], dtype="int64"),
+    )
+
+    expected_output = pd.DataFrame(
+        {
+            "contVar_Female": [24523.0, 23421.0, np.nan],
+            "contVar_Male": [22379.0, 23831.0, 29234.0],
+        }
+    )
+
+    result = df_in.pivot_wider(names_from="gender", names_sort=True)
+
+    assert_frame_equal(result, expected_output)
+
+
+def test_index_names_sort_True():
+    "Test output if index is supplied and `names_sort ` is True."
+    df = pd.DataFrame(
+        [
+            {"stat": "mean", "score": 4, "var": "var1"},
+            {"stat": "sd", "score": 7, "var": "var1"},
+            {"stat": "mean", "score": 1, "var": "var2"},
+            {"stat": "sd", "score": 2, "var": "var2"},
+            {"stat": "mean", "score": 11, "var": "var3"},
+            {"stat": "sd", "score": 14, "var": "var3"},
+        ]
+    )
+
+    expected_output = pd.DataFrame(
+        {"var": ["var1", "var2", "var3"], "mean": [4, 1, 11], "sd": [7, 2, 14]}
+    )
+
+    result = df.pivot_wider(
+        index="var", names_from="stat", values_from="score", names_sort=True
+    )
+
+    assert_frame_equal(result, expected_output)