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/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,7 +11,6 @@
 # -- Path setup --------------------------------------------------------------
 
 import datetime
-
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -16,6 +16,7 @@
     Iterable,
     List,
     Optional,
+    Pattern,
     Set,
     Tuple,
     Union,
@@ -35,8 +36,11 @@
     _check_instance,
     _clean_accounting_column,
     _complete_groupings,
+    _computations_pivot_longer,
     _currency_column_to_numeric,
+    _data_checks_pivot_longer,
     _grid_computation,
+    _pivot_longer_pattern_match,
     _replace_empty_string_with_none,
     _replace_original_empty_string_with_none,
     _strip_underscores,
@@ -4590,3 +4594,287 @@ def complete(
         df = df.fillna(fill_value)
 
     return df
+
+
+def patterns(regex_pattern: Union[str, Pattern]) -> Pattern:
+    """
+    This function converts a string into a compiled regular expression;
+    it can be used to select columns in the index or columns_names
+    arguments of ``pivot_longer`` function.
+
+    A regular expression is returned.
+    """
+
+    check("regular expression", regex_pattern, [str, Pattern])
+
+    return re.compile(regex_pattern)
+
+
+@pf.register_dataframe_method
+def pivot_longer(
+    df: pd.DataFrame,
+    index: Optional[Union[List, Tuple, str, Pattern]] = None,
+    column_names: Optional[Union[List, Tuple, str, Pattern]] = None,
+    names_sep: Optional[Union[str, Pattern]] = None,
+    names_pattern: Optional[Union[str, Pattern]] = None,
+    names_to: Optional[Union[List, Tuple, str]] = None,
+    values_to: Optional[str] = "value",
+) -> pd.DataFrame:
+    """
+    Unpivots a DataFrame from 'wide' to 'long' format.
+
+    This method does not mutate the original DataFrame.
+
+    It is a wrapper around `pd.melt` and is meant to serve as a single point
+    for transformations that require `pd.melt` or `pd.wide_to_long`. It is
+    modeled after the `pivot_longer` function in R's tidyr package.
+
+    This function is useful to massage a DataFrame into a format where
+    one or more columns are considered measured variables, and all other
+    columns are considered as identifier variables.
+
+    All measured variables are “unpivoted” (and typically duplicated) along the
+    row axis.
+
+    Example 1: The following DataFrame contains heartrate data for patients
+    treated with two different drugs, 'a' and 'b'.
+
+    .. code-block:: python
+
+              name   a   b
+        0   Wilbur  67  56
+        1  Petunia  80  90
+        2  Gregory  64  50
+
+    The column names 'a' and 'b' are actually the names of a measured variable
+    (i.e. the name of a drug), but the values are a different measured variable
+    (heartrate). We would like to unpivot these 'a' and 'b' columns into a
+    'drug' column and a 'heartrate' column.
+
+    .. code-block:: python
+
+        df = pd.DataFrame(...).pivot_longer(column_names=['a', 'b'],
+                                            names_to='drug',
+                                            values_to='heartrate')
+
+              name drug  heartrate
+        0   Wilbur    a         67
+        1   Wilbur    b         56
+        2  Petunia    a         80
+        3  Petunia    b         90
+        4  Gregory    a         64
+        5  Gregory    b         50
+
+    Example 2: The dataframe below has year and month variables embedded within
+    the column names.
+
+    .. code-block:: python
+
+            col1	2019-12	     2020-01	 2020-02
+        0	a	   -1.085631	-1.506295	-2.426679
+        1	b	    0.997345	-0.578600	-0.428913
+        2	c	    0.282978	 1.651437	 1.265936
+
+    Pivot_longer can conveniently reshape the data into long format, with new
+    columns for the year and month. We simply pass in the new column names to
+    `names_to`, and pass the hyphen '-' to the `names_sep` argument. Note how
+    this effectively replicates the pandas' `wide_to_long` function.
+
+    .. code-block:: python
+
+        df = (pd.DataFrame(...)
+             .pivot_longer(index='col1',
+                           names_to=('year','month'),
+                           names_sep='-')
+              )
+
+          col1  year   month      value
+        0    a  2019     12     -1.085631
+        1    a  2020      1     -1.506295
+        2    a  2020      2     -2.426679
+        3    b  2019     12      0.997345
+        4    b  2020      1     -0.578600
+        5    b  2020      2     -0.428913
+        6    c  2019     12      0.282978
+        7    c  2020      1      1.651437
+        8    c  2020      2      1.265936
+
+    Example 3: The dataframe below has names embedded in it
+    ('measure1', 'measure2') that we would love to reuse as
+    column names.
+
+    .. code-block:: python
+
+            treat1-measure1	treat1-measure2	treat2-measure1	treat2-measure2
+        0	         1	        4	            2	            5
+        1	         2	        5	            3	            4
+
+    For this, we take advantage of the `.value` variable, which signals to
+    `pivot_longer` to treat the part of the column names corresponding to
+    `.value` as new column names.
+
+    .. code-block:: python
+
+        df = (pd.DataFrame(...)
+              .pivot_longer(names_to=("group",'.value'),
+                            names_sep = '-')
+              )
+
+            group  measure1  measure2
+        0  treat1         1         4
+        1  treat2         2         5
+        2  treat1         2         5
+        3  treat2         3         4
+
+    Example 4: We can also pivot from wide to long using regular expressions
+
+    .. code-block:: python
+
+            n_1  n_2  n_3  pct_1  pct_2  pct_3
+        0   10   20   30   0.1    0.2    0.3
+
+        df = (pd.DataFrame(...)
+              .pivot_longer(names_to = (".value", "name"),
+                            names_pattern = "(.*)_(.)")
+              )
+
+            name    n  pct
+        0     1  10.0  0.1
+        1     2  20.0  0.2
+        2     3  30.0  0.3
+
+    You can also take advantage of `janitor.patterns` function, which allows
+    selection of columns via a regular expression; this can come in handy if
+    you have a lot of column names to use as index, and do not wish to manually
+    type them all.
+
+    .. code-block:: python
+
+             name    wk1   wk2   wk3   wk4
+        0    Alice     5     9    20    22
+        1    Bob       7    11    17    33
+        2    Carla     6    13    39    40
+
+        df = pd.DataFrame(...).pivot_longer(janitor.patterns("^(?!wk)"))
+
+             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
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        df = jn.pivot_longer(
+            df = df,
+            index = [column1, column2, ...],
+            column_names = [column3, column4, ...],
+            names_to = new_column_name,
+            names_sep = string/regular expression,
+            names_pattern = string/regular expression,
+            value_name = new_column_name
+        )
+
+    Method chaining syntax:
+
+    .. code-block:: python
+
+        df = (
+            pd.DataFrame(...)
+            .pivot_longer(
+                df,
+                index = [column1, column2, ...],
+                column_names = [column3, column4, ...],
+                names_to = new_column_name,
+                names_sep = string/regular expression,
+                names_pattern = string/regular expression,
+                value_name= new_column_name
+            )
+        )
+
+    :param df: A pandas dataframe.
+    :param index: Name(s) of columns to use as identifier variables.
+        Should be either a single column name, a list/tuple of
+        column names, or a or a `janitor.patterns` function. You can
+        also dynamically select column names by using a regular
+        expression with the `janitor.patterns` function.
+    :param column_names: Name(s) of columns to unpivot. Should be either
+        a single column name, a list/tuple of column names, or a
+        `janitor.patterns` function. You can also dynamically select
+        column names by using a regular expression with the
+        `janitor.patterns` function.
+    :param names_to: Name of new column as a string that will contain
+        what were previously the column names in `column_names`.
+        The default is `variable` if no value is provided. It can
+        also be a list/tuple of strings that will serve as new column
+        names, if `name_sep` or `names_pattern` is provided.
+        If `.value` is in `names_to`, new column names will be extracted
+        from part of the existing column names and `values_to` will be
+        replaced.
+    :param names_sep: Determines how the column name is broken up, if
+        `names_to` contains multiple values. It takes the same
+        specification as pandas' `str.split` method, and can be a string
+        or regular expression.
+    :param names_pattern: Determines how the column name is broken up. It takes
+        the same specification as pandas' `str.extractall` method, which is
+        a regular expression containing matching groups.
+    :param values_to: Name of new column as a string that will contain what
+        were previously the values of the columns in `column_names`.
+    :returns: A pandas DataFrame that has been unpivoted from wide to long
+        format.
+    :raises: TypeError if `index` or `column_names` is not a string, or a
+        list/tuple of strings, or a `janitor.patterns` function.
+    :raises: TypeError if `names_to` or `column_names` is not a string, or a
+        list/tuple of strings.
+    :raises: TypeError if `values_to` is not a string.
+    :raises: ValueError if `names_to` is a list/tuple, and both `names_sep` and
+        `names_pattern` are provided.
+    :raises: ValueError if `names_to` is a string or a list/tuple of length 1,
+        and `names_sep` is provided.
+    :raises: TypeError if `names_sep` or `names_pattern` is not a string or
+        regular expression.
+    :raises: ValueError if `names_to` is a list/tuple, and its length does not
+        match the number of extracted columns.
+    :raises: Warning if `df` is a MultiIndex dataframe.
+    """
+
+    # this code builds on the wonderful work of @benjaminjack’s PR
+    # https://github.com/benjaminjack/pyjanitor/commit/e3df817903c20dd21634461c8a92aec137963ed0
+
+    df = df.copy()
+
+    (
+        df,
+        index,
+        column_names,
+        names_sep,
+        names_pattern,
+        names_to,
+        values_to,
+    ) = _data_checks_pivot_longer(
+        df, index, column_names, names_sep, names_pattern, names_to, values_to
+    )
+
+    df, index, column_names = _pivot_longer_pattern_match(
+        df, index, column_names
+    )
+
+    df = _computations_pivot_longer(
+        df, index, column_names, names_sep, names_pattern, names_to, values_to
+    )
+
+    return df
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -5,10 +5,11 @@
 import sys
 import warnings
 from itertools import chain, product
-from typing import Callable, Dict, List, Union
+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
 
@@ -600,3 +601,383 @@ def _complete_groupings(df, list_of_columns):
     df = df.set_index(index_columns)
 
     return df, new_reindex_columns
+
+
+def _data_checks_pivot_longer(
+    df, index, column_names, names_sep, names_pattern, names_to, values_to
+):
+
+    """
+    This function raises errors or warnings if the arguments have the wrong
+    python type, or if an unneeded argument is provided.
+    This function is executed before proceeding to the computation phase.
+
+    Type annotations are not provided because this function is where type
+    checking happens.
+    """
+
+    if any(
+        (
+            isinstance(df.index, pd.MultiIndex),
+            isinstance(df.columns, pd.MultiIndex),
+        ),
+    ):
+        warnings.warn(
+            """pivot_longer is designed for single index dataframes and
+               may produce unexpected results for multiIndex dataframes;
+               for such cases, kindly use pandas.melt."""
+        )
+
+    if index is not None:
+        if isinstance(index, str):
+            index = [index]
+        check("index", index, [list, tuple, Pattern])
+
+    if column_names is not None:
+        if isinstance(column_names, str):
+            column_names = [column_names]
+        check("column_names", column_names, [list, tuple, Pattern])
+
+    if names_to is not None:
+        check("names_to", names_to, [list, tuple, str])
+
+        if isinstance(names_to, (list, tuple)):
+            if not all(isinstance(word, str) for word in names_to):
+                raise TypeError(
+                    "All entries in `names_to` argument must be strings."
+                )
+
+            if len(names_to) > 1:
+                if all((names_pattern is not None, names_sep is not None)):
+                    raise ValueError(
+                        """Only one of names_pattern or names_sep
+                       should be provided."""
+                    )
+        if isinstance(names_to, str) or (len(names_to) == 1):
+            # names_sep creates more than one column
+            # whereas regex with names_pattern can be limited to one column
+            if names_sep is not None:
+                raise ValueError(
+                    """
+                    For a single names_to value,
+                    names_sep is not required.
+                    """
+                )
+    if names_pattern is not None:
+        check("names_pattern", names_pattern, [str, Pattern])
+
+    if names_sep is not None:
+        check("names_sep", names_sep, [str, Pattern])
+
+    check("values_to", values_to, [str])
+
+    return (
+        df,
+        index,
+        column_names,
+        names_sep,
+        names_pattern,
+        names_to,
+        values_to,
+    )
+
+
+def _pivot_longer_pattern_match(
+    df: pd.DataFrame,
+    index: Optional[Union[str, Pattern]] = None,
+    column_names: Optional[Union[str, Pattern]] = None,
+) -> Tuple:
+    """
+    This checks if a pattern (regular expression) is supplied
+    to index or columns and extracts the names that match the
+    given regular expression.
+    """
+
+    if isinstance(column_names, Pattern):
+        column_names = [col for col in df if column_names.search(col)]
+
+    if isinstance(index, Pattern):
+        index = [col for col in df if index.search(col)]
+
+    return df, index, column_names
+
+
+def _reindex_func(frame: pd.DataFrame, indexer=None) -> pd.DataFrame:
+    """
+    Function to reshape dataframe in pivot_longer, to try and make it look
+    similar to the source data in terms of direction of the columns. It is a
+    temporary measure until the minimum pandas version is 1.1, where we can
+    take advantage of the `ignore_index` argument in `pd.melt`.
+
+    Example: if columns are `id, ht1, ht2, ht3`, then depending on the
+    arguments passed, the column in the reshaped dataframe, based on this
+    function, will look like `1,2,3,1,2,3,1,2,3...`. This way, we ensure that
+    for every index, there is a complete set of the data.
+
+    A reindexed dataframe is returned.
+    """
+
+    if indexer is None:
+        uniq_index_length = len(frame.drop_duplicates())
+    else:
+        uniq_index_length = len(frame.loc[:, indexer].drop_duplicates())
+        if "index" in indexer:
+            frame = frame.drop("index", axis=1)
+    sorter = np.reshape(frame.index, (-1, uniq_index_length))
+    # reshaped in Fortan order achieves the alternation
+    sorter = np.ravel(sorter, order="F")
+    return frame.reindex(sorter)
+
+
+def _computations_pivot_longer(
+    df: pd.DataFrame,
+    index: Optional[Union[List, Tuple]] = None,
+    column_names: Optional[Union[List, Tuple]] = None,
+    names_sep: Optional[Union[str, Pattern]] = None,
+    names_pattern: Optional[Union[str, Pattern]] = None,
+    names_to: Optional[Union[List, Tuple, str]] = None,
+    values_to: Optional[str] = "value",
+) -> pd.DataFrame:
+    """
+    This is the main workhorse of the `pivot_longer` function.
+
+    There are a couple of scenarios that this function takes care of when
+    unpivoting :
+
+    1. Regular data unpivoting is covered with pandas melt.
+    2. if the length of `names_to` is > 1, the function unpivots the data,
+       using `pd.melt`, and then separates into individual columns, using
+       `str.split(expand=True)` if `names_sep` is provided or
+       `str.extractall()` if `names_pattern is provided. The labels in
+       `names_to` become the new column names.
+    3. If `names_to` contains `.value`, then the function replicates
+       `pd.wide_to_long`, using `pd.melt`. Unlike `pd.wide_to_long`, the
+       stubnames do not have to be prefixes, they just need to match the
+       position of `.value` in `names_to`. Just like in 2 above, the columns
+       are separated into individual columns. The labels in the column
+       corresponding to `.value` become the new column names, and override
+       `values_to` in the process. The other extracted column stays
+       (if len(`names_to`) is > 1), with the other name in `names_to` as
+       its column name.
+
+    The function also tries to emulate the way the source data is structured.
+    Say data looks like this :
+        id, a1, a2, a3, A1, A2, A3
+         1, a, b, c, A, B, C
+
+    when pivoted into long form, it will look like this :
+              id instance    a     A
+        0     1     1        a     A
+        1     1     2        b     B
+        2     1     3        c     C
+
+    where the columns `a` comes before `A`, as it was in the source data,
+    and in column `a`, `a > b > c`, also as it was in the source data.
+    This also visually creates a complete set of the data per index.
+    """
+
+    if index is not None:
+        check_column(df, index, present=True)
+        # this should take care of non unique index
+        # we'll get rid of the extra in _reindex_func
+        # TODO: what happens if `index` is already a name
+        # in the columns?
+        if df.loc[:, index].duplicated().any():
+            df = df.reset_index()
+            index = ["index"] + index
+
+    if column_names is not None:
+        check_column(df, column_names, present=True)
+
+    if index is None and (column_names is not None):
+        index = df.columns.difference(column_names)
+
+    # scenario 1
+    if all((names_pattern is None, names_sep is None)):
+        df = pd.melt(
+            df,
+            id_vars=index,
+            value_vars=column_names,
+            var_name=names_to,
+            value_name=values_to,
+        )
+
+        # reshape in the order that the data appears
+        # this should be easier to do with ignore_index in pandas version 1.1
+        if index is not None:
+            df = _reindex_func(df, index).reset_index(drop=True)
+            return df.transform(pd.to_numeric, errors="ignore")
+        return df
+
+    # scenario 2
+    if any((names_pattern is not None, names_sep is not None)):
+        # should avoid conflict if index/columns has a string named `variable`
+        uniq_name = "*^#variable!@?$%"
+        df = pd.melt(
+            df, id_vars=index, value_vars=column_names, var_name=uniq_name
+        )
+
+        # pd.melt returns uniq_name and value as the last columns. We can use
+        # that knowledge to get the data before( the index column(s)),
+        # the data between (our uniq_name column),
+        #  and the data after (our values column)
+        position = df.columns.get_loc(uniq_name)
+        if position == 0:
+            before_df = pd.DataFrame([], index=df.index)
+        else:
+            # just before uniq_name column
+            before_df = df.iloc[:, :-2]
+        after_df = df.iloc[:, -1].rename(values_to)
+        between_df = df.pop(uniq_name)
+        if names_sep is not None:
+            between_df = between_df.str.split(names_sep, expand=True)
+        else:
+            between_df = between_df.str.extractall(names_pattern).droplevel(-1)
+        # set_axis function labels argument takes only list-like objects
+        if isinstance(names_to, str):
+            names_to = [names_to]
+
+        if len(names_to) != between_df.shape[-1]:
+            raise ValueError(
+                """
+                Length of ``names_to`` does not match
+                number of columns extracted.
+                """
+            )
+        before_df = _reindex_func(before_df, index)
+        between_df = between_df.set_axis(names_to, axis="columns")
+
+        # we take a detour here to deal with paired columns, where the user
+        # might want one of the names in the paired column as part of the
+        # new column names. The `.value` indicates that that particular
+        # value becomes a header.
+
+        # It is also another way of achieving pandas wide_to_long.
+
+        # Let's see an example of a paired column
+        # say we have this data :
+        # code is copied from pandas wide_to_long documentation
+        # https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.wide_to_long.html
+        #     famid  birth  ht1  ht2
+        # 0      1      1  2.8  3.4
+        # 1      1      2  2.9  3.8
+        # 2      1      3  2.2  2.9
+        # 3      2      1  2.0  3.2
+        # 4      2      2  1.8  2.8
+        # 5      2      3  1.9  2.4
+        # 6      3      1  2.2  3.3
+        # 7      3      2  2.3  3.4
+        # 8      3      3  2.1  2.9
+
+        # and we want to reshape into data that looks like this :
+        #      famid  birth age   ht
+        # 0       1      1   1  2.8
+        # 1       1      1   2  3.4
+        # 2       1      2   1  2.9
+        # 3       1      2   2  3.8
+        # 4       1      3   1  2.2
+        # 5       1      3   2  2.9
+        # 6       2      1   1  2.0
+        # 7       2      1   2  3.2
+        # 8       2      2   1  1.8
+        # 9       2      2   2  2.8
+        # 10      2      3   1  1.9
+        # 11      2      3   2  2.4
+        # 12      3      1   1  2.2
+        # 13      3      1   2  3.3
+        # 14      3      2   1  2.3
+        # 15      3      2   2  3.4
+        # 16      3      3   1  2.1
+        # 17      3      3   2  2.9
+
+        # we have height(`ht`) and age(`1,2`) paired in the column name.
+        # Note how `1, 2` is repeated for the extracted age column for each
+        # combination of `famid` and `birth`. The repeat of `1,2` also
+        # simulates how it looks in the source data : `ht1 > ht2`.
+        # As such, for every index, there is a complete set of the data;
+        # the user can visually see the unpivoted data for each index
+        # and be assured of complete/accurate sync.
+        # The code below achieves that.
+
+        # scenario 3
+        if ".value" in names_to:
+            if names_to.count(".value") > 1:
+                raise ValueError(
+                    "Column name `.value` must not be duplicated."
+                )
+            # extract new column names and assign category dtype
+            after_df_cols = pd.unique(between_df.loc[:, ".value"])
+            dot_value_dtype = CategoricalDtype(after_df_cols, ordered=True)
+            between_df = between_df.astype({".value": dot_value_dtype})
+            if len(names_to) > 1:
+                other_header = between_df.columns.difference([".value"])[0]
+                other_header_values = pd.unique(
+                    between_df.loc[:, other_header]
+                )
+                other_header_dtype = CategoricalDtype(
+                    other_header_values, ordered=True
+                )
+                between_df = between_df.astype(
+                    {other_header: other_header_dtype}
+                )
+                between_df = between_df.sort_values([".value", other_header])
+            else:
+                other_header = None
+                other_header_values = None
+                # index order not assured if just .value and quicksort
+                between_df = between_df.sort_values(
+                    [".value"], kind="mergesort"
+                )
+
+            # reshape index_sorter and use the first column as the index
+            # of the reshaped after_df. after_df will be reshaped into
+            # specific number of columns, based on the length of
+            # `after_df_cols`
+            index_sorter = between_df.index
+            after_df = after_df.reindex(index_sorter).to_numpy()
+            after_index = np.reshape(
+                index_sorter, (-1, len(after_df_cols)), order="F"
+            )
+            after_index = after_index[:, 0]
+            after_df = np.reshape(
+                after_df, (-1, len(after_df_cols)), order="F"
+            )
+            after_df = pd.DataFrame(
+                after_df, columns=after_df_cols, index=after_index
+            )
+            # if `names_to` has a length more than 1,
+            # then we need to sort the other header, so that there is
+            # an alternation, ensuring a complete representation of
+            # each value per index.
+            # if, however, `names_to` is of length 1, then between_df
+            # will be an empty dataframe, and its index will be the
+            # same as the index of `after_df`
+            # once the indexes are assigned to before, after, and between
+            # we can recombine with a join to get the proper alternation
+            # and complete data per index/section
+            if other_header:
+                other_header_index = np.reshape(
+                    after_index, (-1, len(other_header_values)), order="F"
+                )
+                other_header_index = np.ravel(other_header_index)
+                between_df = between_df.loc[other_header_index, [other_header]]
+            else:
+                other_header_index = None
+                between_df = pd.DataFrame([], index=after_index)
+            if position == 0:  # no index or column_names supplied
+                df = pd.DataFrame.join(between_df, after_df, how="inner")
+            else:
+                df = pd.DataFrame.join(
+                    before_df, [between_df, after_df], how="inner"
+                )
+            return df.reset_index(drop=True).transform(
+                pd.to_numeric, errors="ignore"
+            )
+
+        # this kicks in if there is no `.value` in `names_to`
+        # here we reindex the before_df, to simulate the order of the columns
+        # in the source data.
+        df = pd.DataFrame.join(
+            before_df, [between_df, after_df], how="inner"
+        ).reset_index(drop=True)
+        return df.transform(pd.to_numeric, errors="ignore")


Test patch:
diff --git a/tests/functions/test_pivot_longer.py b/tests/functions/test_pivot_longer.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_pivot_longer.py
@@ -0,0 +1,1372 @@
+from itertools import product
+
+import numpy as np
+import pandas as pd
+import pytest
+from pandas.testing import assert_frame_equal
+
+from janitor import patterns
+
+df_checks = pd.DataFrame(
+    [
+        {"region": "Pacific", "2007": 1039, "2009": 2587},
+        {"region": "Southwest", "2007": 51, "2009": 176},
+        {"region": "Rocky Mountains and Plains", "2007": 200, "2009": 338},
+    ]
+)
+
+
+@pytest.fixture
+def df_checks_output():
+    return pd.DataFrame(
+        {
+            "region": [
+                "Pacific",
+                "Pacific",
+                "Southwest",
+                "Southwest",
+                "Rocky Mountains and Plains",
+                "Rocky Mountains and Plains",
+            ],
+            "year": [2007, 2009, 2007, 2009, 2007, 2009],
+            "num_nests": [1039, 2587, 51, 176, 200, 338],
+        }
+    )
+
+
+paired_columns_pattern = [
+    (
+        pd.DataFrame(
+            {
+                "id": [1, 2, 3],
+                "M_start_date_1": [201709, 201709, 201709],
+                "M_end_date_1": [201905, 201905, 201905],
+                "M_start_date_2": [202004, 202004, 202004],
+                "M_end_date_2": [202005, 202005, 202005],
+                "F_start_date_1": [201803, 201803, 201803],
+                "F_end_date_1": [201904, 201904, 201904],
+                "F_start_date_2": [201912, 201912, 201912],
+                "F_end_date_2": [202007, 202007, 202007],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {"id": 1, "cod": "M", "start": 201709, "end": 201905},
+                {"id": 1, "cod": "M", "start": 202004, "end": 202005},
+                {"id": 1, "cod": "F", "start": 201803, "end": 201904},
+                {"id": 1, "cod": "F", "start": 201912, "end": 202007},
+                {"id": 2, "cod": "M", "start": 201709, "end": 201905},
+                {"id": 2, "cod": "M", "start": 202004, "end": 202005},
+                {"id": 2, "cod": "F", "start": 201803, "end": 201904},
+                {"id": 2, "cod": "F", "start": 201912, "end": 202007},
+                {"id": 3, "cod": "M", "start": 201709, "end": 201905},
+                {"id": 3, "cod": "M", "start": 202004, "end": 202005},
+                {"id": 3, "cod": "F", "start": 201803, "end": 201904},
+                {"id": 3, "cod": "F", "start": 201912, "end": 202007},
+            ]
+        ),
+        "id",
+        ("cod", ".value"),
+        "(M|F)_(start|end)_.+",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "person_id": [1, 2, 3],
+                "date1": ["12/31/2007", "11/25/2009", "10/06/2005"],
+                "val1": [2, 4, 6],
+                "date2": ["12/31/2017", "11/25/2019", "10/06/2015"],
+                "val2": [1, 3, 5],
+                "date3": ["12/31/2027", "11/25/2029", "10/06/2025"],
+                "val3": [7, 9, 11],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {"person_id": 1, "value": 1, "date": "12/31/2007", "val": 2},
+                {"person_id": 1, "value": 2, "date": "12/31/2017", "val": 1},
+                {"person_id": 1, "value": 3, "date": "12/31/2027", "val": 7},
+                {"person_id": 2, "value": 1, "date": "11/25/2009", "val": 4},
+                {"person_id": 2, "value": 2, "date": "11/25/2019", "val": 3},
+                {"person_id": 2, "value": 3, "date": "11/25/2029", "val": 9},
+                {"person_id": 3, "value": 1, "date": "10/06/2005", "val": 6},
+                {"person_id": 3, "value": 2, "date": "10/06/2015", "val": 5},
+                {"person_id": 3, "value": 3, "date": "10/06/2025", "val": 11},
+            ]
+        ),
+        patterns("^(?!(date|val))"),
+        (".value", "value"),
+        r"([a-z]+)(\d)",
+    ),
+    (
+        pd.DataFrame(
+            [
+                {
+                    "id": 1,
+                    "a1": "a",
+                    "a2": "b",
+                    "a3": "c",
+                    "A1": "A",
+                    "A2": "B",
+                    "A3": "C",
+                }
+            ]
+        ),
+        pd.DataFrame(
+            {
+                "id": [1, 1, 1],
+                "instance": [1, 2, 3],
+                "a": ["a", "b", "c"],
+                "A": ["A", "B", "C"],
+            }
+        ),
+        "id",
+        (".value", "instance"),
+        r"(\w)(\d)",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "A1970": ["a", "b", "c"],
+                "A1980": ["d", "e", "f"],
+                "B1970": [2.5, 1.2, 0.7],
+                "B1980": [3.2, 1.3, 0.1],
+                "X": [-1.085631, 0.997345, 0.282978],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "X": [
+                    -1.085631,
+                    -1.085631,
+                    0.997345,
+                    0.997345,
+                    0.282978,
+                    0.282978,
+                ],
+                "year": [1970, 1980, 1970, 1980, 1970, 1980],
+                "A": ["a", "d", "b", "e", "c", "f"],
+                "B": [2.5, 3.2, 1.2, 1.3, 0.7, 0.1],
+            }
+        ),
+        "X",
+        (".value", "year"),
+        "([A-Z])(.+)",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "id": ["A", "B", "C", "D", "E", "F"],
+                "f_start": ["p", "i", "i", "p", "p", "i"],
+                "d_start": [
+                    "2018-01-01",
+                    "2019-04-01",
+                    "2018-06-01",
+                    "2019-12-01",
+                    "2019-02-01",
+                    "2018-04-01",
+                ],
+                "f_end": ["p", "p", "i", "p", "p", "i"],
+                "d_end": [
+                    "2018-02-01",
+                    "2020-01-01",
+                    "2019-03-01",
+                    "2020-05-01",
+                    "2019-05-01",
+                    "2018-07-01",
+                ],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {"id": "A", "status": "start", "f": "p", "d": "2018-01-01"},
+                {"id": "A", "status": "end", "f": "p", "d": "2018-02-01"},
+                {"id": "B", "status": "start", "f": "i", "d": "2019-04-01"},
+                {"id": "B", "status": "end", "f": "p", "d": "2020-01-01"},
+                {"id": "C", "status": "start", "f": "i", "d": "2018-06-01"},
+                {"id": "C", "status": "end", "f": "i", "d": "2019-03-01"},
+                {"id": "D", "status": "start", "f": "p", "d": "2019-12-01"},
+                {"id": "D", "status": "end", "f": "p", "d": "2020-05-01"},
+                {"id": "E", "status": "start", "f": "p", "d": "2019-02-01"},
+                {"id": "E", "status": "end", "f": "p", "d": "2019-05-01"},
+                {"id": "F", "status": "start", "f": "i", "d": "2018-04-01"},
+                {"id": "F", "status": "end", "f": "i", "d": "2018-07-01"},
+            ]
+        ),
+        "id",
+        (".value", "status"),
+        "(.*)_(.*)",
+    ),
+]
+
+paired_columns_sep = [
+    # https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.wide_to_long.html
+    (
+        pd.DataFrame(
+            {
+                "A(weekly)-2010": [0.548814, 0.7151890000000001, 0.602763],
+                "A(weekly)-2011": [0.544883, 0.423655, 0.645894],
+                "B(weekly)-2010": [
+                    0.437587,
+                    0.8917729999999999,
+                    0.9636629999999999,
+                ],
+                "B(weekly)-2011": [0.383442, 0.791725, 0.528895],
+                "X": [0, 1, 1],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "X": [0, 0, 1, 1, 1, 1],
+                "year": [2010, 2011, 2010, 2011, 2010, 2011],
+                "A(weekly)": [
+                    0.548814,
+                    0.544883,
+                    0.7151890000000001,
+                    0.423655,
+                    0.602763,
+                    0.645894,
+                ],
+                "B(weekly)": [
+                    0.437587,
+                    0.383442,
+                    0.8917729999999999,
+                    0.791725,
+                    0.9636629999999999,
+                    0.528895,
+                ],
+            }
+        ),
+        "X",
+        (".value", "year"),
+        "-",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "indexer": [0, 1],
+                "S_1": [1, 1],
+                "S_2": [0, 1],
+                "S_3": ["0", np.nan],
+                "S_4": ["1", np.nan],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "indexer": [0, 0, 0, 0, 1, 1, 1, 1],
+                "num": [1, 2, 3, 4, 1, 2, 3, 4],
+                "S": [1, 0, 0, 1, 1, 1, np.nan, np.nan],
+            }
+        ),
+        "indexer",
+        (".value", "num"),
+        "_",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "county": [1001, 1003, 1005],
+                "area": [275, 394, 312],
+                "pop_2006": [1037, 2399, 1638],
+                "pop_2007": [1052, 2424, 1647],
+                "pop_2008": [1102, 2438, 1660],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "county": [
+                    1001,
+                    1001,
+                    1001,
+                    1003,
+                    1003,
+                    1003,
+                    1005,
+                    1005,
+                    1005,
+                ],
+                "area": [275, 275, 275, 394, 394, 394, 312, 312, 312],
+                "year": [2006, 2007, 2008, 2006, 2007, 2008, 2006, 2007, 2008],
+                "pop": [1037, 1052, 1102, 2399, 2424, 2438, 1638, 1647, 1660],
+            }
+        ),
+        ["county", "area"],
+        (".value", "year"),
+        "_",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "family": [1, 2, 3, 4, 5],
+                "dob_child1": [
+                    "1998-11-26",
+                    "1996-06-22",
+                    "2002-07-11",
+                    "2004-10-10",
+                    "2000-12-05",
+                ],
+                "dob_child2": [
+                    "2000-01-29",
+                    np.nan,
+                    "2004-04-05",
+                    "2009-08-27",
+                    "2005-02-28",
+                ],
+                "gender_child1": [1, 2, 2, 1, 2],
+                "gender_child2": [2.0, np.nan, 2.0, 1.0, 1.0],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {
+                    "family": 1,
+                    "child": "child1",
+                    "dob": "1998-11-26",
+                    "gender": 1.0,
+                },
+                {
+                    "family": 1,
+                    "child": "child2",
+                    "dob": "2000-01-29",
+                    "gender": 2.0,
+                },
+                {
+                    "family": 2,
+                    "child": "child1",
+                    "dob": "1996-06-22",
+                    "gender": 2.0,
+                },
+                {
+                    "family": 2,
+                    "child": "child2",
+                    "dob": np.nan,
+                    "gender": np.nan,
+                },
+                {
+                    "family": 3,
+                    "child": "child1",
+                    "dob": "2002-07-11",
+                    "gender": 2.0,
+                },
+                {
+                    "family": 3,
+                    "child": "child2",
+                    "dob": "2004-04-05",
+                    "gender": 2.0,
+                },
+                {
+                    "family": 4,
+                    "child": "child1",
+                    "dob": "2004-10-10",
+                    "gender": 1.0,
+                },
+                {
+                    "family": 4,
+                    "child": "child2",
+                    "dob": "2009-08-27",
+                    "gender": 1.0,
+                },
+                {
+                    "family": 5,
+                    "child": "child1",
+                    "dob": "2000-12-05",
+                    "gender": 2.0,
+                },
+                {
+                    "family": 5,
+                    "child": "child2",
+                    "dob": "2005-02-28",
+                    "gender": 1.0,
+                },
+            ]
+        ),
+        "family",
+        (".value", "child"),
+        "_",
+    ),
+    (
+        pd.DataFrame(
+            [
+                {
+                    "id": "A",
+                    "Q1r1_pepsi": 1,
+                    "Q1r1_cola": 0,
+                    "Q1r2_pepsi": 1,
+                    "Q1r2_cola": 0,
+                },
+                {
+                    "id": "B",
+                    "Q1r1_pepsi": 0,
+                    "Q1r1_cola": 0,
+                    "Q1r2_pepsi": 1,
+                    "Q1r2_cola": 1,
+                },
+                {
+                    "id": "C",
+                    "Q1r1_pepsi": 1,
+                    "Q1r1_cola": 1,
+                    "Q1r2_pepsi": 1,
+                    "Q1r2_cola": 1,
+                },
+            ]
+        ),
+        pd.DataFrame(
+            [
+                {"id": "A", "brand": "pepsi", "Q1r1": 1, "Q1r2": 1},
+                {"id": "A", "brand": "cola", "Q1r1": 0, "Q1r2": 0},
+                {"id": "B", "brand": "pepsi", "Q1r1": 0, "Q1r2": 1},
+                {"id": "B", "brand": "cola", "Q1r1": 0, "Q1r2": 1},
+                {"id": "C", "brand": "pepsi", "Q1r1": 1, "Q1r2": 1},
+                {"id": "C", "brand": "cola", "Q1r1": 1, "Q1r2": 1},
+            ]
+        ),
+        "id",
+        (".value", "brand"),
+        "_",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "event": [1, 2],
+                "url_1": ["g1", "g3"],
+                "name_1": ["dc", "nyc"],
+                "url_2": ["g2", "g4"],
+                "name_2": ["sf", "la"],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "event": [1, 1, 2, 2],
+                "item": [1, 2, 1, 2],
+                "url": ["g1", "g2", "g3", "g4"],
+                "name": ["dc", "sf", "nyc", "la"],
+            }
+        ),
+        "event",
+        (".value", "item"),
+        "_",
+    ),
+]
+
+multi_index_df = [
+    pd.DataFrame(
+        pd.DataFrame(
+            {
+                "name": {
+                    (67, 56): "Wilbur",
+                    (80, 90): "Petunia",
+                    (64, 50): "Gregory",
+                }
+            }
+        )
+    ),
+    pd.DataFrame(
+        {
+            ("name", "a"): {0: "Wilbur", 1: "Petunia", 2: "Gregory"},
+            ("names", "aa"): {0: 67, 1: 80, 2: 64},
+            ("more_names", "aaa"): {0: 56, 1: 90, 2: 50},
+        }
+    ),
+    pd.DataFrame(
+        {
+            ("name", "a"): {
+                (0, 2): "Wilbur",
+                (1, 3): "Petunia",
+                (2, 4): "Gregory",
+            },
+            ("names", "aa"): {(0, 2): 67, (1, 3): 80, (2, 4): 64},
+            ("more_names", "aaa"): {(0, 2): 56, (1, 3): 90, (2, 4): 50},
+        }
+    ),
+]
+
+multiple_values_sep = [
+    (
+        pd.DataFrame(
+            {
+                "country": ["United States", "Russia", "China"],
+                "vault_2012": [48.1, 46.4, 44.3],
+                "floor_2012": [45.4, 41.6, 40.8],
+                "vault_2016": [46.9, 45.7, 44.3],
+                "floor_2016": [46.0, 42.0, 42.1],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 48.1,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 45.4,
+                },
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 46.9,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 46.0,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 46.4,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 41.6,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 45.7,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 42.0,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 44.3,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 40.8,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 44.3,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 42.1,
+                },
+            ]
+        ),
+        "country",
+        ("event", "year"),
+        "_",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "country": ["United States", "Russia", "China"],
+                "vault_2012_f": [
+                    48.132,
+                    46.36600000000001,
+                    44.266000000000005,
+                ],
+                "vault_2012_m": [46.632, 46.86600000000001, 48.316],
+                "vault_2016_f": [
+                    46.86600000000001,
+                    45.733000000000004,
+                    44.332,
+                ],
+                "vault_2016_m": [45.865, 46.033, 45.0],
+                "floor_2012_f": [45.36600000000001, 41.599, 40.833],
+                "floor_2012_m": [45.266000000000005, 45.308, 45.133],
+                "floor_2016_f": [45.998999999999995, 42.032, 42.066],
+                "floor_2016_m": [43.757, 44.766000000000005, 43.799],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 48.132,
+                },
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 46.632,
+                },
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 46.86600000000001,
+                },
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 45.865,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 45.36600000000001,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 45.266000000000005,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 45.998999999999995,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 43.757,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 46.36600000000001,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 46.86600000000001,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 45.733000000000004,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 46.033,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 41.599,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 45.308,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 42.032,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 44.766000000000005,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 44.266000000000005,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 48.316,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 44.332,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 45.0,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "f",
+                    "score": 40.833,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2012,
+                    "gender": "m",
+                    "score": 45.133,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "f",
+                    "score": 42.066,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2016,
+                    "gender": "m",
+                    "score": 43.799,
+                },
+            ]
+        ),
+        "country",
+        ("event", "year", "gender"),
+        "_",
+    ),
+]
+
+multiple_values_pattern = [
+    (
+        pd.DataFrame(
+            {
+                "country": ["United States", "Russia", "China"],
+                "vault2012": [48.132, 46.36600000000001, 44.266000000000005],
+                "floor2012": [45.36600000000001, 41.599, 40.833],
+                "vault2016": [46.86600000000001, 45.733000000000004, 44.332],
+                "floor2016": [45.998999999999995, 42.032, 42.066],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 48.132,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 45.36600000000001,
+                },
+                {
+                    "country": "United States",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 46.86600000000001,
+                },
+                {
+                    "country": "United States",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 45.998999999999995,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 46.36600000000001,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 41.599,
+                },
+                {
+                    "country": "Russia",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 45.733000000000004,
+                },
+                {
+                    "country": "Russia",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 42.032,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2012,
+                    "score": 44.266000000000005,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2012,
+                    "score": 40.833,
+                },
+                {
+                    "country": "China",
+                    "event": "vault",
+                    "year": 2016,
+                    "score": 44.332,
+                },
+                {
+                    "country": "China",
+                    "event": "floor",
+                    "year": 2016,
+                    "score": 42.066,
+                },
+            ]
+        ),
+        "country",
+        ("event", "year"),
+        r"([A-Za-z]+)(\d+)",
+    )
+]
+
+
+# https://community.rstudio.com/t/pivot-longer-on-multiple-column-sets-pairs/43958/10
+paired_columns_no_index_pattern = [
+    (
+        pd.DataFrame(
+            {
+                "off_loc": ["A", "B", "C", "D", "E", "F"],
+                "pt_loc": ["G", "H", "I", "J", "K", "L"],
+                "pt_lat": [
+                    100.07548220000001,
+                    75.191326,
+                    122.65134479999999,
+                    124.13553329999999,
+                    124.13553329999999,
+                    124.01028909999998,
+                ],
+                "off_lat": [
+                    121.271083,
+                    75.93845266,
+                    135.043791,
+                    134.51128400000002,
+                    134.484374,
+                    137.962195,
+                ],
+                "pt_long": [
+                    4.472089953,
+                    -144.387785,
+                    -40.45611048,
+                    -46.07156181,
+                    -46.07156181,
+                    -46.01594293,
+                ],
+                "off_long": [
+                    -7.188632000000001,
+                    -143.2288569,
+                    21.242563,
+                    40.937416999999996,
+                    40.78472,
+                    22.905889000000002,
+                ],
+            }
+        ),
+        pd.DataFrame(
+            [
+                {
+                    "set": "off",
+                    "loc": "A",
+                    "lat": 121.271083,
+                    "long": -7.188632000000001,
+                },
+                {
+                    "set": "pt",
+                    "loc": "G",
+                    "lat": 100.07548220000001,
+                    "long": 4.472089953,
+                },
+                {
+                    "set": "off",
+                    "loc": "B",
+                    "lat": 75.93845266,
+                    "long": -143.2288569,
+                },
+                {
+                    "set": "pt",
+                    "loc": "H",
+                    "lat": 75.191326,
+                    "long": -144.387785,
+                },
+                {
+                    "set": "off",
+                    "loc": "C",
+                    "lat": 135.043791,
+                    "long": 21.242563,
+                },
+                {
+                    "set": "pt",
+                    "loc": "I",
+                    "lat": 122.65134479999999,
+                    "long": -40.45611048,
+                },
+                {
+                    "set": "off",
+                    "loc": "D",
+                    "lat": 134.51128400000002,
+                    "long": 40.937416999999996,
+                },
+                {
+                    "set": "pt",
+                    "loc": "J",
+                    "lat": 124.13553329999999,
+                    "long": -46.07156181,
+                },
+                {
+                    "set": "off",
+                    "loc": "E",
+                    "lat": 134.484374,
+                    "long": 40.78472,
+                },
+                {
+                    "set": "pt",
+                    "loc": "K",
+                    "lat": 124.13553329999999,
+                    "long": -46.07156181,
+                },
+                {
+                    "set": "off",
+                    "loc": "F",
+                    "lat": 137.962195,
+                    "long": 22.905889000000002,
+                },
+                {
+                    "set": "pt",
+                    "loc": "L",
+                    "lat": 124.01028909999998,
+                    "long": -46.01594293,
+                },
+            ]
+        ),
+        ("set", ".value"),
+        "(.+)_(.+)",
+    )
+]
+
+names_single_value = [
+    (
+        pd.DataFrame(
+            {
+                "event": [1, 2],
+                "url_1": ["g1", "g3"],
+                "name_1": ["dc", "nyc"],
+                "url_2": ["g2", "g4"],
+                "name_2": ["sf", "la"],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "event": [1, 1, 2, 2],
+                "url": ["g1", "g2", "g3", "g4"],
+                "name": ["dc", "sf", "nyc", "la"],
+            }
+        ),
+        "event",
+        "(.+)_.",
+    ),
+    (
+        pd.DataFrame(
+            {
+                "id": [1, 2, 3],
+                "x1": [4, 5, 6],
+                "x2": [5, 6, 7],
+                "y1": [7, 8, 9],
+                "y2": [10, 11, 12],
+            }
+        ),
+        pd.DataFrame(
+            {
+                "id": [1, 1, 2, 2, 3, 3],
+                "x": [4, 5, 5, 6, 6, 7],
+                "y": [7, 10, 8, 11, 9, 12],
+            }
+        ),
+        "id",
+        "(.).",
+    ),
+]
+
+index_labels = [pd.Index(["region"]), {"2007", "region"}]
+column_labels = [{"region": 2007}, {"2007", "2009"}]
+names_to_labels = [1, {12, "newnames"}]
+
+index_does_not_exist = ["Region", [2007, "region"]]
+column_does_not_exist = ["two thousand and seven", ("2007", 2009)]
+
+index_type_checks = (
+    (frame, index) for frame, index in product([df_checks], index_labels)
+)
+column_type_checks = (
+    (frame, column_name)
+    for frame, column_name in product([df_checks], column_labels)
+)
+names_to_type_checks = (
+    (frame, names_to)
+    for frame, names_to in product([df_checks], names_to_labels)
+)
+
+names_to_sub_type_checks = (
+    (df_checks, (1, "rar")),
+    (df_checks, [{"set"}, 20]),
+)
+
+index_presence_checks = (
+    (frame, index)
+    for frame, index in product([df_checks], index_does_not_exist)
+)
+column_presence_checks = (
+    (frame, column_name)
+    for frame, column_name in product([df_checks], column_does_not_exist)
+)
+
+names_sep_not_required = [
+    (df_checks, "rar", "_"),
+    (df_checks, ["blessed"], ","),
+]
+
+names_sep_type_check = [
+    (df_checks, ["rar", "bar"], 1),
+    (df_checks, ("rar", "ragnar"), ["\\d+"]),
+]
+names_pattern_type_check = [
+    (df_checks, "rar", 1),
+    (df_checks, ["rar"], ["\\d+"]),
+]
+
+
+@pytest.mark.parametrize("df,index", index_type_checks)
+def test_type_index(df, index):
+    "Raise TypeError if wrong type is provided for the `index`."
+    with pytest.raises(TypeError):
+        df.pivot_longer(index=index)
+
+
+@pytest.mark.parametrize("df,column", column_type_checks)
+def test_type_column_names(df, column):
+    "Raise TypeError if wrong type is provided for `column_names`."
+    with pytest.raises(TypeError):
+        df.pivot_longer(column_names=column)
+
+
+@pytest.mark.parametrize("df,names_to", names_to_type_checks)
+def test_type_names_to(df, names_to):
+    "Raise TypeError if wrong type is provided for `names_to`."
+    with pytest.raises(TypeError):
+        df.pivot_longer(names_to=names_to)
+
+
+@pytest.mark.parametrize("df,names_to", names_to_sub_type_checks)
+def test_subtype_names_to(df, names_to):
+    """
+    Raise TypeError if `names_to` is a list/tuple and the wrong type is
+    provided for entries in `names_to`.
+    """
+    with pytest.raises(TypeError):
+        df.pivot_longer(names_to=names_to)
+
+
+@pytest.mark.parametrize("df,index", index_presence_checks)
+def test_presence_index(df, index):
+    "Raise ValueError if labels in `index` do not exist."
+    with pytest.raises(ValueError):
+        df.pivot_longer(index=index)
+
+
+@pytest.mark.parametrize("df,column", column_presence_checks)
+def test_presence_columns(df, column):
+    "Raise ValueError if labels in `column_names` do not exist."
+    with pytest.raises(ValueError):
+        df.pivot_longer(column_names=column)
+
+
+@pytest.mark.parametrize("df,names_to, names_sep", names_sep_not_required)
+def test_name_sep_names_to_len(df, names_to, names_sep):
+    """
+    Raise ValueError if the `names_to` is a string, or `names_to` is a
+    list/tuple, its length is one, and `names_sep` is provided.
+    """
+    with pytest.raises(ValueError):
+        df.pivot_longer(names_to=names_to, names_sep=names_sep)
+
+
+@pytest.mark.parametrize("df,names_to, names_sep", names_sep_type_check)
+def test_name_sep_wrong_type(df, names_to, names_sep):
+    "Raise TypeError if the wrong type provided for `names_sep`."
+    with pytest.raises(TypeError):
+        df.pivot_longer(names_to=names_to, names_sep=names_sep)
+
+
+@pytest.mark.parametrize(
+    "df,names_to, names_pattern", names_pattern_type_check
+)
+def test_name_pattern_wrong_type(df, names_to, names_pattern):
+    "Raise TypeError if the wrong type provided for `names_pattern`."
+    with pytest.raises(TypeError):
+        df.pivot_longer(names_to=names_to, names_pattern=names_pattern)
+
+
+@pytest.mark.parametrize("df", multi_index_df)
+def test_warning_multi_index(df):
+    "Raise Warning if dataframe is a MultiIndex."
+    with pytest.warns(UserWarning):
+        df.pivot_longer()
+
+
+def test_length_mismatch():
+    """
+    Raise error if `names_to` is a list/tuple and its length
+    does not match the number of extracted columns.
+    """
+    data = pd.DataFrame(
+        {
+            "country": ["United States", "Russia", "China"],
+            "vault_2012_f": [48.132, 46.36600000000001, 44.266000000000005],
+            "vault_2012_m": [46.632, 46.86600000000001, 48.316],
+            "vault_2016_f": [46.86600000000001, 45.733000000000004, 44.332],
+            "vault_2016_m": [45.865, 46.033, 45.0],
+            "floor_2012_f": [45.36600000000001, 41.599, 40.833],
+            "floor_2012_m": [45.266000000000005, 45.308, 45.133],
+            "floor_2016_f": [45.998999999999995, 42.032, 42.066],
+            "floor_2016_m": [43.757, 44.766000000000005, 43.799],
+        }
+    )
+    with pytest.raises(ValueError):
+        data.pivot_longer(names_to=["event", "year"], names_sep="-")
+
+
+def test_duplicate_dot_value():
+    "Raise error if `names_to` contains more than 1 `.value."
+    data = pd.DataFrame(
+        {
+            "off_loc": ["A", "B", "C", "D", "E", "F"],
+            "pt_loc": ["G", "H", "I", "J", "K", "L"],
+            "pt_lat": [
+                100.07548220000001,
+                75.191326,
+                122.65134479999999,
+                124.13553329999999,
+                124.13553329999999,
+                124.01028909999998,
+            ],
+            "off_lat": [
+                121.271083,
+                75.93845266,
+                135.043791,
+                134.51128400000002,
+                134.484374,
+                137.962195,
+            ],
+            "pt_long": [
+                4.472089953,
+                -144.387785,
+                -40.45611048,
+                -46.07156181,
+                -46.07156181,
+                -46.01594293,
+            ],
+            "off_long": [
+                -7.188632000000001,
+                -143.2288569,
+                21.242563,
+                40.937416999999996,
+                40.78472,
+                22.905889000000002,
+            ],
+        }
+    )
+    with pytest.raises(ValueError):
+        data.pivot_longer(
+            names_to=[".value", ".value"], names_pattern="(.+)_(.+)"
+        )
+
+
+def test_both_names_sep_and_pattern():
+    "Raise ValueError if both `names_sep` and `names_pattern` is provided."
+    with pytest.raises(ValueError):
+        df_checks.pivot_longer(
+            names_to=["rar", "bar"], names_sep="-", names_pattern=r"\\d+"
+        )
+
+
+def test_values_to():
+    "Raise TypeError if the wrong type is provided for `values_to`."
+    with pytest.raises(TypeError):
+        df_checks.pivot_longer(values_to=["salvo"])
+
+
+def test_pivot_no_args_passed():
+    "Test output if no arguments are passed."
+    df_no_args = pd.DataFrame({"name": ["Wilbur", "Petunia", "Gregory"]})
+    df_no_args_output = pd.DataFrame(
+        {
+            "variable": ["name", "name", "name"],
+            "value": ["Wilbur", "Petunia", "Gregory"],
+        }
+    )
+    result = df_no_args.pivot_longer()
+
+    assert_frame_equal(result, df_no_args_output)
+
+
+def test_pivot_index_only(df_checks_output):
+    "Test output if only `index` is passed."
+    result = df_checks.pivot_longer(
+        index="region", names_to="year", values_to="num_nests"
+    )
+    assert_frame_equal(result, df_checks_output)
+
+
+def test_pivot_column_only(df_checks_output):
+    "Test output if only `column_names` is passed."
+    result = df_checks.pivot_longer(
+        column_names=["2007", "2009"], names_to="year", values_to="num_nests"
+    )
+    assert_frame_equal(result, df_checks_output)
+
+
+def test_pivot_index_patterns_only(df_checks_output):
+    "Test output if the `patterns` function is passed to `index`."
+    result = df_checks.pivot_longer(
+        index=patterns(r"[^\d+]"), names_to="year", values_to="num_nests"
+    )
+    assert_frame_equal(result, df_checks_output)
+
+
+def test_pivot_columns_patterns_only(df_checks_output):
+    "Test output if the `patterns` function is passed to `column_names`."
+    result = df_checks.pivot_longer(
+        column_names=patterns(r"\d+"), names_to="year", values_to="num_nests"
+    )
+    assert_frame_equal(result, df_checks_output)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,index,names_to,names_pattern", paired_columns_pattern
+)
+def test_extract_column_names_pattern(
+    df_in, df_out, index, names_to, names_pattern
+):
+    """
+    Test output if `.value` is in the `names_to` argument and
+    names_pattern is used.
+    """
+    result = df_in.pivot_longer(
+        index=index, names_to=names_to, names_pattern=names_pattern
+    )
+    assert_frame_equal(result, df_out)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,index,names_to,names_sep", paired_columns_sep
+)
+def test_extract_column_names_sep(df_in, df_out, index, names_to, names_sep):
+    """
+    Test output if `.value` is in the `names_to` argument and names_sep
+    is used.
+    """
+    result = df_in.pivot_longer(
+        index=index, names_to=names_to, names_sep=names_sep
+    )
+    assert_frame_equal(result, df_out)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,index,names_to,names_sep", multiple_values_sep
+)
+def test_multiple_values_sep(df_in, df_out, index, names_to, names_sep):
+    """
+    Test function to extract multiple columns, using the `names_to` and
+    names_sep arguments.
+    """
+    result = df_in.pivot_longer(
+        index=index, names_to=names_to, names_sep=names_sep, values_to="score"
+    )
+    assert_frame_equal(result, df_out)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,index,names_to,names_pattern", multiple_values_pattern
+)
+def test_multiple_values_pattern(
+    df_in, df_out, index, names_to, names_pattern
+):
+    """
+    Test function to extract multiple columns, using the `names_to` and
+    names_pattern arguments.
+    """
+    result = df_in.pivot_longer(
+        index=index,
+        names_to=names_to,
+        names_pattern=names_pattern,
+        values_to="score",
+    )
+    assert_frame_equal(result, df_out)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,names_to,names_pattern", paired_columns_no_index_pattern
+)
+def test_paired_columns_no_index_pattern(
+    df_in, df_out, names_to, names_pattern
+):
+    """
+    Test function where `.value` is in the `names_to` argument, names_pattern
+    is used and no index is supplied.
+    """
+    result = df_in.pivot_longer(names_to=names_to, names_pattern=names_pattern)
+    assert_frame_equal(result, df_out)
+
+
+@pytest.mark.parametrize(
+    "df_in,df_out,index, names_pattern", names_single_value
+)
+def test_single_value(df_in, df_out, index, names_pattern):
+    "Test function where names_to is a string and == `.value`."
+    result = df_in.pivot_longer(
+        index=index, names_to=".value", names_pattern=names_pattern
+    )
+    assert_frame_equal(result, df_out)