Repository: ericmjl/pyjanitor (python)

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!

Pull request:
[ENH] Pivot wider method
Please describe the changes proposed in the pull request:

- Add pivot_wider function
- Convenience arguments to collapse levels
- Allows for easy transformation back to long form and matches original form for a number of cases
- Option to not collapse levels and retain MultiIndex form.

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #434.**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.rst`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.rst` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

## Quick Check

To do a very quick check that everything is correct, follow these steps below:

- [x] Run the command `make check` from pyjanitor's top-level directory. This will automatically run:
    - black formatting
    - flake8 checking
    - running the test suite
    - docs build

Once done, please check off the check-box above.

If `make check` does not work for you, you can execute the commands listed in the Makefile individually.

## Code Changes

<!-- If you have not made code changes, please feel free to delete this section. -->

If you are adding code changes, please ensure the following:

- [x] Ensure that you have added tests.
- [x] Run all tests (`$ pytest .`) locally on your machine.
    - [x] Check to ensure that test coverage covers the lines of code that you have added.
    - [x] Ensure that all tests pass.

## Documentation Changes

<!-- If you have not made documentation changes, please feel free to delete this section. -->

If you are adding documentation changes, please ensure the following:

- [ ] Build the docs locally.
- [ ] View the docs to check that it renders correctly.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 3f808c8..6c3bdbd 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,8 @@ new version (on deck)
   allowing the user to control the data types. - @samukweku
 - [INF] Loosen dependency specifications. Switch to pip-tools for managing
   dependencies. Issue #760. @MinchinWeb
+- [ENH] Add ``pivot_wider`` function, which is the inverse of the `pivot_longer`
+  function. @samukweku
 
 v0.20.10
 =======
diff --git a/docs/reference/general_functions.rst b/docs/reference/general_functions.rst
index 53ddf79..9d9b46b 100644
--- a/docs/reference/general_functions.rst
+++ b/docs/reference/general_functions.rst
@@ -92,3 +92,4 @@ Other
     groupby_topk
     complete
     pivot_longer
+    pivot_wider
\ No newline at end of file
diff --git a/janitor/functions.py b/janitor/functions.py
index 0d8a4be..883546b 100644
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -37,8 +37,10 @@ from .utils import (
     _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
index b2b6a05..a80e994 100644
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -9,6 +9,7 @@ from typing import Callable, Dict, List, Optional, Pattern, Tuple, Union
 
 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
index 0000000..388d93c
--- /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)